如何假装 WebGL 拥有几何着色器

之前遇到一个需求,希望可以可视化模型的法线和切线。一般来说,实现这类能力要么在 CPU 组装数据,要么利用几何着色器。因为我希望能够显示蒙皮网格的辅助线,而 Galacean 的骨骼蒙皮都是在着色器中实现的,因此只剩下 GPU 的方案,但 WebGL 偏偏没有几何着色器。事实上,无论是 WebGPU 或是 Metal 都没有几何着色器,但是如果有计算着色器,或者更高级的网格着色器的话也有一些变通的方案,但是在 WebGL 上,这些能力都是不存在的。

一个直接的想法就是把 Vertex Buffer 和 Index Buffer 都作为 Uniform 传递给着色器进行处理,但是 Uniform 的数量是有限制的,因此想到了类似骨骼纹理的方案,但不同的是,这一次是把整个网格的数据全部装到 Texture 里面,这样就可以在着色器通过采样的方式使用这些数据,而且只会占用一个 Uniform 单元。顺着这个逻辑发现,如果实现了这一点,那么就可以很容易实现各种网格处理的方法,因为对于顶点着色器来说,他就可以访问一整个网格的数据,而不局限于单一的一个点。再进一步,利用 TextureArray (或者现代图形 API 才有的 Bindless 特性,甚至都可以不要求每张纹理的大小是相同的),甚至可以把一个超大的场景塞到 GPU 里面去(只要显存足够),然后对整个场景做剔除甚至是光追,思想大致和 几何图像 是类似的。

不扯太远,在实现上也并没用什么高深的算法,核心在于如何对齐地将数据放到 Texture,并且在着色器中正确读取想要的数据进行处理 。最后通过法线渲染和线框渲染这两个例子,展示基于这种做法将如何简化网格处理,由此可以将大量的网格处理算法转移到 GPU 当中实现。

数据上传

顶点属性数据

Vertex Buffer 包含了顶点位置,法线,切线,UV,骨骼权重,骨骼指标等等数据,而且一般来说顶点数量会有几万个,数据的组织方式如下:

为了保持精度我们使用 RGBA32 这样的纹理格式存储这些数据。这样每一次采样都可以获得四个 Float 类型的数据。但是每一个顶点拥有的数据往往会超过 4 个浮点数,所以首先需要将数据按照4进行对齐:

typescript 复制代码
const alignElementCount = Math.ceil(newElementCount / 4) * 4;

另外,顶点一般会有几千到上万个,因此不可能用长是 vertexCount,宽为 1 的纹理进行存储,这里设定纹理每行不能超过 512 个顶点。如果纹理的高最大是 4096,那么也可以存储超过两百万个顶点,非常够用了。因此,我们还需要对顶点的数量按照行的上界进行对齐,得到纹理的大小:

typescript 复制代码
const width = Math.min(vertexCount, NormalMaterial._MAX_TEXTURE_ROWS) * alignElementCount;
const height = Math.ceil(vertexCount / NormalMaterial._MAX_TEXTURE_ROWS);

接下来就是根据对齐补 0,这里我遇到的主要障碍是 Joint 数据,因为 Galacean 当中 Joint 的数据通过 uint8 将 4 个指标数据组合成 float32,在着色器当中获得该浮点数后,不能直接转成 uint 类型,这样就只能得到 0,因为这是字面值的转换,必须使用 floatBitsToInt 进行 bit 层面的转换。但这一函数在低版本的 GLSL 当中是不支持的,为了兼容性我直接将 Joint 的四个数据按照浮点数进行存储。

顶点指标数据

相比于顶点属性,顶点指标要简单的多。为什么顶点指标数据需要额外一张贴图而不能合到上面的数据当中去呢?主要是因为顶点数据存储的是三角形的顶点列表,如果说顶点属性是以顶点为单一元素的,那么顶点指标则是以三角形为元素进行组织的。每个三角形有三个浮点数,由于我们用的是 RGBA32 存储,因此最后一个数值还要补零对齐。纹理宽高的计算方式和上述没有区别。

数据访问

数据访问是特别难调试的问题,必须要理解纹理数据的存储和读取顺序。由于现在的数据都被存在纹理当中了,因此着色器可以看成是 GPU 的通用处理器,并行的数量可以通过 SubMesh 任意控制。例如我们希望绘制法线,那么法线都是线段,并且顶点数量是原有网格顶点数量的两倍,我就可以构造一个Mesh:

typescript 复制代码
createLineMesh(mesh: ModelMesh): ModelMesh {
    const normalMesh = new ModelMesh(this.engine);
    normalMesh.addSubMesh(0, mesh.vertexCount * 2, MeshTopology.Lines);
    return normalMesh;
}

通过 subMesh 当中的 count 控制并行的数量。当然,GPU 的是否并行是由 Wrap 决定的且乱序的,但是着色器当中可以用 gl_VertexID 获取到当前顶点的指标。其实熟悉计算着色器的读者可以发现,这就是 gl_LocalInvocationID 通过这个指标可以知道当前的 kernel 在 thread group 当中的位置。

