Swizzle (свизлинг) — одна из самых приятных мелочей в шейдерных языках: к любому вектору можно обратиться, переставив или продублировав его компоненты — vec.xxy, vec.zx, vec.rgb. В C++ из коробки такого нет, но очень хочется писать движковый и игровой код так же лаконично, как в GLSL. В этой статье — как мы добавили swizzle в векторную математику движка через union и шаблоны, без накладных расходов по памяти и по скорости.
Что в свизлинге тебе моём…
Лучше всего понять, как работает swizzle, — это показать на примерах:
vec3 vec{1.0, 2.0, 3.0};
vec4 a;
vec3 b;
vec2 c;
float d;
b = vec.xyz; // b is now (1.0, 2.0, 3.0)
d = vec[2]; // d is now 3.0
a = vec.xxxx; // a is now (1.0, 1.0, 1.0, 1.0)
c = vec.zx; // c is now (3.0, 1.0)
b = vec.rgb; // b is now (1.0, 2.0, 3.0)
a = vec.yy; // error; incostistent size
Есть несколько вариантов решения: использовать код из библиотеки glm, CxxSwizzle или написать свою реализацию. Библиотеку glm советую всем, кто начинает писать свой движок или хочет разобраться с игровыми движками. Однако код в ней очень специфичный и многословный на мой взгляд — он является наследием трудных 90-х (первый код был написан в июне 1992 года, это больше 31 года назад).
Ещё можно посмотреть реализацию здесь. Там реализовано много дополнительных функций, но в игровом движке обычно уже присутствует большая часть логики, хоть и в другом виде. Это решение хорошее, но, опять же, код там очень многословный. Мы же хотели получить простое и красивое решение, которое можно записать в пяти строчках кода.
Задача свизлинга сама по себе не очень сложная и обычно включается в список вопросов на собеседовании не только в игровых компаниях, правда в несколько другом виде. Перед переходом к коду хочу обратить ваше внимание на несколько моментов, которые мы учитывали при реализации нашего решения:
- Скорость выполнения. Решение не должно вносить вычислительной нагрузки по сравнению с эквивалентными скалярными операциями, не должно жертвовать вычислительной эффективностью ради реализации свизлинга, по возможности сделать на интринсиках SSE.
- Использование несмежных компонентов вектора, например
v0.xxy + v1.xzy. Это требует возможности доступа и выполнения операций над компонентами вектора в пользовательском порядке. - Запись в изменённый вектор с ограничениями (от этого потом отказались, чтобы не раздувать логику, в 99% этим не пользуются): например
v1.yxwy = v0;с условием, что повторные элементы запрещены явно, то есть каждому элементу должно быть присвоено значение только один раз, и ни один элемент не должен получить несколько присваиваний. - Отсутствие дополнительных накладных расходов по памяти: базовый тип не должен измениться в размерах. Это означает, что
vec3будет занимать пространство, эквивалентное хранению трёх элементов своего типа данных, без дополнительных накладных расходов.
Есть несколько различных способов добиться синтаксиса v1.yxwz = v0; без скобок: макросы, использование union и прокси-объекты. В случае с макросами можно скрыть функции, например:
#define yxwz _yxwz() vec4 vecb = veca.yxwz
Проблема вообще любых макросов заключается в том, что они усложняют отладку, засоряют код и имеют специфику использования. Можно попробовать решить эту проблему с помощью объектов-прокси, содержащих ссылку на исходный вектор, но решение уже выглядит сложным. Плюс, операции с ними могут выполняться через указатель, что снизит общую производительность при работе с такими данными. Ну и, кроме того, я вообще не люблю использовать макросы, если есть возможность обойтись кодом.
Так что после обсуждения в курилке остановились на реализации через union — псевдокод такого union'a будет выглядеть так:
union {
float v[2];
??? xx;
??? xy;
??? yx;
??? yy;
...
};
Чтобы не писать много обёрток и уложить новые типы данных в union, они должны быть plain old data — так проще будет применить фокус со свизлингом. Осталось сделать такую реализацию, чтобы не пришлось писать эти классы руками — пусть трудится компилятор. Выглядеть это будет как-то так (псевдокод):
template<T, X, Y>
struct proxy {
template<T, X2, Y2>
proxy operator = (const SwizzleProxy2<T, X2, Y2> &o) {
((T*)this)[X] = ((T*)&o)[X2];
((T*)this)[Y] = ((T*)&o)[Y2];
return this;
}
};
Тут мы ссылаемся на this, т.е. начало класса, подразумевая, что как самостоятельный тип данных swizzling существовать не будет, а будет только отображаться на память базового класса. Попытка сделать реализацию в лоб, как выяснилось, вызывает приступы ворнингов почти у всех компиляторов и false positive срабатывания статических анализаторов. Но псевдокод выше демонстрирует основную идею реализации операторов для свизлинга с использованием двух элементов: xx, xy, wx и так далее. Аргументы шаблона X и Y могут быть любыми индексами элементов вектора.
Если вы заметили, то собственных данных у класса нет, а такое вольное обращение с указателем на начало класса вызывало не только кучу ворнингов при компиляции первых вариантов под ps5 clang, но и увеличивало время компиляции почти на 10% — с условных 5 до шести минут. Но если задать хотя бы минимальный объект в классе, то время компиляции возвращалось в норму.
template<T, int X, int Y>
struct proxy {
byte v;
...
};
В итоге в класс был добавлен локальный массив, совпадающий по размерам с базовым. Так как все основные операторы уже были реализованы в классах vec2/3/4, то на долю swizzle-класса остаётся только оператор приведения типа к вектору и базовая логика. Итак, первая реализация, как она ушла в движок:
template<template<typename> class TT, typename T, size_t X, size_t Y>
struct swizzle_vec2{
T v[2];
inline TT<T>& operator= (const TT<T>& rhs) {
v[X] = rhs[0];
v[Y] = rhs[1];
return *(TT<T>*)this;
}
inline auto &operator= (const swizzle_vec2& rhs) {
v[X] = rhs.v[X];
v[Y] = rhs.v[Y];
return *this;
}
inline operator TT<T> () const { return TT<T>{v[X], v[Y]}; }
};
И копипаста для каждого из классов vec3/vec4. В движке придерживаются принципа DRY, но для обкатки фичи и быстрого запуска решили улучшайзинг отложить на попозже, собрав отзывы о работе с новым функционалом.
template<template<typename> class TT, typename T, size_t X, size_t Y, size_t Z>
struct swizzle_vec3{
T v[3];
inline TT<T>& operator= (const TT<T>& rhs) {
v[X] = rhs[0];
v[Y] = rhs[1];
v[Z] = rhs[2];
return *(TT<T>*)this;
}
...
inline operator TT<T> () const { return TT<T>{v[X], v[Y], v[Z]}; } // unpack
};
Чтобы свизлинг заработал прозрачно в коде, в каждый класс надо было ещё добавить union-структуры, которые бы это обеспечивали:
// v2 swizzles
#define swizzle_v2 \
swizzle_vec2<T, 2, 0, 0> xx; \
swizzle_vec2<T, 2, 0, 1> xy; \
swizzle_vec2<T, 2, 1, 0> yx; \
swizzle_vec2<T, 2, 1, 1> yy; \
...
template <class T>
struct vec2 {
union {
struct { T x, y; };
swizzle_v2
};
};
Финальный вариант
template<template<typename> class TT, typename T, size_t X, size_t Y>
struct swizzle_vec2 {
T v[2];
inline TT<T>& operator= (const TT<T>& rhs) {
v[X] = rhs[0]; // access pack element 0
v[Y] = rhs[1]; // access pack element 1
return *(TT<T>*)this;
}
inline auto &operator= (const swizzle_vec2& rhs) {
v[X] = rhs.v[X];
v[Y] = rhs.v[Y];
return *this;
}
inline operator TT<T> () const { return TT<T>{v[X], v[Y]}; } // unpack
};
#define swizzle_v2(TT) \
swizzle_vec2<TT, T, 0, 0> xx; \
swizzle_vec2<TT, T, 0, 1> xy; \
swizzle_vec2<TT, T, 1, 0> yx; \
swizzle_vec2<TT, T, 1, 1> yy;
template <class T>
struct vec2 {
union {
struct { T x, y; };
swizzle_v2(vec2)
};
};
int main() {
vec2<float> veca{0, 1};
printf("veca{%f, %f}\n", veca.x, veca.y);
vec2<float> vecb = veca.yx;
printf("vecb{%f, %f}", vecb.x, vecb.y);
return 0;
}
Попробовать можно тут.
Реализация для vec3/vec4 не отличалась сильно ничем, кроме большого макроса финальных swizzle-структур, но такова была цена — захотели упростить жизнь коллегам, значит больше логики в коде.
#define swizzle_v4 \
Swizzle_vec2<TT, T, 0, 0> xx; \
Swizzle_vec2<TT, T, 0, 1> xy; \
Swizzle_vec2<TT, T, 0, 2> xz; \
Swizzle_vec2<TT, T, 0, 3> xw; \
....
Swizzle_vec3<TT, T, 0, 0, 0> xxx; \
Swizzle_vec3<TT, T, 0, 0, 1> xxy; \
Swizzle_vec3<TT, T, 0, 0, 2> xxz; \
Swizzle_vec3<TT, T, 0, 0, 3> xxw; \
Swizzle_vec3<TT, T, 0, 1, 0> xyx; \
.....
Swizzle<TT, T, 0, 0, 0, 0> xxxx; \
Swizzle<TT, T, 0, 0, 0, 1> xxxy; \
Swizzle<TT, T, 0, 0, 0, 2> xxxz; \
Swizzle<TT, T, 0, 0, 0, 3> xxxw; \
Swizzle<TT, T, 0, 0, 1, 0> xxyx; \
Копипаста — очень плохое решение в любом случае, но позволило выпустить этот функционал быстро и собрать фидбек. Решение работало, но знание, что можно сделать лучше и меньшим количеством кода, и убрать копипасту не давало спокойно спать. Позже, на рефакторе, было сделано общее решение и убраны простыни макросов.
Кот копипасту не одобряет!
Утро добрым не бывает
Однажды утром стали падать тесты. Падать стало вот на таком моменте:
...
vec3<float> vecc{1, 2, 3};
printf("vecc{%f, %f, %f}\n", vecc.x, vecc.y, vecc.z);
vec3<float> vecd{0, 0, 0};
vecd.xz = vecc.xz;
printf("vecd{%f, %f, %f}\n", vecd.x, vecd.y, vecd.z); <<<<<<<
...
output:
vecc{1.000000, 2.000000, 3.000000}
vecd{1.000000, 2.000000, 0.000000} -> vecd должен быть {1.0, 0.0, 2.0}
Блейм по исходникам привёл вот к такому изменению в коде:
template<template<typename> class TT, typename T, size_t X, size_t Y>
struct swizzle_vec2{
T v[2];
inline TT<T>& operator= (const TT<T>& rhs) {
v[X] = rhs[0];
v[Y] = rhs[1];
return *(TT<T>*)this;
}
!!!! inline auto &operator= (const swizzle_vec2& rhs) = default; !!!!
inline operator TT<T> () const { return TT<T>{v[X], v[Y]}; }
};
На первый взгляд всё хорошо — это код верен, если бы swizzle_vec2 был полноценным классом со своими данными, а не маппингом на память чужих. Получается, дефолтный оператор копирования выполняет то, для чего предназначен — копирует память из одного места в другое.
inline auto &operator= (const swizzle_vec2& rhs) = default;
->
call memset@PLTS // vecd.xz = vecc.xz
mov rax, qword ptr [rbp - 36] // vecd.xz = vecc.xz
mov qword ptr [rbp - 48], rax // vecd.xz = vecc.xz
Ошибку быстро поправили, заодно выделили время на рефактор кода. В итоге все классы получилось свести к одному шаблонному:
template <typename T, int RSIZE, int... INDEXES>
struct swizzle {
T v[RSIZE];
static constexpr int indexes[] = {INDEXES...};
template <int... INDEXES2>
inline auto &operator=(const swizzle<T, RSIZE, INDEXES2...> &rhs) {
static_assert(sizeof...(INDEXES) == RSIZE, "error: assigning swizzle of different dimensions");
constexpr int rindexes[] = {INDEXES2...};
for(int i = 0; i < sizeof...(INDEXES); ++i) { v[indexes[i]] = rhs.v[rindexes[i]]; }
return *this;
}
inline auto &operator=(const swizzle &rhs) {
for(int i = 0; i < sizeof...(INDEXES); ++i) { v[indexes[i]] = rhs.v[indexes[i]]; }
return *this;
}
};
А для конкретных реализаций под вектора осталась только операция приведения к конкретному типу:
template<class T> struct vec2;
template<class T> struct vec3;
template <typename T, int RSIZE, int... SWIZZLES>
struct swizzle2 : public swizzle<T, RSIZE, SWIZZLES...> {
inline auto &operator= (const vec2<T>& l) {
static_assert (sizeof...(SWIZZLES) == 2, "error: assigning swizzle2 not from vec2f");
v[indexes[0]] = l.x;
v[indexes[1]] = l.y;
return *this;
}
inline operator vec2<T> () const {
static_assert(sizeof...(SWIZZLES) > 1, "error: no data that convert to vec2");
return vec2<T>{v[indexes[0]], v[indexes[1]]};
}
};
template <typename T, int RSIZE, int... SWIZZLES>
struct swizzle3 : public swizzle<T, RSIZE, SWIZZLES...> {
inline auto &operator= (const vec3<T>& l) {
static_assert (sizeof...(SWIZZLES) == 3, "error: assigning swizzle3 not from vec3f");
v[indexes[0]] = l.x;
v[indexes[1]] = l.y;
v[indexes[2]] = l.z;
return *this;
}
inline operator vec3<T> () const {
static_assert(sizeof...(SWIZZLES) > 2, "error: no data that convert to vec3");
return vec3<T>{v[indexes[0]], v[indexes[1]], v[indexes[2]]};
}
};
Для полного счастья остаётся свернуть простыню прокси-структур, которая всё равно присутствует в каждом классе vec2/3/4:
#define _(x) $(x ## xx) $(x ## xy) $(x ## xz) $(x ## yx) $(x ## yy) $(x ## yz) $(x ## zx) $(x ## zy) $(x ## zz)
_(x) _(y) _(z) _(w)
Финальный код классов векторов после этого будет выглядеть вот так:
template <class T>
struct vec2 {
union {
struct { T x, y; };
#define $(name) swizzle2<T, 2, swizzle_idx__(#name, 0), swizzle_idx__(#name, 1)> name;
$(xx) $(xy) $(yx) $(yy)
#undef $
};
};
Код примера положу под спойлер — он не очень интересен, просто рабочая реализация (godbolt).
Пример целиком
template <typename T, int SIZE, int... INDEXES>
struct swizzle {
T v[SIZE];
static constexpr int indexes[] = {INDEXES...};
template <int RSIZE, int... INDEXES2>
inline auto &operator= (const swizzle<T, RSIZE, INDEXES2...>& rhs) {
static_assert (SIZE == RSIZE, "error: assigning swizzle of different dimensions");
constexpr int rindexes[] = {INDEXES2...};
for(int i = 0; i < SIZE; ++i) {
v[indexes[i]] = rhs.v[rindexes[i]];
}
return *this;
}
inline auto &operator= (const swizzle& rhs) {
for(int i = 0; i < SIZE; ++i) {
v[indexes[i]] = rhs.v[indexes[i]];
}
return *this;
}
};
template<class T> struct vec2;
template<class T> struct vec3;
template <typename T, int... SWIZZLES>
struct swizzle2 : public swizzle<T, SWIZZLES...> {
inline swizzle<T, SWIZZLES...> &operator= (const vec2<T>& l) {
static_assert (sizeof...(SWIZZLES) > 1, "error: assigning swizzle2 not from vec2f");
this->v[this->indexes[0]] = l.x;
this->v[this->indexes[1]] = l.y;
return *this;
}
inline operator vec2<T> () const {
static_assert(sizeof...(SWIZZLES) > 1, "error: no data that convert to vec2");
return vec2<T>{this->v[this->indexes[0]], this->v[this->indexes[1]]};
}
};
template <typename T, int SIZE, int... SWIZZLES>
struct swizzle3 : public swizzle<T, SIZE, SWIZZLES...> {
inline swizzle<T, SIZE, SWIZZLES...> &operator= (const vec3<T>& l) {
static_assert (SIZE == 3, "error: assigning swizzle3 not from vec3f");
this->v[this->indexes[0]] = l.x;
this->v[this->indexes[1]] = l.y;
this->v[this->indexes[2]] = l.z;
return *this;
}
inline operator vec3<T> () const {
static_assert (SIZE > 2, "error: no data that convert to vec3");
return vec3<T>{this->v[this->indexes[0]], this->v[this->indexes[1]], this->v[this->indexes[2]]};
}
};
constexpr int inline swizzle_idx__(const char *x, int offset) { switch(*(x+offset)) { case 'x': return 0; case 'y': return 1; case 'z': return 2; case 'w': return 3; } return -1; }
template <class T>
struct vec2 {
union {
struct { T x, y; };
#define $(name) swizzle2<T, 2, swizzle_idx__(#name, 0), swizzle_idx__(#name, 1)> name;
$(xx) $(xy) $(yx) $(yy)
#undef $
};
};
template <class T>
struct vec3 {
union {
struct { T x, y, z; };
#define $(name) swizzle2<T, 2, swizzle_idx__(#name, 0), swizzle_idx__(#name, 1)> name;
$(xx) $(xy) $(xz) $(yx) $(yy) $(yz) $(zx) $(zy) $(zz)
#undef $
#define $(name) swizzle3<T, 3, swizzle_idx__(#name, 0), swizzle_idx__(#name, 1), swizzle_idx__(#name, 2)> name;
#define _(x) $(x ## xx) $(x ## xy) $(x ## xz) $(x ## yx) $(x ## yy) $(x ## yz) $(x ## zx) $(x ## zy) $(x ## zz)
_(x) _(y) _(z) _(w)
#undef _
#undef $
};
};
int main() {
vec2<float> veca{0, 1};
printf("veca{%f, %f}\n", veca.x, veca.y);
vec2<float> vecb = veca.yx;
printf("vecb{%f, %f}\n", vecb.x, vecb.y);
vec3<float> vecc{1, 2, 3};
printf("vecc{%f, %f, %f}\n", vecc.x, vecc.y, vecc.z);
vec3<float> vecd{0, 0, 0};
vecd.xz = vecc.xz;
printf("vecd{%f, %f, %f}\n", vecd.x, vecd.y, vecd.z);
return 0;
}
Бенчмарки
Если мы посмотрим, что делает наш свизлинг в асме, то делает он ровно то, что мы написали, — перекидывает байты из одного места в другое (godbolt).
Для реализованного класса разница между 2/3-свизлингом вполне ожидаема и зависит только от количества операций внутри (quick-bench).
К сожалению, веб-версия не позволяет затащить код из glm и CxxSwizzle, пришлось разворачивать локально. volatile здесь сделан, чтобы компилятор не смог намухлевать с присвоением констант.
float volatile xx;
float volatile x = xx + 1;
float volatile y = xx + 2;
float volatile z = xx + 3;
static void Vec3Swizzling(benchmark::State& state) {
for (auto _ : state) {
vec3<float> veca{x, y, z};
vec3<float> vecb{1, 2, 3};
vecb.xz = veca.xz;
benchmark::DoNotOptimize(veca);
benchmark::DoNotOptimize(vecb);
}
}
BENCHMARK(Vec3Swizzling);
static void Vec3SwizzlingGlm(benchmark::State& state) {
for (auto _ : state) {
glm::vec3<float> vecc{x, y, z};
glm::vec3<float> vecd{0, 0, 0};
vecd.xz = vecc.xz;
benchmark::DoNotOptimize(vecc);
benchmark::DoNotOptimize(vecd);
}
}
BENCHMARK(Vec3SwizzlingGlm);
static void Vec3SwizzlingCxxSwizzle(benchmark::State& state) {
for (auto _ : state) {
cxx::vec3<float> vecc{x, y, z};
cxx::vec3<float> vecd{0, 0, 0};
vecd.xz = vecc.xz;
benchmark::DoNotOptimize(vecc);
benchmark::DoNotOptimize(vecd);
}
}
BENCHMARK(Vec3SwizzlingCxxSwizzle);
По бенчам текущая реализация быстрее реализации в glm в 1.2 раза и CxxSwizzle в два раза. Ускорение достигается за счёт отсутствия лишних проверок, которые есть в этих либах, и лучшей адаптации к структурам данных движка.
Можно ещё ускорить свизлинг, если воспользоваться интринсиками SSE для перемешивания, — тогда в асме это будет вообще одна операция. Есть вопрос, как это всё ляжет в памяти, но это будет тема следующего рефакторинга.
← Все статьи