Магия swizzle из шейдеров в C++
В шейдере мы можем написать vec3 v0 = v1.xxy * 2
, а также любую другую комбинацию x, y, z и w в зависимости от длины вектора. Я рассматриваю только размеры вектора до 4, как самые распространенные для использования. Полученный вектор может иметь не только ту же самую размерность, но и меньшую или большую, причем его компоненты могут быть скопированы в произвольном порядке. Эта операция называется 'swizzle', и она чертовски удобна для различных операций с малоразмерными векторами, особенно если они представляют игровые сущности в виде позиций, размера или цветов. Вектора используются повсюду в игровых проектах (да и не только в игровых), а не только в шейдерах. В какой-то момент было решено добавить 'swizzle' в наш игровой движок в базовые классы vec2
, vec3
и vec4
. Возникли вопросы: как добиться такого же синтаксического и семантического поведения в C++ коде, при этом минимизируя потери производительности.
Что в свизлинге тебе моем…
Лучше всего понять как работает 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, СxxSwizzle или написать свою реализацию. Библиотеку glm советую всем, кто начинает писать свой или хочет разобраться с игровыми движками. Однако, код в ней очень специфичный и многословный на мой взгляд, он является наследием трудных 90-х (первый код был написан в июне 1992 года, это больше 31 года назад).
Еще можно посмотреть реализацию здесь (https://github.com/gwiazdorrr/CxxSwizzle). Там реализовано много дополнительных функций, но в игровом движке обычно уже присутствует большая часть логики, хоть и в другом виде. Это решение хорошее, но, опять же, код там очень многословный. Мы же хотели получить простое и красивое решение, которое можно записать в пяти строчках кода.
Задача свизлинга сама по себе не очень сложная и обычно включается в список вопросов на собеседовании не только в игровых компаниях, правда в несколько другом виде. Перед переходом к коду хочу обратить ваше внимание на несколько моментов, которые мы учитывали при реализации нашего решения
Скорость выполнения, решение не должно вносить вычислительной нагрузки по сравнению с эквивалентными скалярными операциями. Решение не должно жертвовать вычислительной эффективностью ради реализации свизлинга, по возможности сделать на интринсиках 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 only data, так проще будет применить фокус со свизлингом. Осталось сделать такую реализацию, чтобы не пришлось писать эти классы руками — пусть трудится компилятор. Выглядеть это будет как-то так (псевдокод):
template
struct proxy {
template
proxy operator = (const SwizzleProxy2 &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 сlang, но и увеличивало время компиляции почти на 10%. С условных 5 до шести минут. Но если задать, хотя бы минимальный объект в классе, то время компиляции возвращалось в норму.
template
struct proxy {
byte v;
...
};
В итоге в класс был добавлен локальный массив, совпадающий по размерам с базовым. Так как все основные операторы уже были реализованы в классах vec2/¾, то на долю swizzle класса остается только оператор приведения типа к вектору и базовая логика. Итак первая реализация, как она ушла в движок.
template class TT, typename T, size_t X, size_t Y>
struct swizzle_vec2{
T v[2];
inline TT& operator= (const TT& rhs) {
v[X] = rhs[0];
v[Y] = rhs[1];
return *(TT*)this;
}
inline auto &operator= (const swizzle_vec2& rhs) {
v[X] = rhs.v[X];
v[Y] = rhs.v[Y];
return *this;
}
inline operator TT () const { return TT{v[X], v[Y]}; }
};
И копипаста для каждого из классов vec3/vec4. В движке придерживаются принципа DRY, но для обкатки фичи и быстрого запуска, решили улучшайзинг отложить на попозже, собрав отзывы о работе с новым функционалом.
template class TT, typename T, size_t X, size_t Y, size_t Z>
struct swizzle_vec3{
T v[3];
inline TT& operator= (const TT& rhs) {
v[X] = rhs[0];
v[Y] = rhs[1];
v[Z] = rhs[2];
return *(TT*)this;
}
...
inline operator TT () const { return TT{v[X], v[Y], v[Z]}; } // unpack
};
Чтобы свизлинг заработал прозрачно в коде, в каждый класс надо было еще добавить union-структуры, которые бы это обеспечивали:
// v2 swizzles
#define swizzle_v2 \
swizzle_vec2 xx; \
swizzle_vec2 xy; \
swizzle_vec2 yx; \
swizzle_vec2 yy; \
...
template
struct vec2 {
union {
struct { T x, y; };
swizzle_v2
};
};
Финальный вариант
template class TT, typename T, size_t X, size_t Y>
struct swizzle_vec2 {
T v[2];
inline TT& operator= (const TT& rhs) {
v[X] = rhs[0]; // access pack element 0
v[Y] = rhs[1]; // access pack element 1
return *(TT*)this;
}
inline auto &operator= (const swizzle_vec2& rhs) {
v[X] = rhs.v[X];
v[Y] = rhs.v[Y];
return *this;
}
inline operator TT () const { return TT{v[X], v[Y]}; } // unpack
};
#define swizzle_v2(TT) \
swizzle_vec2 xx; \
swizzle_vec2 xy; \
swizzle_vec2 yx; \
swizzle_vec2 yy;
template
struct vec2 {
union {
struct { T x, y; };
swizzle_v2(vec2)
};
};
int main() {
vec2 veca{0, 1};
printf("veca{%f, %f}\n", veca.x, veca.y);
vec2 vecb = veca.yx;
printf("vecb{%f, %f}", vecb.x, vecb.y);
return 0;
}
Попробовать можно тут (https://godbolt.org/z/rhcocMods)
Реализация для vec3/vec4 не отличалась сильно ничем, кроме большого макроса финальных swizzle-стуктур, но такова была цена — захотели упростить жизнь коллегам, значит больше логики в коде.
#define swizzle_v4 \
Swizzle_vec2 xx; \
Swizzle_vec2 xy; \
Swizzle_vec2 xz; \
Swizzle_vec2 xw; \
....
Swizzle_vec3 xxx; \
Swizzle_vec3 xxy; \
Swizzle_vec3 xxz; \
Swizzle_vec3 xxw; \
Swizzle_vec3 xyx; \
.....
Swizzle xxxx; \
Swizzle xxxy; \
Swizzle xxxz; \
Swizzle xxxw; \
Swizzle xxyx; \
Копипаста очень плохое решение в любом случае, но позволило выпустить этот функционал быстро и собрать фидбек. Решение работало, но знание что можно сделать лучше и меньшим количеством кода, и убрать копипасту не давало спокойно спать. Позже, на рефакторе было сделано общее решение и убраны простыни макросов.
Кот копипасту не одобряет!
Утро добрым не бывает
Однажды утром стали падать тесты. Падать стало вот на таком моменте:
...
vec3 vecc{1, 2, 3};
printf("vecc{%f, %f, %f}\n", vecc.x, vecc.y, vecc.z);
vec3 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 class TT, typename T, size_t X, size_t Y>
struct swizzle_vec2{
T v[2];
inline TT& operator= (const TT& rhs) {
v[X] = rhs[0];
v[Y] = rhs[1];
return *(TT*)this;
}
!!!! inline auto &operator= (const swizzle_vec2& rhs) = default; !!!!
inline operator TT () const { return TT{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
struct swizzle {
T v[RSIZE];
static constexpr int indexes[] = {INDEXES...};
template
inline auto &operator=(const swizzle &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 struct vec2;
template struct vec3;
template
struct swizzle2 : public swizzle {
inline auto &operator= (const vec2& 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 () const {
static_assert(sizeof...(SWIZZLES) > 1, "error: no data that convert to vec2");
return vec2{v[indexes[0]], v[indexes[1]]};
}
};
template
struct swizzle3 : public swizzle {
inline auto &operator= (const vec3& l) {
static_assert (sizeof...(SWIZZLES) == 3, "error: assigning swizzle2 not from vec2f");
v[indexes[0]] = l.x;
v[indexes[1]] = l.y;
v[indexes[2]] = l.z;
return *this;
}
inline operator vec3 () const {
static_assert(sizeof...(SWIZZLES) > 2, "error: no data that convert to vec3");
return vec3{v[indexes[0]], v[indexes[1]], v[indexes[2]]};
}
};
для полного счастья остается свернуть простыню прокси-структур, которая все равно присутствует в каждом классе vec2/¾
#define swizzle_v2 \
swizzle2 xx; \
swizzle2 xy; \
swizzle2 yx; \
swizzle2 yy; \
swizzle3 xxx; \
swizzle3 xxy; \
swizzle3 xyx; \
swizzle3 xyy; \
swizzle3 yxx; \
swizzle3 yxy; \
swizzle3 yyx; \
swizzle3 yyy; \
swizzle4 xxxx; \
swizzle4 xxxy; \
swizzle4 xxyx; \
swizzle4 xxyy; \
swizzle4 xyxx; \
swizzle4 xyxy; \
swizzle4 xyyx; \
swizzle4 xyyy; \
swizzle4 yxxx; \
swizzle4 yxxy; \
swizzle4 yxyx; \
swizzle4 yxyy; \
swizzle4 yyxx; \
swizzle4 yyxy; \
swizzle4 yyyx; \
swizzle4 yyyy;
...
К сожалению убрать макросы без новых макросов не удалось, поэтому приходится пользоваться темной стороны силы.
Используй макросы, Люк!
Если посмотреть на приведенный код, то можно заметить что получаемые значения имеют ограниченный набор входных данных x, y, z, w, более того — каждое положение внутри набора соответствуют конкретному состоянию класса свизлинга
xx -> swizzle2 -> {0, 0}
xy -> swizzle2 -> {0, 1}
yx -> swizzle2 -> {1, 0}
...
эта зависимость может быть выражена через constexpr функцию, например так
constexpr int inline swizzle_idx__(pcstr x, int offset) {
switch(*(x+offset)) { case 'x': return 0; case 'y': return 1;
case 'z': return 2; case 'w': return 3; }
return -1;
}
swizzle2 xx
-> swizzle2
пробуем свернуть это выражение в макрос
swizzle2 xx;
swizzle2 xy;
swizzle2 yx;
swizzle2 yy;
->
#define $(name) swizzle2 name;
$(xx) $(xy) $(yx) $(yy)
с vec3 и vec4 не должно возникнуть сложностей, принцип похожий — определяем макрос для свизлинга в конкретную последовательность:
#define $(name) swizzle3 name;
А вот самих комбинаций тут будет уже больше, поэтому придется поправить макрос для перебора всех возможных вариантов. Финальный код получается вот таким и, чтобы не загромождать хедеры лишними дефайнами, может быть размещен прямо в теле union
#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
struct vec2 {
union {
struct { T x, y; };
#define $(name) swizzle2 name;
$(xx) $(xy) $(yx) $(yy)
#undef $
};
};
код примера положу под спойлер, он не очень интересен, просто рабочая реализация (https://godbolt.org/z/ff55c4YnP)
Пример
template
struct swizzle {
T v[RSIZE]; // тут нужен отдельный размер, потому что свизлинг
// может быть замаплен на класс большего размера
// и компилятор будет выдавать ворниг в этом месте
static constexpr int indexes[] = {INDEXES...};
template
inline auto &operator=(const swizzle& 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 struct vec2;
template struct vec3;
template
struct swizzle2 : public swizzle {
inline auto &operator= (const vec2& l) {
static_assert (sizeof...(SWIZZLES) == 2, "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 () const {
static_assert(sizeof...(SWIZZLES) > 1, "error: no data that convert to vec2");
return vec2{this->v[this->indexes[0]], this->v[this->indexes[1]]};
}
};
template
struct swizzle3 : public swizzle {
inline operator vec3 () const {
static_assert(sizeof...(SWIZZLES) > 2, "error: no data that convert to vec3");
return vec3{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
struct swizzle3 : public swizzle {
inline auto &operator= (const vec3& l) {
static_assert (sizeof...(SWIZZLES) == 3, "error: assigning swizzle2 not from vec2f");
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 () const {
static_assert(sizeof...(SWIZZLES) > 2, "error: no data that convert to vec3");
return vec3{this->v[this->indexes[0]], this->v[this->indexes[1]], this->v[this->indexes[2]]};
}
};
template
struct vec3 {
union {
struct { T x, y, z; };
#define $(name) swizzle2 name;
$(xx) $(xy) $(xz) $(yx) $(yy) $(yz) $(zx) $(zy) $(zz)
#undef $
#define $(name) swizzle3 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 veca{0, 1};
printf("veca{%f, %f}\n", veca.x, veca.y);
vec2 vecb = veca.yx;
printf("vecb{%f, %f}\n", vecb.x, vecb.y);
vec3 vecc{1, 2, 3};
printf("vecc{%f, %f, %f}\n", vecc.x, vecc.y, vecc.z);
vec3 vecd{0, 0, 0};
vecd.xz = vecc.xz;
printf("vecd{%f, %f, %f}\n", vecd.x, vecd.y, vecd.z);
return 0;
}
Бенчмарки
Если мы посмотрим, что делает наш свизлинг в асме, то делает он ровно то, что мы написали — перекидывает байты из одного места в другое (https://godbolt.org/z/9EPe3q4EE)
для реализованного класса, разница между 2/3 свизлингом вполне ожидаема и зависит только от количества операций внутри.
https://quick-bench.com/q/XYSn8lcsfWbp4B5qAuL
К сожалению, веб версия не позволяет затащить код из 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 veca{x, y, z};
vec3 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) {
vec3 vecc{x, y, z};
vec3 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 vecc{x, y, z};
cxx::vec3 vecd{0, 0, 0};
vecd.xz = vecc.xz;
benchmark::DoNotOptimize(vecc);
benchmark::DoNotOptimize(vecd);
}
}
BENCHMARK(Vec3SwizzlingCxxSwizzle);
По бенчам текущая реализация быстрее реализации в glm в 1.2 раза и CxxSwizzle в два раза. Ускорение достигается за счет отсутствие лишних проверок, которые есть в этих либах, и лучшей адаптации к структурам данных движка.
Можно еще ускорить свизлинг, если воспользоваться интринсиками SSE для перемешивания, тогда в асме это будет вообще одна операция. Есть вопрос как это все ляжет в памяти, но это будет тема следующего рефатокторинга.