随着 Web 业务的拓展,我们有时也需要在网页 /H5/ 小程序中渲染 3D 模型,WebGL 标准就有了用武之地。可以认为 WebGL 是 OpenGL API 的 JS 版本,由浏览器厂商对接操作系统 api,实现浏览器环境里使用 GPU 进行实时绘制。由于业务需要,笔者也对 WebGL 进行了一些学习。本文采用卡通渲染作为例子,从零开始实现一个着色器,以加深对渲染管线的理解。
着色器是 3D 渲染流程中很重要的一环,引入了许多抽象概念,初学不易上手,而网上的资料大都是面向游戏开发者的。因此,本文将从前端视角出发,紧密结合用例,从头开始编写一个卡通渲染着色器。如果你对渲染管线感兴趣,但是从未读懂过着色器代码,面对成堆的矩阵坐标和微积分公式头晕脑胀,那么本文可以作为你学习着色器的入门参考。
卡通渲染又叫做非真实渲染(Non-Photorealistic Rendering,NPR),是和基于物理的渲染(Physically Based Rendering,PBR)相对的概念。
在传统的 3D 渲染领域,采用基于物理的 PBR 渲染管线,可以依据物理公式,将材质的特性标准化,以贴近现实的风格进行绘制。但近年来手游市场二次元风的盛行,产生了一个新的风格流派,不追求真实感,而是以贴近 2D 卡通的风格渲染绘制 3D 角色。例如塞尔达、罪恶装备、原神等游戏均属于这一类。
注意,NPR 只是一系列技术的统称,和标准化的 PBR 不同的是,它是一种高度风格化的渲染方式,可以拥有各种各样的风格,但核心的技术有一些共通之处,包括但不限于以下几项:
梯度漫反射(卡通风格阴影)
局部高光
边缘光
描边
本文将从零开始,基于 WebGL,使用 Threejs 组织数据,从零开始编写 demo,逐个实现上述效果。
先放效果(使用的模型来自 sketchfab,在此感谢制作者的无偿分享):
如果你是实时渲染领域的小白,那么首先要对渲染管线有个简单的认识。概括来说,从读取模型数据到绘制的过程,可以用下面这张图表示(摘自 OpenGL 官方文档):
我们的 3D 几何模型和材质贴图,经过顶点着色,片元着色,最后转化成二维的像素数据,绘制在屏幕中。因为处理过程是管道式的,所以称之为渲染管线。
绘制是逐帧进行的,从模型数据准备完毕,发送一次渲染命令,到屏幕上展示出渲染结果,我们称为一次绘制调用(drawCall)。
这条管线的大部分流程都是固定的,但顶点着色器和片元着色器是可编程的。这意味着我们可以自定义这两部分的算法,通过“填空题”让 3D 模型按照我们预期的方式映射成 2D 像素。
渲染引擎里的“材质”,指的就是着色器算法(包括顶点着色器和片元着色器)和数据(包括属性数据和纹理贴图)的集合。给同样的几何体应用不同的材质,就能渲染出不同的效果。
Demo 中使用模型比较简单,只有基础的几何模型和基本颜色贴图(又叫漫反射贴图)。
首先我们需要引入角色的几何模型(对常见模型格式,Threejs 都提供了加载器)。
读取成功后,我们首先使用 Threejs 默认的网状线材质进行渲染。
模型中囊括了顶点、法线、uv 等常用数据。下面的工作就是将材质替换为我们自定义的材质,也就是“着色”。场景需要光源,所以我们需要建立一个固定位置的点光源,不随摄像机移动,后续传进着色器使用。
下图绿色小方块表示光源的位置:
我们使用 Threejs 提供的 ShaderMaterial 类,这是一种开放的材质,需要开发者自行传入着色器代码和参数。核心代码如下:
new THREE.ShaderMaterial({ =
uniforms: {
_MainTex: {
value: new THREE.TextureLoader().load("./xxx_albedo.png")
}
},
vertexShader: vertexShaderToon,
fragmentShader: fragShaderToon
});
材质创建有三个入参,其中顶点着色器(vertexShader,vs)和片元着色器(fragmentShader,fs)分别传入一个代码片段,uniform 则传入静态参数。
顶点着色器——对模型的每个顶点调用一次。比如我们只画一个三角形,需要创建三个顶点,那么顶点着色器就会运行三次,运行次数和模型的复杂度有关。在顶点着色器中,可以访问到顶点的三维坐标、uv 坐标、法向量等信息。这些都是我们模型的固有属性,我们可以在顶点着色器里,通过修改这些值,或者将其传递到片元着色器中。
片元着色器——对每个屏幕像素调用一次的程序。比如我们要把模型绘制在 200*100 的画布上,那么片元着色器就会运行 200*100 次,计算出每个像素最终的颜色。运行次数和模型复杂度无关,只和屏幕分辨率有关。
注意,上述调度执行策略都是渲染管线完成的,和传统 Web 开发“接管全程”的理念不同,我们创建 shader 时,实际上做的是填空题。
在 WebGL 标准下,着色器代码使用 GLSL 语言编写,这是一种专门的强类型语言,它编译之后将直接运行在硬件里。而 uniform 则是我们从 JS 向 GLSL 传参的桥梁。在 JS 侧传入之后,在着色器一侧,就能通过 uniform 声明语句来取用这些参数。此外,这个模型固有的属性(坐标、uv、法向量等),也可以通过 attribute 变量取到。
注意,Threejs 的 ShaderMaterial 有一个属性:
isRawMaterial = false
默认为 false,Three 在组装着色器的时候,会在开头注入一些常用的常量和属性,实际执行的代码比传入的多一些。如果设置为 true,则不注入任何内容,完全交给开发者全权控制。这里我们还是需要一些基础的前置信息,所以不改变默认值。
我们的第一个目标是,从基础纹理上采样,并展示原始的颜色。这个过程相当于把贴图素材沿着 uv 坐标“贴”到模型上去。对应的着色器片段如下:
let vertexShaderToon = `
varying vec2 vUv;
void main()
{
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}`
let fragShaderToon = `
varying vec2 vUv;
uniform sampler2D _MainTex;
void main() {
计算基础漫反射颜色
vec3 albedoColor = texture2D(_MainTex, vUv).rgb;
gl_FragColor = vec4( albedoColor, 1.0);
}`
下面我们逐行理解这些代码:
gl_Position
gl_Position,是顶点着色器的输出,表示每个顶点经过我们自定义的一系列计算之后,得到的结果,它是一个 vec4 类型,前三个值是 xyz 坐标浮点数,最后一个值是为了补齐矩阵行数而设置的固定值 1.0。我们写任何一个顶点着色器,不管代码多复杂,最后都需要设置这个值,渲染管线将在后续步骤中使用它。
projectionMatrix * modelViewMatrix
这是用来计算坐标变换的两个矩阵。如何变换?首先我们要记住下面这个公式:
变换后的坐标 = 视口矩阵 x 投影矩阵 x 视图矩阵 x 模型矩阵 x 模型点坐标
公式最左边,是我们储存的模型数据里所包含的坐标,它们是基于模型本身的坐标系的。把它放在场景里的时候,我们需要指定一个位置,可能还会指定一些缩放,旋转等等,这样一来,模型相对于场景的坐标系,就会有一个偏移,用模型矩阵(modelMatrix)来表示。而我们的摄像机也是有方向的,我们放置模型之后,切换观察角度,看到的东西也不一样,视口的偏移就用视图矩阵(viewMatrix)来表示。
我们知道在一帧的绘制里,model 和 view 坐标是固定的,调用的瞬间,渲染管线就能计算出来,为了简化计算,就把前面两个矩阵合二为一,变成 modelViewMatrix。projectionMatrix 则是投影矩阵,和我们设置 camera 的属性有关(我们知道 Threejs 可以设置正交相机和投影相机,后者会有一些近大远小的效果),摄像机的属性就通过投影矩阵(projectionMatrix)来表达。
视口矩阵则是从摄像机坐标系到窗口(在 webgl 就是我们的 canvas 对象)的变换,通常只是做一些裁减的工作,所以在渲染管线里不必设置。
综上所述,gl_Position 就是我们把模型的坐标(position)变换到视口坐标后的变换结果。前面说过顶点着色器的调用次数是顶点个数,所以每个坐标定点都被算了一遍。算出来的结果将被传递给片元着色器使用。
varying vec2 vUv
vs 和 fs 的前面都有这样的声明语句。varing 是变量类型,如果我们想在 vs 和 fs 之间传递信息,就需要声明一个 varing 类型的值,在 vs 里设置而在 fs 里取用。这里我们设置了 vUv,而它的值就是 uv,和 position 一样是模型的固有属性,这两句代码,相当于把每个顶点的 uv 坐标信息原封不动地透传给 fs。
然后,我们再看片元着色器,它接收了 vs 传入的 uv 坐标,除此之外还接收了一个参数 _MainTex,就是我们基础的贴图,是定义着色器的时候从 js 传入的,你也可以换成喜欢的名字。
那 uniform 和 varing 的区别是什么呢?我的理解是,uniform 在每次渲染前就传入了,所以对于着色器来说,它是一个常量。实际运行时,渲染引擎会把我们传入的纹理素材,从 CPU 拷贝到 GPU 的显存里,然后再去执行代码。而 varing 是在着色器运行过程中才算出来的,所以就是一个变量。
texture2D 是一个默认的采样方法,通过顶点的 uv 坐标,去纹理贴图上做采样,取出对应的颜色值。
而 gl_FragColor,和上面的 gl_Position 类似,是片元着色器的输出,表示经过一系列复杂计算后,每个像素最终的颜色值,它就是渲染管线最终绘制像素的依据。可以说我们所有着色器代码,都是为了计算它。
这里我们取了从 _Maintex 上采样出来的颜色的 rgb 值,加上透明度 1.0,就得到了最终渲染用的颜色值,效果如图:
可以看出,上面的渲染结果看起来还是扁平的,是因为我们还没算光照,只是简单采样了纹理颜色而已。有光照才会有阴影,所以我们的下一步就是来计算最基础的阴影。
修改后的 shader 代码如下:
new THREE.ShaderMaterial({ =
uniforms: {
_MainTex: {
value: new THREE.TextureLoader().load("./xxx_albedo.png")
},
light: {
value: new THREE.Vector3(0, 0, 100)
},
},
vertexShader: vertexShaderToon,
fragmentShader: fragShaderToon
});
let vertexShaderToon = `
varying vec2 vUv;
varying vec3 viewLight;
varying vec3 viewNormal;
uniform vec3 light;
void main()
{
直接传递给fs
vUv = uv;
viewLight = normalize(vec4(light, 1.0).xyz);
转换成视图坐标系,传递给fs
viewNormal = normalize(normalMatrix * normal);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}`;
let fragShaderToon = `
varying vec2 vUv;
varying vec3 viewLight;
varying vec3 viewNormal;
uniform sampler2D _MainTex;
void main() {
计算基础漫反射颜色
vec3 albedoColor = texture2D(_MainTex, vUv).rgb;
计算卡通渲染下的阶梯阴影
float diffuse = dot(viewLight, viewNormal);
if (diffuse > 0.7) {
diffuse = 1.0;
}
else {
diffuse = 0.5;
}
vec3 finalColor = albedoColor * diffuse;
gl_FragColor = vec4( finalColor, 1.0);
}`
首先增加一个变量 light,把光源坐标(xyz)传进去。
之后定义 varing viewLight,把光源坐标进行归一化,补齐位数之后,透传光源数据给 fs。
为什么只进行了归一化和补齐位数,但没有进行坐标变换呢?因为我们希望场景里的光照是固定的,不会随着模型、摄像机的坐标而移动。这样转动模型的时候,才便于观察阴影的变化。
接下来定义 varing viewNormal,把顶点的法线坐标也传递给 fs,这里就需要进行坐标变换了,把它乘以 normalMatrix,从模型坐标变换到视图坐标。
为什么不能继续用 modelViewMatrix 呢?因为法向量表示的是一个方向,而顶点坐标表示的是一个位置,所以这两个变换矩阵不一样。
在 fs 里我们开始计算阴影,计算的依据——光照和模型法线的夹角。这里的原理也非常直观,我们看到物体的颜色和朝向有关系,而物体的朝向用法线表示,那么法线和光源夹角越小,照射到表面上的光就越少,表面越暗。
通过 dot 方法进行点乘,即两个向量的点乘表示夹角大小,结果是 0 和 1 之间的值。我们假设光照强度为 100%(点积为 0)的情况,则显示原本的漫反射颜色,其余情况则叠加一层阴影,将点积作为阴影权重系数 diffuse,就得到修正后的计算公式:
vec3 finalColor = albedoColor * diffuse;
这里我们指定阴影的颜色就是黑灰色,所以用一位浮点数 diffuse 就可以表达了。但在实际情况中也可能用到美术指定的阴影色,随漫反射的颜色变化,感兴趣可以修改试试。
因为我们模型法线是光滑的,通过这种计算,得到的是一个渐变的软阴影。但我们想要做一个硬阴影,就要把 diffuse 阶梯化。小于阈值就取颜色 1,大于就取 0,这样绘制出的阴影就是硬阴影。还可以根据需要改成三值、四值。
最终的渲染结果如下:
接下来我们来画高光,高光的计算方式和阴影有所不同,阴影只和光源方向有关,但高光是随着视角变化的,可以让模型看起来效果更丰富。
添加后的着色器代码如下:
let vertexShaderToon = `
varying vec2 vUv;
varying vec3 viewLight;
varying vec3 viewNormal;
varying vec3 viewPosition;
uniform vec3 light;
void main()
{
直接传递给fs
vUv = uv;
viewLight = normalize(vec4(light, 1.0).xyz);
转换成视图坐标系,传递给fs
viewNormal = normalize(normalMatrix * normal);
viewPosition = ( modelViewMatrix * vec4(position, 1.0)).xyz;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}`
let fragShaderToon = `
varying vec2 vUv;
varying vec3 viewLight;
varying vec3 viewNormal;
varying vec3 viewPosition;
uniform sampler2D _MainTex;
void main() {
计算基础漫反射颜色
vec3 albedoColor = texture2D(_MainTex, vUv).rgb;
计算卡通渲染下的阶梯阴影
float diffuse = dot(viewLight, viewNormal);
if (diffuse > 0.7) {
diffuse = 1.0;
}
else {
diffuse = 0.5;
}
计算高光反射值(Phong模型)
float shininessVal=1.0;
vec3 specularColor = vec3(1.0, 1.0, 1.0);
vec3 R = reflect(-viewLight, viewNormal); // 计算光源沿法线反射后的方向
vec3 V = normalize(-viewPosition); // 计算视线方向
float specAngle = max(dot(R, V), 0.0); // 反射方向和视线方向的夹角(点积)即为高光系数,越接近平行,高光越强烈
float specularFactor = pow(specAngle, shininessVal);
卡通渲染阶梯化处理
if (specularFactor > 0.8) {
specularFactor = 0.5;
}
else {
specularFactor = 0.0;
}
vec3 finalColor = albedoColor * diffuse;
finalColor += specularColor * specularFactor;
gl_FragColor = vec4( finalColor, 1.0);
}`
结合注释理解计算原理——高光由光源反射方向和视线方向的夹角来决定。这个也很好理解,我们假设有一个光滑的物体(比如金属头盔)放在灯泡下,如果我们顺着灯泡的方向看,就能看到很刺眼的亮斑,如果我们换个角度,亮斑也会随之偏移,而且亮度变小了。这也是 Phong 氏光照模型的经典计算方式,虽然简单粗暴,但效果还算准确。
这里我们写死了高光颜色为白色,计算系数为 1.0,夹角则是 R 和 V 两个向量的点积。R 是光源 (viewLight) 沿法线 (viewNormal) 反射后的方向,使用内建函数 reflect 算出。V 是视线方向,是由 viewPosition 算出来的,而 viewPosition 是在 vs 里,将顶点坐标从模型坐标系转换到视图坐标系之后的结果。这里和前文计算 gl_Position 的方式类似,但我们没办法直接用到内建变量,因此要额外定义一个 varing 来存储它。
为什么视线方向是顶点坐标取反呢?原理也很直观,因为视图坐标系下,我们的眼睛就是坐标原点,视线方向就是从原点发射一条射线和顶点相连,它的值就是坐标取反,因为是方向向量,所以取反后还要归一化处理。需要注意的是,R 和 V 的点积可能是负数(发生在靠近边缘的顶点上),所以还需要对负值做一个裁减。裁减后再做阶梯化,就得到了硬阴影的高光系数。
至此,我们的顶点颜色变成:
基础色 + 阴影颜色 x 阴影系数 + 高光颜色 x 高光系数
绘制效果如图:
(因为免费模型精度较低,高光就显得比较硬,感兴趣的朋友可以换更细致的模型进行尝试。)
虽然有阴影和高光,但整体效果看起来还是太死板了,所以下一步我们给它加上边缘光。
Rim Lighting,也可以翻译成轮廓光,作用是提亮模型边缘,也是卡通渲染常用的一种手法。和视角光源、视角都有关系,但不论视角如何变化,照亮的都是模型当前的边缘区域。我们看下给纯黑色的兔子模型添加边缘光的效果:
那如何判断边缘区域?方法也很直观,前面我们已经计算出了视图坐标下的法线,我们知道越靠近边缘的平面,法线和视线越接近垂直,夹角越大。所以我们只要计算法线方向和视线方向的点积,就能算出边缘光系数。
代码如下:
let vertexShaderToon = `
varying vec2 vUv;
varying vec3 viewLight;
varying vec3 viewNormal;
varying vec3 viewPosition;
uniform vec3 light;
void main()
{
直接传递给fs
vUv = uv;
viewLight = normalize(vec4(light, 1.0).xyz);
转换成视图坐标系,传递给fs
viewNormal = normalize(normalMatrix * normal);
viewPosition = ( modelViewMatrix * vec4(position, 1.0)).xyz;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}`
let fragShaderToon = `
varying vec2 vUv;
varying vec3 viewLight;
varying vec3 viewNormal;
varying vec3 viewPosition;
uniform sampler2D _MainTex;
void main() {
计算基础漫反射颜色
vec3 albedoColor = texture2D(_MainTex, vUv).rgb;
计算卡通渲染下的阶梯阴影
float diffuse = dot(viewLight, viewNormal);
if (diffuse > 0.7) {
diffuse = 1.0;
}
else {
diffuse = 0.5;
}
计算高光反射值(Phong模型)
float shininessVal=1.0;
vec3 specularColor = vec3(1.0, 1.0, 1.0);
vec3 R = reflect(-viewLight, viewNormal); // 计算光源沿法线反射后的方向
vec3 V = normalize(-viewPosition); // 计算视线方向
float specAngle = max(dot(R, V), 0.0); // 反射方向和视线方向的夹角(点积)即为高光系数,越接近平行,高光越强烈
float specularFactor = pow(specAngle, shininessVal);
卡通渲染阶梯化处理
if (specularFactor > 0.8) {
specularFactor = 0.5;
}
else {
specularFactor = 0.0;
}
计算rim lighting
vec3 rimColor = vec3(1.0, 0.0, 0.0);
float rimFactor = 0.5;
float rimWidth = 1.0;
float rimAngle = max( dot(viewNormal, V), 0.0); // 视线方向和法线方向的夹角(点积),越接近垂直,越靠近模型边缘
float rimndotv = max(0.0, rimWidth - rimAngle);
vec3 finalColor = albedoColor * diffuse;
finalColor += specularColor * specularFactor;
finalColor += rimColor * rimndotv * rimFactor;
gl_FragColor = vec4( finalColor, 1.0);
}`
如果有需要,也可以把边缘光进行阶梯化,类似于二次元画风里常见的边缘提亮。本文该例子就保持渐变的效果。
加入边缘光后,颜色公式变成:
基础色 + 阴影颜色 x 阴影系数 + 高光颜色 x 高光系数 + 边缘光 x 边缘光系数
绘制效果如下:
可以看出,叠加三种光照之后,绘制效果已经初具雏形,我们继续实践另一种常用的技术——描边。下面分别介绍描边的三种方法:法线夹角法、法线膨胀法、卷积法。
计算 rim lighting 时,我们通过法线可以提取出模型的边缘,既然如此,我们把边缘光二值化,并且改成黑色,不就完成描边了么?的确可以这么做,但渲染结果可能并不令人满意。首先我们的角色模型网格比较复杂,不同区域片元密度不一致,曲率不一致,如果直接用法线来算,得到的边缘宽度不统一,效果就不好。
但法线夹角也有它的优势,即计算简单。无需引入额外步骤,只需要单次绘制流程,所以在渲染一些简单的场景元素时,还是有用武之地的。
渲染是一门实用的技术,没有好坏优劣之分,只有合不合适。开发者的工作,就是结合实际场景,在效率和效果之间寻找一个平衡点。从 Rim Lighting 稍作修改就能得到法线夹角法描边,这里就不再赘述,感兴趣可以自行魔改。
首先回想一下我们在 Photoshop 等图形编辑软件里,给 2D 图像描边的方式:首先选中模型,然后扩展选区 1px,新建一个图层填充黑色,把它放在原图层的下方,完成。
在 3D 场景里,我们也可以做类似的事情,先让模型膨胀一圈,膨胀的方向沿着顶点法线方向。然后将膨胀后的模型渲染为全黑色,最后再渲染原始模型,盖在黑色模型的上方,这就是法线膨胀法。
前面所有着色器里,我们没有修改过 vs 输出的 gl_Position,但我们要渲染膨胀后的全黑模型,就必须修改这个值才行,但这个值被修改之后,我们就没办法在 fs 里继续绘制原始图像了。
怎么办?既然一次画不完,那就改变渲染管线,连续画两次,把结果叠加在一起作为一帧的输出。这种技术在渲染领域被称为多 pass(两次就是 2 pass)。
为此我们需要建立一个新的 ShaderMaterial:
let cmOutline = new THREE.Mesh(new THREE.BufferGeometry().copy(cmCharacer.geometry));
new THREE.ShaderMaterial({ =
uniforms: {
offset: {
type: 'f',
value: 0.05 //偏移值
},
color: {
value: new THREE.Color(0.0, 0.0, 0.0)
},
},
vertexShader: vertexShaderOutline,
fragmentShader: fragShaderOutline
});
let vertexShaderOutline = `
uniform float offset;
void main() {
vec4 pos = modelViewMatrix * vec4( position + normal * offset, 1.0 );
gl_Position = projectionMatrix * pos;
}`
let fragShaderOutline = `
uniform vec3 color;
void main(){
gl_FragColor = vec4( color, 1.0 );
}`
着色器代码比较简单,pos 是原始顶点沿着法线方向膨胀 offset 宽度后的结果。
现在我们同时有 cmCharacer 和 cmOutline 两个 mesh,我们要调整帧渲染流程,先画 outline,再画 character,并且手动将 renderer 的 autoClear 设置为 false,避免前一次绘制的结果被清除掉。
let scene = new THREE.Scene();
scene.add(cmCharacer);
let outlineScene = new THREE.Scene();
outlineScene.add(cmOutline);
renderer.autoClear = false;//防止渲染器在渲染每一帧之前自动清除其输出
renderer.render(outlineScene, camera);
renderer.clearDepth();
renderer.render(scene, camera);
两次绘制之间我们插了一句 renderer.clearDepth(),用来清理深度缓存。那深度缓存又是什么?所谓深度,就是顶点距离摄像机的距离,缓存就是用来保存这个数据的缓冲区,它可以表达出模型的互相遮挡关系。渲染器在执行着色的时候,为了提高计算效率,会使用深度缓存进行裁切,把所有被挡住的顶点都忽略不算。
如果不清理深度缓存,那么膨胀后的模型一定比原来的更靠前,深度数据够小,相当于把原模型“裹”起来,那么原模型的顶点都会被忽略掉,导致只能渲染一个黑影。清理之后,深度缓存被重置,原模型才会叠加在阴影上,形成正确的结果。
这种描边方式比前一种法线夹角法的效果好,但也存在问题,法线膨胀的结果并不能保证均匀。比如下图法线突变的地方,膨胀后就会出现瑕疵。
接下来,我们继续开拓思路,描边既然是针对每一帧渲染结果的,其实和模型的 3D 属性已经没有关系了,那我们能不能先把结果渲染出来,然后像处理 2D 图像那样,给它描个边?答案是可以的。在渲染管线里,这种技术叫做后处理(post process)。
引入后处理的渲染流程变得更加复杂了。我们还是分别处理描边和本体。绘制描边的时候,我们先用原始模型画一张黑白二值图,画在辅助的离屏渲染器上。然后用卷积的方式,从二值图里把边缘提取出来,再加到原始模型上。如下图所示:
首先看提取边缘的代码:
let cmMask = new THREE.Mesh(new THREE.BufferGeometry().copy(cmCharacer.geometry));
cmMask.material = new THREE.ShaderMaterial({
vertexShader: vertexShaderMask,
fragmentShader: fragShaderMask,
depthTest: false
});
let vertexShaderMask = `
uniform float offset;
void main() {
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}`
let fragShaderMask = `
void main(){
gl_FragColor = vec4( 1.0, 1.0, 1.0, 1.0 );
}`
关闭深度测试(depthTest)是因为我们 fs 的逻辑非常简单,没有任何的矩阵操作,所以关掉深度缓存反而可以节约一些计算步骤和存储空间。
接下来继续修改渲染管线,创建一个 maskScene 来渲染,并且把 renderer 的渲染目标(target)设置到一个空 buffer 里。
let maskScene = new THREE.Scene();
maskScene.add(cmMask);
let maskBuffer = new THREE.WebGLRenderTarget(canvasW, canvasH, {
minFilter: THREE.LinearFilter,
magFilter: THREE.LinearFilter,
format: THREE.RGBAFormat,
antialias: true
});
renderer.autoClear = false;
let oldRenderTarget = renderer.getRenderTarget();
renderer.setRenderTarget(maskBuffer);
renderer.clear();
renderer.render(maskScene, camera);
执行上述代码后,maskBuffer 就是我们想要的黑白二值图。
我们再创建一个描边对象,注意这里不再使用 cmCharacter 的几何数据,而是直接创建一个平面网格,宽高和视口 canvas 保持一致。
let cmEdge = new THREE.Mesh(new THREE.PlaneBufferGeometry(canvasW, canvasH));
new THREE.ShaderMaterial({ =
vertexShader: vertexShaderEdge,
fragmentShader: fragShaderEdge,
depthTest: false,
uniforms: {
maskTexture: {
value: maskBuffer.texture
},
texSize: {
value: new THREE.Vector2(canvasW, canvasH)
},
color: {
value: new THREE.Color(0.0, 0.0, 0.0)
},
thickness: {
type: 'f',
value: 1.6
},
transparent: true
},
});
let vertexShaderEdge = `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}`;
let fragShaderEdge = `
uniform sampler2D maskTexture;
uniform vec2 texSize;
uniform vec3 color;
uniform float thickness;
varying vec2 vUv;
void main() {
vec2 invSize = thickness / texSize;
采用 Roberts 算子
vec4 uvOffset = vec4(1.0, 0.0, 0.0, 1.0) * vec4(invSize, invSize);
滤波器-2*2矩阵
vec4 c1 = texture2D( maskTexture, vUv + uvOffset.xy);
vec4 c2 = texture2D( maskTexture, vUv - uvOffset.xy);
vec4 c3 = texture2D( maskTexture, vUv + uvOffset.yw);
vec4 c4 = texture2D( maskTexture, vUv - uvOffset.yw);
r 只有 0/1 两个值
float diff1 = (c1.r - c2.r)*0.5;// 判断x方向是否属于边缘
float diff2 = (c3.r - c4.r)*0.5; // 判断y方向是否属于边缘
float d = length(vec2(diff1, diff2));
gl_FragColor = d > 0.0 ? vec4(color, 1.0) : vec4(1.0, 1.0, 1.0, 0.0);
}`;
关于卷积的基本原理,本文不再赘述,这里采取的也是最基础的卷积算法,感兴趣的朋友可以自行查阅。
得到卷积结果后,我们继续改造渲染管线。
let edgeScene = new THREE.Scene();
edgeScene.add(edgeObj);
// edgeScene是一张平面,不应随原始模型转动,所以需要一个新的正交相机
var edgeCamera = new THREE.OrthographicCamera(-canvasW / 2, canvasW / 2, canvasH / 2, -canvasH / 2, 0, 1);
edgeCamera.position.z = 1;
edgeCamera.lookAt(new THREE.Vector3());
renderer.setRenderTarget(oldRenderTarget);
renderer.render(edgeScene, edgeCamera);
renderer.clearDepth();
// 渲染原始图像
renderer.render(scene, camera);
把渲染目标(target)设置回来,然后采取 2 pass 的方式,先渲染 edgeScene,再渲染 scene,二者叠加,得到最终的结果。
这样提取出的边缘很清晰,而且在尖锐地方也不会产生突变瑕疵,效果图如下:
此外它还有一个特点,不会随着视角的远近而改变粗细,而上一种法线膨胀法,因为膨胀是基于模型坐标系进行的,所以会有近大远小的问题,如果想要得到远近一致的边缘,必须得把摄像机距离也传入,对 offset 的取值做修正。
但卷积法也是三种方式里步骤最复杂的,而且生成了中间纹理,因为纹理坐标要在两次 drawCall 前后复用,就需要从 gpu 复制到显存,再复制回去,这在实际生产环境中,会消耗显存和带宽,所以我们也要根据实际情况来进行取舍。
最后的绘制效果如图:
经过长长的流程,我们终于“画”出了一个看上去还像那么回事的结果。当然,上面的代码仅仅是基本原理的简化版本,真正的渲染技术要比这复杂得多。
举个例子,细心的朋友可能发现了,我们的效果图里,面部区域并没有叠加高光和阴影。如果我们依照和其他区域一样的模式来叠加,会得到很糟糕的结果。
这里并不是因为算法“错”了,而是因为卡通风格的面部绘制本来就有特殊性,常常需要进行一些风格化处理,比如手动调整每个平面法线的值,人为地让表面变得更平滑。对于鼻子、眼睛、眉毛、头发等等的模型,也有很多特殊的处理技巧,需要开发人员和美术设计人员密切沟通,反复调整,达到最佳的效果。
项目中的核心代码都放在 GitHub 项目中,地址如下:
https://github.com/wendychengc/WebglToonShaderDemo
由于效果图里的模型加载和前处理比较复杂,为了方便查看核心代码,使用了一个更加精简的模型。感兴趣的朋友欢迎克隆下来研究,更欢迎与我交流讨论,共同进步。
成文迪
腾讯科技有限公司 PCG 高级前端开发工程师
腾讯 T11 级高级前端工程师,QQ 团队核心开发,曾负责腾讯文档收集表、QQ 小程序、厘米秀等重要项目。
今年 8 月,GMTC 全球大前端技术大会将再一次与你在线下相约。来自阿里、腾讯、字节等一线大厂的 50+ 技术专家现场分享,邀你一同探索大前端发展的最新技术趋势与热点方向。点击底部【阅读原文】查看已上线的演讲议题,门票限时 9 折,抢占交流席位:+86 13269078023(同微信)。