查看原文
其他

【第2776期】WebGL 实战之绘制圆角矩形

haocongx 前端早读课 2022-11-07

前言

先了解 WebGL 在项目中的应用。今日前端早读课文章由 @haocongx 分享,公号:WeChatFE 授权。

正文从这开始~~

随着业务发展需要,订阅号消息流中的部分卡片现已完成动态化改造,卡片 UI 渲染的工作由基于 WebGL 实现的渲染器完成。本篇文章将介绍底层的实现原理,核心就是用 WebGL 实现圆角矩形的绘制,基于圆角矩形我们就可以像拼图一般完成整个卡片的绘制。

WebGL 绘制矩形

由于 WebGL 上手难度较高,阅读本文前假定你已了解 WebGL 的基础用法。我们知道使用 WebGL 绘制图形主要依赖 WebGL 程序(WebGL Program)来实现,核心是着色器(Shader)的编写。创建完 WebGL 程序后就可以调用浏览器提供的 WebGL API 来完成 WebGL 上下文的创建,绘制前向 WebGL 程序传递好数据,最后调用绘制指令输出结果。

WebGL 是以顶点和图元来描述图形几何信息的。顶点就是几何图形的顶点,比如,三角形有三个顶点,四边形有四个顶点。图元是 WebGL 可直接处理的图形单元,由 WebGL 的绘图模式决定,有点、线、三角形等等。

WebGL 绘制一个图形的过程,一般需要用到两段着色器,一段叫顶点着色器(Vertex Shader)负责处理图形的顶点信息,另一段叫片元着色器(Fragment Shader)负责处理图形的像素信息。

一段绘制矩形的 Shader 代码示例:

// 顶点着色器
attribute vec4 a_Position;
void main() {
gl_Position = a_Position;
}
// 片元着色器
precision mediump float;
uniform vec4 u_FragColor;
void main() {
gl_FragColor = u_FragColor;
}

创建完 WebGL 程序后接着调用 WebGL API 传入顶点数据和颜色色值(红色为例)就可以在屏幕上输出红色矩形了:

// 传入顶点坐标
var vertexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
-0.5, 0.5, 0.5, 0.5, 0.5, -0.5, // Triangle 1
-0.5, 0.5, 0.5, -0.5, -0.5, -0.5 // Triangle 2
]), gl.STATIC_DRAW);

// 传入颜色值
var u_FragColor = gl.getUniformLocation(gl.program, 'u_FragColor');
gl.uniform4fv(u_FragColor, [1.0, 0.0, 0.0, 1.0]); // rgba

// 清空屏幕
gl.clearColor(0.0, 0.0, 0.0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);

// 输出结果
gl.drawArrays(gl.TRIANGLES, 0, 6);


WebGL 绘制矩形

利用 SDF 实现圆角矩形

下面进入正题,介绍如何在实现矩形的基础上绘制圆角矩形。一个思路是只保留图形边缘轮廓内的像素,轮廓外则用透明度为 0 的像素填充,如下图所示:


绿色圆点代表画布上的像素点

可以看到这个思路的核心就是判断当前绘制的像素点是否位于图形轮廓的内部,如果有一个函数能算出当前点到形状边缘的距离且距离带有符号(正或负),那么我们就能知道点是在轮廓内还是轮廓外了。而这个函数就是:符号距离函数(SDF)。

符号距离函数(signed distance function),简称 SDF,又可以称为定向距离函数(oriented distance function),用来在空间中的一个有限区域上确定一个点到区域边界的最短距离并同时对距离的符号进行定义:点在区域边界内部为负,外部为正(内部为正,外部为负亦可),位于边界上时为 0。

下面贴出一些基础 2D 图形的 SDF 代码:


圆形

/**
* 圆形:1. p表示画布上任意一点
* 2. r为圆的半径
*/

float sdfCircle( vec2 p, float r )
{
// 与圆心距离为 r 的点,在该圆上,SDF 取值 0
return length(p) - r;
}

