WebGL+shader实现素描效果渲染

时间:2022-09-10 17:00:46

转自:点击打开链接

实现一个这样的渲染效果,主要的步骤包括:

  1. 准备模型和场景
  2. 通过 WebGL (Three.js) 导入场景
  3. 实现 Shader 以表现接近素描的效果

在最重要的第 3 步中,我们要实现的主要有两个效果:

  1. 模型边缘的描边 (不同于单纯的线框)
  2. 模型表面类似于素描的线条效果

为了实现这样的效果,我们实际并不能直接在单一的 3D 的空间上完成的,而需要另外准备一个二维场景用于合成。总体的渲染与合成流程如下:

WebGL+shader实现素描效果渲染Pipeline

其中的 3D 场景,就是我们想要处理成素描效果的场景。这里使用了一个小技巧,那就是我们并非直接将 3D 场景中的渲染效果输出到屏幕,而是先将三种不同类型的渲染结果输出到位于显存中的 Buffer(Three.js 中的WebGLRenderTarget) 里。再在 2D 场景中合成这些输出结果。

这个 2D 场景非常简单,里面只有一个恰好和视口大小一样的矩形平面和一个非透视类型的 Camera,将我们从 3D 场景得到的不同类型的渲染图作为矩形平面的贴图,这样我们就可以编写 Shader来高效地处理合成效果了。最终输出的结果其实是 2D 场景的渲染结果,但是观看的人不会感觉到任何差异。

使用这样一个简单的 2D 场景进行后期合成可以说是一个非常常用的技巧,因为这样可以通过 OpenGL 充分利用显卡的渲染性能。

准备场景

首先要做的工作是准备用来渲染的场景,选用的建模软件当然是我最喜欢的 Blender。我参考BlenderNation 上刊登的一副室内场景作品进行了仿制。我仿制的场景渲染结果如下:

WebGL+shader实现素描效果渲染Scene

选用这个场景的主要原因是场景的主体结构都非常简单,大多数物体都可以通过简单的立方体变换和修改而成。大量的平面也方便表现素描的效果。

建模的细节不再赘述。在这一阶段还有一个主要的工序需要完成,那就是 UV 展开和阴影明暗的烘焙 (Bake)。

模型的 UV 展开实质上就是确定模型的贴图坐标与模型坐标的映射关系。一个好的 UV 映射决定了模型渲染时贴图的显示效果。因为模型表面的素描效果实际是通过贴图实现的,因此如果没有一个好的 UV 映射,显示出来的笔触可能会出现扭曲、变形、粗细不一等各种问题。UV 展开可以说是一个非常繁琐耗时的工序。最后为了减少工作量,我不得不删除了一些比较复杂的模型。

我将场景中的所有模型合并为一个物体,并完成 UV 展开后的结果如下:

WebGL+shader实现素描效果渲染UV Mapping

完成 UV 展开之后将会进行烘焙。所谓的烘焙 (Bake) 就是将模型在场景环境下的明暗变化、阴影等事先渲染并映射到模型的贴图上。这个技术常用于静态场景中。在这种静态场景里,灯光的位置和角度不会变化,只有摄像机的方向会改变。因此实际上物体的明暗阴影都是固定的,将其固定在贴图中之后,使用 OpenGL 渲染时不再进行明暗处理和阴影生成。这样可以节约大量的计算时间。而且使用 CPU 渲染的阴影往往可以使用更为复杂的算法以获得真实的效果。

Blender 的烘焙选项在 Render 选项卡的最下方,这里选择 Full Render 来将一切光源产生的明暗阴影都固定下来。

WebGL+shader实现素描效果渲染Bake Panel

对照之前的 UV 展开,我烘焙出来的光影贴图如下:

WebGL+shader实现素描效果渲染Room Baked

最后,使用 Three.js 提供的输出插件,将我们的场景输出成 Three.js 可以识别的.json文件。我输出的模型文件和相关贴图都已经上传到 GitHub 的仓库里。

这里再为有兴趣的同学推荐一个来自*同胞的 Blender 基础教程 (YouTube)。个人感觉是 Blender 的中文视频教程中比较好的一个,虽然时间录制早了些,但是讲解很清晰。而且本文制作时使用的建模、UV 展开、贴图和烘焙技巧都有介绍。

编写 Shader

终于到了这篇文章的重中之重了,Shader 是通过 GPU 实现图形渲染的核心,通过 OpenGL实现的任何 2D 或 3D 效果都离不开它。

