使用 C++ 模拟 ShaderLanguage 的 swizzle

swizzle 语法

经常编写着色器的同学应该对 swizzle(重排)语法非常熟悉,方便又灵活,可以说是用过一次便回味无穷。

代码

vec4 color = vec4(1.0, 0.5, 0.0, 1.0);
vec3 rgb = color.rgb;        // { 1.0, 0.5, 0.0 }
vec2 xy = color.xy;          // { 1.0, 0.5 }
vec4 bgra = color.bgra;      // { 0.0, 0.5, 1.0, 1.0 }

可惜的是,C++ 中并不存在这样的语法,但是可以利用语法特性来模拟它,基本的思路是使用一个代理类来存储被操作点的引用以及需要操作的位置信息。

知名的 swizzle 实现

GLM

作为图形编程中的常客,GLM 提供了一套和 GLSL 相似的 swizzle 语法,只需要在使用前定义宏 GLM_FORCE_SWIZZLE 即可在向量类中使用了:

代码

#define GLM_FORCE_SWIZZLE
#include <glm/glm.hpp>
glm::vec3 v{1.0f, 2.0f, 3.0f}; v.xy = v.yz; glm::vec3 reverse = v.zyx;

GLM 的实现方式是在类的未命名 union 内部定义一系列预定义的 swizzle 组合代理类,这些类只存储一个标记 vec 类内存起始位置的 char _buffer[1],而需要操作的位置信息则以模板参数形式编译进类型信息本身。

当一个 vec 类被构造时,这些代理类的 _buffer 即被初始化为 vec 实例的内存起始位置,当需要访问代理类的数据时,将 _buffer 转换为 vec 实例化时的数值类型指针,再取出位置信息作为索引即可实现对 vec 数据进行特定模式的访问。

GLM 的 swizzle 实现可以说是非常优雅,在形式和作用上是最还原 GLSL swizzle 语法的。

然而这种实现方式有一个缺点:所有的 swizzle 组合都是预定义的。GLM 的 vec 支持 2,3,4 维度的 swizzle,以 glm::vec3 来举例,它有 3 个元素,则能够组成的 swizzle 组合的总数为:

$$ \begin{aligned} N=\sum_{i=2}^{4} 3^i=117 \\ \end{aligned} $$

也就是说在 glm::vec3 的类定义中会有 117 个类似于 xx, xy, xxx, xyz, xxxx, zyzw 这样的成员(位于未命名 union 内)。虽然它们共用同一块内存,不会增加类的大小,但是代码编辑器的智能补全会将它们一一列举出来,这会让其他的成员变量、函数淹没在这些符号之间,体验上多少有点不好:

注意到 GLM 的绝大多数向量的计算操作都是使用外部函数例如 glm::normalize(v); 而没有将它们写成成员函数,是否也跟这个问题有点关系?

Eigen

Eigen 并没有直接提供 swizzle 语法,但是它的 IndexedView 提供类似的功能:

代码

Eigen::Vector3f v{ 1.0f, 2.0f, 3.0f };
//swz 类型是 Eigen::IndexedView<Eigen::Vector3f, Eigen::Array<int, 2, 1>, Eigen::internal::SingleRange<0>>
auto swz = v({1, 0});
swz = Eigen::Vector2f{4.0f, 5.0f}; 
v({0, 1, 2}) = v({2, 0, 1}); //相当于 v.xyz = v.zxy

我没有细看 Eigen 的源码,但是表面上猜测,IndexedView 类的实现思路基本上也是一种代理的思想,并且它应该将绑定数据的引用和位置信息都保存在了类的数据成员中:

代码

Eigen::Vector3f v{ 1.0f, 2.0f, 3.0f };
constexpr int swz2_size = sizeof(v({0, 1}));                //24 byte
constexpr int swz3_size = sizeof(v({0, 1, 2}));             //24 byte
constexpr int swz4_size = sizeof(v({0, 0, 1, 2}));          //48 byte
constexpr int swz5_size = sizeof(v({0, 0, 1, 1, 2}));       //32 byte
constexpr int swz6_size = sizeof(v({0, 0, 1, 1, 2, 2}));    //40 byte

不同长度的 IndexedView 类的大小是不同的,这说明 IndexedView 类确实将位置信息也保存成为了数据成员。可以看到不同的长度对应的类大小增长很符合 8 字节对齐的特征,但有趣的是长度为 4 时比较反常,经过我的实验,长度为 4 的倍数的 IndexedView 的大小都比较反常,估计是 Eigen 内部的针对性优化导致的。

总的来说,Eigen 的 IndexedView 完全可以满足 swizzle 的功能,但它的主要目标是通用和高效,没有必要为特定的语法作封装。

我的实现

我在编写 point 类时并不知道 GLM 的 swizzle 模块,更不知道 Eigen 的 IndexedView,但是最终实现出来的代码用的思路都相同:用一个代理类作为中间层来进行数据的间接访问。

代理类 exchanger 将位置编译进类型信息中,并保存一个操作数据对象的指针,通过自定义的赋值运算符和类型转换运算符与其他的数据类型进行数据交换,逻辑相当简单。

为了实现使用 xyzw, rgba, stpq 这些标签指定位置信息,我借用 boost preprocessor 库修改每个 swizzle 函数的调用参数为它对应的 index。对于左值操作对象,swizzle 返回一个 exchanger(或 const_exchanger) 类的实例,而如果操作对象是右值,则直接返回一个对应长度的 point 实例(数据拷贝而非引用,避免野指针)。

最终实现的 point 类支持任意长度(实际受限于 boost preprocessor 和编译器限制)和任意位置(代码中支持 0-255,但可以通过在 point_swizzler.hpp 的 POINT_SWIZZLE_CONVERT_PREFIX_255 之后继续添加条目支持更多的位置)的 swizzle.

代码在这里,可以在 test.cpp 中查看使用示例,目前只是提供一种 swizzle 的实现,尚未经过严格测试。

总结

在实现完 point 类之后,我才发现 GLM 和 Eigen 中的类似功能实现,又是一次重复造轮子。但是还是颇有收获的,想当初入门 C++ 时看到模板代码就头疼,现在不管多复杂的库代码也能慢慢剖开分析实现思路,其中很多技巧都是在一次次造轮子中深入掌握的。

总结一下各个实现版本的特点吧:

GLM: 完美还原着色器的 swizzle 语法,但组合是固定的;

Eigen: 支持任意的重排操作,但没有语法上的封装;

我的实现:支持任意的重排操作,但没有完全还原着色器语法。

posted @ 2026-03-10 17:33  saltymilk  阅读(16)  评论(0)    收藏  举报