圆形的 SDF 代码比较好理解,用当前像素点距离圆心的长度减去圆的半径即可。


矩形

/**
* 矩形:1. p表示画布上任意一点
* 2. b为圆角矩形右上角顶点
*/

float sdfBox( in vec2 p, in vec2 b )
{
// abs(p) 是常用技巧,由于原点位于中心,因此四个象限都是相同的,都映射到第一象限处理
// d 表示长方体右上角顶点 b 到当前像素点 p 的向量
vec2 d = abs(p) - b;
// p 点在外部:length(max(d, 0.0)), 在内部则是 min(max(d.x, d.y), 0.0), 这两项总至少有一项为 0
return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0);
}


代码详解,4 种情况分别分析

p 表示画布上任意一点,b 为圆角矩形右上角顶点,代码第一行首先通过 abs (p) 操作将 p 点映射到坐标系的第一象限(x, y 分量为正),因为其他几个象限的计算方式是一样的。接着减去 b 得到 b 点到 p 点的向量 d,也就是 vec2(Px - Bx, Py - By)。接着就是这个公式:length(max(d, 0.0)) + min(max(d.x, d.y), 0.0),看起来不好理解,为了简化分析,如上图所示我们可以将 p 点分四种情况代入公式计算。

p 点落在区域①:

max(vec2(Px - Bx, Py - By), 0.0) = vec2(Px - Bx, 0.0)// 因为 Px - Bx > 0 & Py - By < 0 length(vec2(Px - Bx, 0.0)) = Px - Bx; min(max(Px - Bx, Py - By), 0.0) = 0

故最终结果就是 Px - Bx,这也是区域①内的一点 p 到矩形边界的最短距离。

p 点落在区域②:

max(vec2(Px - Bx, Py - By), 0.0) = vec2(0.0, Py - By)// 因为Px - Bx < 0 & Py - By > 0 length(vec2(0.0, Py - By)) = Py - By; min(max(Px - Bx, Py - By), 0.0) = 0

故最终结果就是 Py - By,这也是区域②内的一点 p 到矩形边界的最短距离。

p 点落在区域③:

max(vec2(Px - Bx, Py - By), 0.0) = vec2(Px - Bx, Py - By)// 因为Px - Bx > 0 & Py - By > 0 length(vec2(Px - Bx, Py - By)) = ((𝑃𝑥−𝐵𝑥)^2+(𝑃𝑦−𝐵𝑦)^2) min(max(Px - Bx, Py - By), 0.0) = 0

故最终结果就是 √((𝑃𝑥−𝐵𝑥)^2+(𝑃𝑦−𝐵𝑦)^2),这也是区域③内的一点 p 到矩形边界的最短距离。

p 点落在区域④:

max(vec2(Px - Bx, Py - By), 0.0) = vec2(0.0, 0.0)// 因为Px - Bx < 0 & Py - By < 0 length(vec2(Px - Bx, Py - By)) = 0.0 min(max(Px - Bx, Py - By), 0.0) = max(Px - Bx, Py - By)

故最终结果就是 max (Px - Bx, Py - By),这也是区域④内的一点 p 到矩形边界的最短距离。

由于 shader 代码不适合进行逻辑运算,iq 大神 (Inigo Quilez) 最终通过一行公式 length(max(d, 0.0)) + min(max(d.x, d.y), 0.0) 完成了矩形 SDF 的计算,不得不膜拜一下大佬。


圆角矩形

/**
* 圆角矩形:1. p 表示画布上任意一点
* 2. b 为圆角矩形右上角顶点
* 3. r 为圆角半径
*/

float sdfRoundedBox( in vec2 p, in vec2 b, in vec4 r )
{
r.xy = (p.x > 0.0) ? r.xy : r.zw;
// 求得圆角矩形在当前像素点所在象限的圆角半径
r.x = (p.y > 0.0) ? r.x : r.y;
vec2 d = abs(p) - b + r.x;
return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0) - r.x;
}