一点点基础知识

众所周知, WebGL 使用的 Shader 语言其实是 OpenGL 的一个嵌入式版本OpenGL ES 所定义的,这一 Shader 语言使用了类似 C 语言的语法,但是有下面几个区别:

  1. Shader 语言没有动态分配内存的机制,所有内存 (变量) 的空间都是静态分配的
  2. Shader 语言是强类型的,不同类型的数不能隐式转换 (比如整形不能隐式转换为浮点型)
  3. Shader 语言提供的一些数据结构,如向量类型vec2vec3vec4和矩阵类型mat2mat2mat4是直接可以使用加减乘除运算符进行操作的。

在 WebGL 中,我们可以自己编写的 Shader 有两种类型

  1. Vertex Shader: 模型的每个顶点上调用
  2. Fragment Shader: 模型三个顶点组成的面上显示出来的每个像素上执行

在渲染时,GPU 会先在每个顶点上执行 Vertex Shader,再在每个像素上执行 Fragment Shader。Vertex Shader 主要用来计算每个定点投影在视平面上的位置,但是也可以用来进行一些颜色的计算并将结果传送给 Fragment Shader。Fragment Shader 则决定了最终显示出来的每个像素的颜色。

接下来介绍 Shader 的变量修饰词。Shader 的变量修饰词可以分为 5 种:

  1. (无): 默认的变量修饰符,作用域只限本地
  2. const: 只读常量
  3. attribute: 用来将每个节点的数据和 Vertex Shader 联系起来的变量,简单来说就是在某一个顶点上执行Vertex Shader 时,变量的值就是这个顶点对应的值。这种对应关系是在初始化 WebGL 的程序时手动指定的。不过幸好 Three.js 已经为我们完成这一任务了。
  4. uniform: 这种类型的变量也是运行在 CPU 的主程序向 Shader 传递数据的一个途径,主要用于与所处理的 Vertex 和 Fragment无关的值,比如摄像机的位置、灯光光源的位置方向等,这些参数在每一帧的渲染时都不变,因此使用uniform传递进来。
  5. varying: 用来从 Vertex Shader 向 Fragment Shader 传递数据的变量。在 Vertex Shader 和 Fragment Shader上定义相同变量名的varying变量,在运行时 Fragment Shader 中变量的值将会是组成这个面的三个顶点所提供的值的线性插值。

Three.js 已经为我们预设了必要的attributeuniform,预设变量列表可以参见文档

两种 Shader 都有一个main函数,不过执行的参数并非通过main函数的参数传入程序,输出结果也不是通过main函数的返回值返回的。实际上,OpenGL 已经固定了每种 Shader 的默认输入变量和输出变量的名称与类型,程序可以直接访问和设置这些变量。当然,外部程序也可以通过attributeuniform机制来指定额外的输入。

一个典型的 Vertex Shader 如下面的代码所示:

1
2
3
void main(void) {
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}

其中,positionprojectionMatrixmodelViewMatrix 这些变量都是 Three.js 默认设置好并传递进 Shader 的。positionattribute类型,它代表了每个 Vertex 在 3D 空间中的坐标,另外两个变量是uniform,是 Three.js根据场景的属性而设定的。gl_Position 就是 OpenGL 指定的 Vertex Shader 的输出值。

一个典型的 Vertex Shader 是通过给出的顶点position,以及相关的一些变换投影矩阵,计算出这个顶点做透视投影后显示在屏幕中的 2D 坐标。因此在这里也可以实现各种透视效果,如常见的投影透视 (近大远小)、平视透视 (远近一样大),甚至超现实的反投影透视 (近小远大) 等。

Fragment Shader 的主要用处是确定某个像素的颜色,其已经指定的输出值为gl_FragColor,这是一个vec4类型的变量,代表了 RGBA 类型的颜色表示,为每一个表面输出白色的 Fragment Shader 如下:

1
2
3
void main(void) {
gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0);
}

除了直接计算颜色,还可以通过贴图 (texture) 来确定某个 Fragment 的颜色。在 WebGL 中,贴图是通过uniform的方式传递进Shader 里的,其类型是sample2D。随后,我们可以使用texture2D(texture, uv)函数获得某一个像素的颜色,这里的uv是一个二维向量,可以通过 Vertex Shader 获得。

在 Three.js 实现访问贴图的一个简单的例子是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Vertex Shader
varying vUv;

