在讲述本文内容之前,我希望读者先具备以下知识点:
- 了解WebGL的基本知识,懂得调用自定义的Shader程序;
- 基本的数学基础和空间几何知识;
- 明白GPU的渲染管线流程;
因为,本文内容主要讲述绘制的核心思路和注意事项,所以对于基本知识只能简单描述,请谅解;
前言
首先,先附上一篇至今我看到思路十分正确,图文并茂(图片真的很好看)的讲述WebGL绘制有宽度的线和箭头贴图的文章:WebGL绘制有宽度的线 https://www.cnblogs.com/dojo-lzz/p/9219290.html
链接博客的作者基本讲述本文想要讲的主要思路,但是他并没有过多的讲述其他细节。我就在上述的博文中补充一些关键的细节。
在绘制箭头线之前,我们必须掌握如何绘制有宽度的线条,因为,箭头图片的贴图座标与此有关。
绘制有宽度的线
WebGL提供绘制线条的接口中是提供了线条宽度的属性,但是在windows系统中不管设置线宽的属性为多少,都是一条只有一像素宽度的线。因此,我们需要利用绘制三角形的方式去拟合带宽度的线。
从上述两张图片可以简单的理解,如何从一条线去构造宽带;假设我们有以下线的顶点数据 Line = [p0, p1, p2 ...],那么我们需要将顶点沿着直线方向的法线方向(垂直方向上)的正方向和反方向各平移 lineWidth / 2 的距离,这就刚好实现宽度为lineWidth的线条。原理十分的简单。
而我们的顶点数据的在每个顶点处只有一个点,若需要在法线方向上的正反方向上都平移,则我们需要提前把一个顶点复制两份,同时做上标识是正方向还是负方向(因为需要区分三角形的顶点索引顺序),然后传给Shader程序。一般上述的步骤需要在Js里面的准备GLSL顶点数据时进行。
// g 是line线条的每个顶点的数据
for (var j = 0; j < g.vertices.length; j++) {
var v = g.vertices[j]
// positions 是需要传到顶点着色器的顶点座标,这里需要把原有
// 顶点复制两份
this.positions.push(v.x, v.y, v.z)
this.positions.push(v.x, v.y, v.z)
// side是表示顶点平移的方向
this.side.push(1)
this.side.push(-1)
}
上述的思路,还需要计算法线方向,因此,我们在准备顶点数据时,把当前的顶点的上一个节点和后一个节点的数据也存到顶点的Attribute属性中。
var l = this.positions.length / 6 // 这里得到为线的原有长度,未复制顶点前的
var v
// 判断线的起点和终点是否重合,是闭环的话,终点前一个点是起点的前一个顶点,否则前一个点就是起点自己
// compareV3 是根据输入的两个数组中索引的位置,判断是否是同一个vector3向量
// copyV3 是复制点的索引为a的向量
if (this.compareV3(0, l - 1)) {
v = this.copyV3(l - 2)
} else {
v = this.copyV3(0)
}
this.previous.push(v[0], v[1], v[2])
this.previous.push(v[0], v[1], v[2])
// 已经得到起点的前一个点,这个得到后面的其他点的前一个点的位置
for (var j = 0; j < l - 1; j++) {
v = this.copyV3(j)
this.previous.push(v[0], v[1], v[2])
this.previous.push(v[0], v[1], v[2])
}
// 这个得到除终点外的其他点的下一个点的位置
for (var j = 1; j < l; j++) {
v = this.copyV3(j)
this.next.push(v[0], v[1], v[2])
this.next.push(v[0], v[1], v[2])
}
// 判断线的终点和起点是否重合,是闭环的话,终点的后一点是起点的后一个顶点,否则后一个点就是终点自己
if (this.compareV3(l - 1, 0)) {
v = this.copyV3(1)
} else {
v = this.copyV3(l - 1)
}
this.next.push(v[0], v[1], v[2])
this.next.push(v[0], v[1], v[2])
// 设置矩形分割的三角形的顶点索引顺序,已经贴图的顺序集合,记得是逆时针的顺序
for (var j = 0; j < l - 1; j++) {
var n = j * 2
this.indices_array.push(n, n + 1, n + 2)
this.indices_array.push(n + 2, n + 1, n + 3)
}
至此,我们已经得到了计算带宽度的线的所有必须的attributes属性
this._attributes = {
position: new BufferAttribute(new Float32Array(this.positions), 3),
previous: new BufferAttribute(new Float32Array(this.previous), 3),
next: new BufferAttribute(new Float32Array(this.next), 3),
side: new BufferAttribute(new Float32Array(this.side), 1),
width: new BufferAttribute(new Float32Array(this.width), 1),
// uv: new BufferAttribute(new Float32Array(this.uvs), 2), // uvs是一个二元组
index: new BufferAttribute(new Uint16Array(this.indices_array), 1)
}
网上关于绘制有宽度的Shader程序的代码很多,这里就不贴出来了,可以参考Three.js的插件MeshLine,参考链接,https://github.com/spite/THREE.MeshLine
箭头贴图
WebGL的UV贴图(参考文章链接)可以参考上述的链接,这里不做详细介绍。在有宽度的线上绘制箭头纹理,其实十分的简单。只需要解决以下几个问题:
- 箭头图片
- 箭头的间隔(分静态和动态的)
- 贴图座标与顶点座标对应
箭头图片的选取要思考箭头的方向与贴图座标相呼应,不然导致箭头方向不准确。
上述计算纹素大概如下,我们可以规定每隔markerDelta米在halfd(halfd = markerDelta/2)处,已uvDelta长的距离里绘制一个箭头。
因此,计算纹素座标的方法如下:
1. 求出线段的每个顶点与线段的起始座标点的距离,将这个距离存入纹理UV的X座标中(利用在顶点着色器中varying变量在片段着色器的插值保证每个片段都能知道自己在当前线上的长度);
2. uvx对markerDelta取模运算得muvx,求出其所在间隔中的所占的长度,在根据规则(if(muvx >= halfd && muvx <= halfd + uvDelta))计算这个像素是否在uvDelta中,若在表示箭头在此区域,则计算纹素座标。
3. 接下来就是计算纹素座标,因为,箭头的长度肯定为整幅图片,因此,uvy为直接取0或者1.而uvx则可以根据muxv - half 与 uvDelta的比例获取。具体看下面的代码。
从上述内容可以得到,需要传递markerDelta和uvDelta作为uniform参数到片段着色器中参与运算,以及传递Uvs顶点座标作为attributes参数到顶点着色器中计算;
以下是计算UVS座标的JS代码:
var l = this.positions.length / 6 // 这里得到为线的原有长度,未复制顶点前的
// 设置uv映射的纹理映射数组,根据贴图图片的总长度为1,把贴图的宽度分割为等分的份额,高度不变,所以这里会把图片给拉长
// uvs数组是记录线段中每个顶点到线段第一个顶点的距离数据
for (var j = 0; j < l; j++) {
this.uvs.push(uvs[j], 0)
this.uvs.push(uvs[j], 1)
}
以下是渲染箭头贴图的着色器代码:
// uniforms 参数
this.uniforms = {
lineWidth: { type: 'f', value: this.lineWidth },
map: { type: 't', value: this.map },
useMap: { type: 'f', value: this.useMap },
color: { type: 'c', value: this.color },
resolution: { type: 'v2', value: this.resolution }, // 处理顶点的
sizeAttenuation: { type: 'f', value: this.sizeAttenuation },// 是否设置稀疏处理
near: { type: 'f', value: this.near },
far: { type: 'f', value: this.far },
repeat: { type: 'v2', value: this.repeat },
// 自定义贴图的间隔和总长
// 如果是想要静态的效果,可以写死为某个常量
uvdelta: { type: 'f', value: this.uvdelta },
markerdelta: { type: 'f', value: this.markerdelta }
}
var fragmentShaderSource =
'#extension GL_OES_standard_derivatives : enable',
'precision mediump float;',
'',
'uniform sampler2D map;',
'uniform float useMap;',
'uniform float uvdelta;', // 代表应该绘制箭头的长度
'uniform float markerdelta;', // 代表线段分割的平均距离
'',
'varying vec2 vUV;',
'varying vec4 vColor;',
'',
'void main() {',
'',
' vec4 c = vColor;',
' if (useMap > 0.0) {',
' vec4 tc = vec4(1.0, 1.0, 1.0, 0.0);',
' float uvx = vUV.x', // 获取uv映射的x座标
' float muvx = mod(uvx, markerdelta);',
' float halfd = markerdelta / 2.0;',
' if(muvx >= halfd && muvx <= halfd + uvdelta) {',
' float s = (muvx - halfd) / uvdelta;',
' tc = texture2D( map, vec2(s, vUV.y));',
' c.xyzw = tc.w >= 0.5 ? tc.xyzw : c.xyzw;', // 如果tc的景深超过0.5就不再变化了,这里可以发挥现象去拓展一下
' }',
' }',
' gl_FragColor = c;',
'}'
总结
文章只讲述了文章开头给出的博客没有提及的部分,建议先阅读其他博客,最后再来阅读本文。
在实际的应用中,因为上述的代码及思路都是以最简单的形式给出,会存在以下需要优化的地方:
1. 线条在拐角处,需要进行插值去使拐角更光滑。参考文献中也给出了一种常见的方法。也可以使用样条曲线去拟合;
2. 箭头图片的使用建议考虑去锯齿,方法百度有很多;
3. glsl代码里有一处计算屏幕宽高比的aspect,这一步不要省略;
关于后面线条描边或者光滑曲线的文章,后面有时间再更新。
参考文献
https://www.cnblogs.com/dojo-lzz/p/9219290.html
https://www.cnblogs.com/dojo-lzz/p/9461506.html
https://blog.csdn.net/a23366192007/article/details/51264454