访问顶点属性

我们继续沿用法线的例子,法线绘制需要的顶点数量是网格顶点数的两倍,所以我们可以对指标除以 2,得到对应网格上真正的顶点指标,例如 100。这意味着该着色器目前处理的就是原有网格上第 100 个顶点。所以我们需要在纹理当中获取该顶点的数据:

c 复制代码
vec4 getVertexElement(float row, float col) {
    return texture2D(u_verticesSampler, vec2((col + 0.5) / u_verticesTextureWidth, (row + 0.5) / u_verticesTextureHeight));
}

void main() {
    int pointIndex = gl_VertexID / 2;
    int row = pointIndex * ELEMENT_COUNT / int(u_verticesTextureWidth);
    int col = pointIndex * ELEMENT_COUNT % int(u_verticesTextureWidth);

    vec4 rows[ELEMENT_COUNT];
    for( int i = 0; i < ELEMENT_COUNT; i++ ) {
      rows[i] = getVertexElement(float(row), float(col + i));
  }
}

这段代码是整个读取数据的核心。更加具体来说,假设每一个顶点拥有的属性包含 16 个浮点数,那么因为每一个纹理采样点上可以存储 4 个,那么每一个顶点都会占据 (16 / 4) 这么多个采样点,即 ELEMENT_COUNT 等于 4。我们将顶点的 index 乘上 ELEMENT_COUNT 就能知道在整个纹理数字当中他的位置,通过取余和取模的运算可以得到该点唯一纹理的哪一行,哪一列。

对于每个顶点,我们都需要采样 4 次才能获得整个顶点属性的数据。纹理可以看成是一个格子,我们需要取格子中点的位置,所以对于行和列,都需要加 0.5 再除以对应的宽高,最终得到所谓的 UV 坐标,并使用点采样的方式获取数据。我们将数据存储到一个数组 rows 当中。通过这组数据,我们将其转成各个属性:

c 复制代码
vec3 POSITION = vec3(rows[0].x, rows[0].y, rows[0].z);        
int row_index = 0;
int value_index = 2;
#ifdef O3_HAS_NORMAL 
vec3 NORMAL = getVec3(rows, row_index, value_index);
#endif

#ifdef O3_HAS_VERTEXCOLOR
vec4 COLOR_0 = getVec4(rows, row_index, value_index);
#endif

#ifdef O3_HAS_WEIGHT
vec4 WEIGHTS_0 = getVec4(rows, row_index, value_index);
#endif

#ifdef O3_HAS_JOINT
vec4 JOINTS_0 = getVec4(rows, row_index, value_index);
#endif

#ifdef O3_HAS_TANGENT
vec4 TANGENT = getVec4(rows, row_index, value_index);
#endif

#ifdef O3_HAS_UV
vec2 TEXCOORD_0 = getVec2(rows, row_index, value_index);
#endif

虽然我们的数据按照每 4 个一组的方式存储,但这只是因为纹理当中每个采样点只能存储 4 个浮点数,采样得到的 4 个 vec,一共 16 个数据都是连续存储的。所以我们通过一个连续的指标 value_index 进行遍历,并且按照每四个增加一的规律递增 row_index:

c 复制代码
vec2 getVec2(inout vec4[ELEMENT_COUNT] rows, inout int row_index, inout int value_index) {
    row_index += (value_index+1)/4;
    value_index = (value_index+1)%4;
    float x = rows[row_index][value_index];
    
    row_index += (value_index+1)/4;
    value_index = (value_index+1)%4;
    float y = rows[row_index][value_index];
    
    return vec2(x, y);
}

访问三角形数据

如果 GPU 计算不需要用到邻居的信息,那类似本文介绍的将整个网格都存储到纹理的方式是比较复杂的,但如果希望获得邻居信息,那么这样的复杂操作就是值得的。对于类似 Wireframe 的绘制来说,一个简单的方式就是通过重心坐标,给每一个网格点都分配一个重心坐标,当进入片段着色器之后,重心坐标会自然被插值,通过插值之后的重心坐标就可以很容易分辨出网格线的边界:

c 复制代码
varying vec3 v_Barycentric;
void main() {
    // 小于边框宽度
    if (any(lessThan(v_Barycentric, vec3(0.1)))) {
        // 边框颜色
        gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0);
    }
    else {
        // 填充背景颜色
        gl_FragColor = vec4(0.5, 0.5, 0.5, 1.0);
    }
}

网络上一些资料的做法是在 CPU 上计算顶点坐标,再塞到 Attribute 当中传递进来,这样的坏处是对于一种特化的渲染需求,就需要取修改网格的数据。如果像这篇文章这样,网格已经通过纹理传递到着色器里面了,那么事情就会变得很简单。

类似法线绘制,我们需要两倍于网格顶点数量 的并行单元,如果要绘制 wireframe 则需要三倍于网格单元数量的并行单元:

c 复制代码
createTriangleMesh(mesh: ModelMesh): ModelMesh {
    const normalMesh = new ModelMesh(this.engine);

    let triangleCount = 0;
    const subMeshes = mesh.subMeshes;
    for (let i = 0; i < subMeshes.length; i++) {
        const subMesh = subMeshes[i];
        triangleCount += subMesh.count / 3;
    }
    normalMesh.addSubMesh(0, triangleCount * 3);
    return normalMesh;
}

所以 gl_VertexID 除以 3 对应的就是三角形单元的编号,记得我们前面说过,顶点指标是按照三角形单元为单位进行组织的,所以我们可以访问顶点指标纹理,获得当前三角形的三个顶点指标,再通过顶点属性纹理读取顶点的数据。

c 复制代码
int indicesIndex = gl_VertexID / 3;
int indicesRow = indicesIndex / int(u_indicesTextureWidth);
int indicesCol = indicesIndex % int(u_indicesTextureWidth);
vec3 triangleIndices = getIndicesElement(float(indicesRow), float(indicesCol));

int subIndex = gl_VertexID % 3;
v_baryCenter = vec3(0.0);
v_baryCenter[subIndex] = 1.0;
int pointIndex = int(triangleIndices[subIndex]);

那么我们需要为每一个网格顶点都设置一个所谓重心坐标,重心坐标的定义大家可以自行了解。简单的说,重心坐标将网格上的位置表示成三个顶点的线性插值的形式,所以三角形的三个顶点上,重心坐标就是 (1, 0, 0), (0, 1, 0), (0, 0, 1),gl_VertexID 因为是 3 的倍数,所以对 3 取模就可以索引对应的三角形顶点,并且按照顺序分配重心坐标即可。从而在片段着色器中获取到的重心坐标,就是插值后的结果了。

案例

在前面介绍的过程中,已经穿插介绍了下面两个案例的一些技术点,包括并行单元的数量等等,这一节再补充一些细节。

法线渲染

每一条法线都有两个点,一个位于网格上,另外一个点则在另外一个点的基础上沿着法线做一定的偏移:

c 复制代码
if (gl_VertexID % 2 == 1) {
    position.xyz += normal * u_lineScale;
}

Wireframe 渲染

Wireframe 渲染最核心的重心坐标我们已经介绍过了,当片段着色器获得了重心坐标就可以计算网格线的宽度,着色细节可以参考 潘与其的文章。最终的代码如下:

c 复制代码
varying vec3 v_baryCenter;

float edgeFactor(){
    vec3 d = fwidth(v_baryCenter);
    vec3 a3 = smoothstep(vec3(0.0), d * 1.5, v_baryCenter);
    return min(min(a3.x, a3.y), a3.z);
}

void main() {
    if (gl_FrontFacing) {
        gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0 - edgeFactor());
    } else {
        // 淡化背面
        gl_FragColor = vec4(0.0, 0.0, 0.0, (1.0 - edgeFactor()) * 0.3);
    }
}

总结

本文介绍了一种将网格做为纹理进行存储的方法,通过这种方法可以将 WebGL 的着色器转换成 GPU 的计算单元,用于处理一系列基于网格数据的计算。这样做的好处,是使得基于网格的计算变得通用,而不需要为了Wireframe 渲染改变原有的网格数据,或者为了法线渲染生成一系列顶点数据。更重要的是,这样非常容易就结合基于 GPU 的骨骼动画等各种 GPU 计算结果。

如何联系我们

Galacean 开源社区群 (钉钉):

Galacean 开源社区群 (微信):

添加群管理员微信:zengxinxin2010, 并备注 "galacean 加群"

网站

官网地址
galacean.antgroup.com

Engine 源码地址
github.com/galacean/en...

Engine Toolkit 源码地址
github.com/galacean/en...

相关推荐
皮皮陶1 天前
Unity WebGL交互通信
unity·交互·webgl
鸢_5 天前
【Threejs】相机控制器动画
javascript·实时互动·动画·webgl
_oP_i6 天前
Unity Addressables 系统处理 WebGL 打包本地资源的一种高效方式
unity·游戏引擎·webgl
新中地GIS开发老师7 天前
WebGIS和WebGL的基本概念介绍和差异对比
学习·arcgis·webgl
_oP_i8 天前
Unity 中使用 WebGL 构建并运行时使用的图片必须使用web服务器上的
前端·unity·webgl
flying robot11 天前
Three.js简化 WebGL 的使用
webgl
小彭努力中11 天前
114. 精灵模型标注场景(贴图)
前端·3d·webgl·贴图
小彭努力中11 天前
109. 工厂光源(环境贴图和环境光)
前端·深度学习·3d·webgl·贴图
小彭努力中12 天前
112. gui辅助调节光源阴影
前端·深度学习·3d·webgl
refineiks13 天前
three.js绘制宽度大于1的线,并动态新增顶点
3d·图形渲染·webgl