Ненормальное программирование

Магия swizzle из шейдеров в C++

10 декабря 20239 мин

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 года назад).

Ещё можно посмотреть реализацию здесь. Там реализовано много дополнительных функций, но в игровом движке обычно уже присутствует большая часть логики, хоть и в другом виде. Это решение хорошее, но, опять же, код там очень многословный. Мы же хотели получить простое и красивое решение, которое можно записать в пяти строчках кода.

Задача свизлинга сама по себе не очень сложная и обычно включается в список вопросов на собеседовании не только в игровых компаниях, правда в несколько другом виде. Перед переходом к коду хочу обратить ваше внимание на несколько моментов, которые мы учитывали при реализации нашего решения:

  1. Скорость выполнения. Решение не должно вносить вычислительной нагрузки по сравнению с эквивалентными скалярными операциями, не должно жертвовать вычислительной эффективностью ради реализации свизлинга, по возможности сделать на интринсиках SSE.
  2. Использование несмежных компонентов вектора, например v0.xxy + v1.xzy. Это требует возможности доступа и выполнения операций над компонентами вектора в пользовательском порядке.
  3. Запись в изменённый вектор с ограничениями (от этого потом отказались, чтобы не раздувать логику, в 99% этим не пользуются): например v1.yxwy = v0; с условием, что повторные элементы запрещены явно, то есть каждому элементу должно быть присвоено значение только один раз, и ни один элемент не должен получить несколько присваиваний.
  4. Отсутствие дополнительных накладных расходов по памяти: базовый тип не должен измениться в размерах. Это означает, что 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 для перемешивания, — тогда в асме это будет вообще одна операция. Есть вопрос, как это всё ляжет в памяти, но это будет тема следующего рефакторинга.

← Все статьи