void main(void) {
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
vUv = uv;
}

// Fragment Shader
uniform sample2D aTexture;
varying vUv;

void main(void) {
gl_FragColor = texture2D(aTexture, vUv);
}

在 Vertex Shader 中使用的uv变量,也是 Three.js 中已经提供好的attribute。接下来就是在 Three.js 中使用 Shader 的方法了。

在 Three.js 中使用 Shader

Three.js 提供了ShaderMaterial用于实现自定义 Shader 的Material。下面是一个来自其官方文档的例子。

1
2
3
4
5
6
7
8
9
10
11
var material = new THREE.ShaderMaterial( {
uniforms: {
time: { type: "f", value: 1.0 },
resolution: { type: "v2", value: new THREE.Vector2() }
},
attributes: {
vertexOpacity: { type: 'f', value: [] }
},
vertexShader: document.getElementById( 'vertexShader' ).textContent,
fragmentShader: document.getElementById( 'fragmentShader' ).textContent
});

你可以通过设置uniformsattributes等参数向 Shader 传递数据,传递的格式文档中都有介绍。我们也是在这里将 Shader 需要用到的 Texture 通过uniforms传递进去的。Texture 写在 unifroms 里的typetvalue可以是一个 Three.js 的Texture对象,也可以是WebGLRenderTarget

这里只是将值传递了进去,你还是要在 Shader 源码里自己声明这些变量才能访问他们,在 Shader 里定义的名称应该与你在 JavaScript 中给出的键名相同。

显示模型的 Outline

模型的 Outline 就是在卡通风格的图画中围绕在物体边缘的线,因为卡通风格中物体的总体色调都比较平面化,所以需要这样的线来强调物体与物体之间的区分。

实现这种 Outline 有两种简单直观的方法:

  1. 使用深度作为特征,将深度变化大的地方标记出来
  2. 使用表面法线的方向作为特征,将发现变化大的地方标记出来

这两种方法都各自有自己的缺点。比如深度特征时,很容易将一个与观察方向夹角比较小的面全部标记为黑色;而法线特征时,又无法将前后两个法线相近但是距离较远的表面区分开。这里参考另一篇相关内容的英文博客Sketch Rendering 的方法来实现。

这种方法结合了深度和法线,假设有两个点 A 和 B,通过计算 A 的空间位置到 B 的法线所构成的平面的距离作为衡量,判断是否应该标记为 Outline。A 和 B 的空间位置则需要通过 A 和 B 的深度来计算出来。因此,我们需要先将我们的 3D 场景的深度和法线渲染图输出出来。

Three.js 已经提供了MeshDepthMaterialMeshNormalMaterial分别用来输出深度和法线渲染图。我们直接使用这两个类就好了。假设我们已经初始化了一个depthMaterial和一个normalMaterial,那么将整个场景里的物体都用某一个 Material 进行渲染的话,我们可以使用

1
objectScene.overrideMaterial = depthMaterial; // 或 normalMaterial

这样的方法实现。

此外,我们不希望渲染结果直接输出到屏幕,因此我们需要先新建一个 WebGLRenderTarget 作为一个 FrameBuffer 来存放结果。此后这个WebGLRenderTarget可以直接作为贴图传入用于合成的 2D 场景。

1
2
3
4
5
6
7
8
9
var pars = {
minFilter: THREE.LinearFilter,
magFilter: THREE.LinearFilter,
format: THREE.RGBFormat,
stencilBuffer: false
}

var depthTexture = new THREE.WebGLRenderTarget(width, height, pars)
var normalTexture = new THREE.WebGLRenderTarget(width, height, pars)

使用下面的代码,将渲染结果输出到 FrameBuffer 里:

1
2
3
4
5
6
7
8
9
10
11
// render depth
objectScene.overrideMaterial = depthMaterial;
renderer.setClearColor('#000000');
renderer.clearTarget(depthTexture, true, true);
renderer.render(objectScene, objectCamera, depthTexture);

// render normal
objectScene.overrideMaterial = normalMaterial;
renderer.setClearColor('#000000');
renderer.clearTarget(normalTexture, true, true);
renderer.render(objectScene, objectCamera, normalTexture);

在输出之前,别忘记使用rendererclearTarget函数将 Buffer 清空。如果将我们在这一步生成的贴图显示出来的话,大概是下面的样子:

WebGL+shader实现素描效果渲染Depth & Normal Texture

生成素描笔触

接下来就是在物体的表面生成绘制的素描线条效果了。这个方面其实比想象中更简单一点,我们的素描效果是使用的是如下一系列贴图组成的:

WebGL+shader实现素描效果渲染Hatching Maps

接下来的问题就是找一种方法将这种不同密度的贴图融合在一起,这种问题被称为 Hatching。这里使用的 Hatching 方法是 MicroSoft Research在 2001 年发表的一篇论文中给出的。

不同于原文中使用 6 张贴图合成的方法,这里采用了使用 3 张贴图合成,然后将贴图旋转 90 度再合成一次,从而获得交叉的笔划。

1
2
3
4
5
6
7
void main() {
vec2 uv = vUv * 15.0;
vec2 uv2 = vUv.yx * 10.0;
float shading = texture2D(bakedshadow, vUv).r + 0.1;
float crossedShading = shade(shading, uv) * shade(shading, uv2) * 0.6 + 0.4;
gl_FragColor = vec4(vec3(crossedShading), 1.0);
}

shade函数就是用合成多个贴图的函数,具体代码可以参见 GitHub上的这个文件。可以注意到,我其实使用了之前 bake 出来的明暗来作为素描线条深浅的参考因素,这样就可以表现出明暗和阴影了。

最后的合成

最后就是要在我们的二维场景里进行最后的合成了。构造这样一个二维场景的代码很简单:

1
2
3
4
var composeCamera = new THREE.OrthographicCamera(-width / 2, width / 2, height / 2, -height / 2, -10, 10);
var composePlaneGeometry = new THREE.PlaneBufferGeometry(width, height);
composePlaneMesh = new THREE.Mesh(composePlaneGeometry, composeMaterial);
composeScene.add(composePlaneMesh);

场景的主要构造就是一个和视口一样大小的矩形几何体,摄像机则是一个OrthographicCamera,这种摄像机没有透视效果,正合适用于我们这种合成的需求。

将前几步输出到 FrameBuffer (也就是WebGLRenderTarget) 的结果作为这个矩形表面的贴图,然后我们编写一个 Shader 来进行合成。

这一次,我们不再需要输出到 Buffer 上,而是直接输出到屏幕。而 Outline 的生成也是在这一步完成的。用来计算 Outline 的函数是:

1
2
3
4
5
6
float planeDistance(const in vec3 positionA, const in vec3 normalA, 
const in vec3 positionB, const in vec3 normalB) {
vec3 positionDelta = positionB-positionA;
float planeDistanceDelta = max(abs(dot(positionDelta, normalA)), abs(dot(positionDelta, normalB)));
return planeDistanceDelta;
}

在当前坐标周围取一个十字形的采样,对于上下和左右取出的点分别执行上面的函数,最后使用smoothstep来获得 Outline 的颜色:

1
2
3
4
5
vec2 planeDist = vec2(
planeDistance(leftpos, leftnor, rightpos, rightnor),
planeDistance(uppos, upnor, downpos, downnor));
float planeEdge = 2.5 * length(planeDist);
planeEdge = 1.0 - 0.5 * smoothstep(0.0, depthCenter, planeEdge);

在最后实现的版本里,我还尝试了再混入法线方式生成的边缘线的效果。最终生成的 Outline 效果如下:

WebGL+shader实现素描效果渲染Outline

最后,将 Hatching 过程输出的结果混合进来:

1
2
vec4 hatch = texture2D(hatchtexture, vUv);
gl_FragColor = vec4(vec3(hatch * edge), 1.0);

完整的实现可以参见我放在 GitHub上的源码

大功告成!最后的合成效果如图:

WebGL+shader实现素描效果渲染Final Result

各位可以访问我使用简单添加了一点交互之后得到的 Live Demo(请使用支持 WebGL 的现代浏览器进行访问,加载模型和全部贴图可能需要一小会,请耐心等待)。

我实现的所有代码以及模型都已经以 BSD 协议发布到 GitHub上了 (这里)。

总结一下

虽然是作为我在学校一门课程的 Final Project 的一部分完成的项目,但是在这个过程中我总算是对于 Shader 的编写方面有所入门。此外,这次进行 Blender进行建模也感觉比以前顺利了许多。

虽然对 Blender 和 WebGL 的爱好现在看起来还没有什么现实价值,但是能够自己完成一个有趣的 Project还是很有成就感的!