可以看到圆角矩形的 SDF 代码和矩形的很像,其实就是将 “内接” 矩形(圆角圆心作为顶点的内部矩形)的 SDF 结果减去一个常数 r(圆角的半径)即可,看完下图就懂了:


“内接” 矩形 SDF 减去圆角半径

算出 SDF 距离后就能对形状内外的像素透明度做相应处理来初步实现圆角矩形的绘制:

pixel_opacity = distance_to_edge < 0 ? 1 : 0

抗锯齿

为什么说是初步实现?如果只是这么简单的处理像素透明度,可以看到实际渲染出来的圆弧曲线边缘会出现锯齿:下图左边所示。而我们实际想要的是右边这样具有平滑边缘的轮廓。

从上面的代码也可以看到,像素被设置成了完全不透明或完全透明,边缘像素没有进行插值处理。

要实现平滑的边缘也很简单,我们对透明度处理函数进行一个小的改动即可,假定距离的单位是像素:

pixel_opacity = clamp( 0.5 - distance_to_edge, 0, 1 )

可以看到,我们让边缘像素的透明度取值在 0 - 1 之间,任何距离边缘超过 1 个像素的像素点透明度仍然会是 1 或 0,只有距离边缘 1 个像素内的像素点才会进行插值。

下面根据两个具体场景进行分析:如下图所示,左图是图形边缘与像素边缘对齐的场景;右图是图形边缘与像素边缘未对齐的场景。

以下两点背景需要注意:

  • GPU 绘图时像素点具有实际大小,因此具有中心和边缘。

  • 绘制时,GPU 占据每个像素的 “像素中心”(pixel center)位置。

通过带入公式我们可以看到右边 case 边缘像素的不透明度为 0.5,这也是符合预期的,即一半在形状内部和一半在形状外部的像素透明度为 0.5。

随着形状边缘稍微向左或向右移动,该像素的透明度将相应地增加或减少。至此,上文提到的边缘锯齿问题也解决了。

小结

利用 SDF 加抗锯齿算法,我们就可以用 WebGL 绘制出任意的圆角矩形(矩形和圆形也算特殊的圆角矩形),在此基础上我们可以对圆角矩形进行颜色填充或者纹理叠加(文本可转换为纹理)。这样,我们就可以像拼图一般搭建出我们想要的卡片了,以下图为例,卡片可以分解成多个圆角矩形的排列和叠加。

卡片示例

头像

封面图

2d 文本

在此基础上可以继续丰富 Shader 能力,例如支持一些常用滤镜如线性渐变,高斯模糊等,这样就可以绘制更具表现力的卡片了,例如下图展示了线性渐变叠加高斯模糊的效果:

渐变叠加模糊示例

参考资料

  • WebGL fundamentals: https://webglfundamentals.org/

  • 跟月影学可视化: https://time.geekbang.org/column/intro/100053801?tab=intro

  • draw-a-rectangle: https://github.com/bonigarcia/webgl-examples/blob/master/basic_concepts/draw-a-rectangle.html

  • Signed Distance Field: https://zhuanlan.zhihu.com/p/26217154

  • 2D distance functions: https://iquilezles.org/articles/distfunctions2d/

  • 2D 基本图形的 Sign Distance Function (SDF) 详解(上): https://blog.csdn.net/qq_41368247/article/details/106194092

  • Antialiasing with a signed distance field: https://mortoray.com/2015/06/19/antialiasing-with-a-signed-distance-field/

  • Antialiasing For SDF Textures: https://drewcassidy.me/2020/06/26/sdf-antialiasing/

  • Improved Alpha-Tested Magnification for Vector Textures and Special Effects: https://steamcdn-a.akamaihd.net/apps/valve/2007/SIGGRAPH2007_AlphaTestedMagnification.pdf

关于本文
作者:@haocongx
原文:https://mp.weixin.qq.com/s/4uUaFMc6uUOPXE-8cveTFg


稿+vzhgb_f2er

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存