转自:点击打开链接
实现一个这样的渲染效果,主要的步骤包括:
- 准备模型和场景
- 通过 WebGL (Three.js) 导入场景
- 实现 Shader 以表现接近素描的效果
在最重要的第 3 步中,我们要实现的主要有两个效果:
- 模型边缘的描边 (不同于单纯的线框)
- 模型表面类似于素描的线条效果
为了实现这样的效果,我们实际并不能直接在单一的 3D 的空间上完成的,而需要另外准备一个二维场景用于合成。总体的渲染与合成流程如下:
其中的 3D 场景,就是我们想要处理成素描效果的场景。这里使用了一个小技巧,那就是我们并非直接将 3D 场景中的渲染效果输出到屏幕,而是先将三种不同类型的渲染结果输出到位于显存中的 Buffer(Three.js 中的WebGLRenderTarget
) 里。再在 2D 场景中合成这些输出结果。
这个 2D 场景非常简单,里面只有一个恰好和视口大小一样的矩形平面和一个非透视类型的 Camera,将我们从 3D 场景得到的不同类型的渲染图作为矩形平面的贴图,这样我们就可以编写 Shader来高效地处理合成效果了。最终输出的结果其实是 2D 场景的渲染结果,但是观看的人不会感觉到任何差异。
使用这样一个简单的 2D 场景进行后期合成可以说是一个非常常用的技巧,因为这样可以通过 OpenGL 充分利用显卡的渲染性能。
准备场景
首先要做的工作是准备用来渲染的场景,选用的建模软件当然是我最喜欢的 Blender。我参考BlenderNation 上刊登的一副室内场景作品进行了仿制。我仿制的场景渲染结果如下:
选用这个场景的主要原因是场景的主体结构都非常简单,大多数物体都可以通过简单的立方体变换和修改而成。大量的平面也方便表现素描的效果。
建模的细节不再赘述。在这一阶段还有一个主要的工序需要完成,那就是 UV 展开和阴影明暗的烘焙 (Bake)。
模型的 UV 展开实质上就是确定模型的贴图坐标与模型坐标的映射关系。一个好的 UV 映射决定了模型渲染时贴图的显示效果。因为模型表面的素描效果实际是通过贴图实现的,因此如果没有一个好的 UV 映射,显示出来的笔触可能会出现扭曲、变形、粗细不一等各种问题。UV 展开可以说是一个非常繁琐耗时的工序。最后为了减少工作量,我不得不删除了一些比较复杂的模型。
我将场景中的所有模型合并为一个物体,并完成 UV 展开后的结果如下:
完成 UV 展开之后将会进行烘焙。所谓的烘焙 (Bake) 就是将模型在场景环境下的明暗变化、阴影等事先渲染并映射到模型的贴图上。这个技术常用于静态场景中。在这种静态场景里,灯光的位置和角度不会变化,只有摄像机的方向会改变。因此实际上物体的明暗阴影都是固定的,将其固定在贴图中之后,使用 OpenGL 渲染时不再进行明暗处理和阴影生成。这样可以节约大量的计算时间。而且使用 CPU 渲染的阴影往往可以使用更为复杂的算法以获得真实的效果。
Blender 的烘焙选项在 Render 选项卡的最下方,这里选择 Full Render 来将一切光源产生的明暗阴影都固定下来。
对照之前的 UV 展开,我烘焙出来的光影贴图如下:
最后,使用 Three.js 提供的输出插件,将我们的场景输出成 Three.js 可以识别的.json
文件。我输出的模型文件和相关贴图都已经上传到 GitHub 的仓库里。
这里再为有兴趣的同学推荐一个来自*同胞的 Blender 基础教程 (YouTube)。个人感觉是 Blender 的中文视频教程中比较好的一个,虽然时间录制早了些,但是讲解很清晰。而且本文制作时使用的建模、UV 展开、贴图和烘焙技巧都有介绍。
编写 Shader
终于到了这篇文章的重中之重了,Shader 是通过 GPU 实现图形渲染的核心,通过 OpenGL实现的任何 2D 或 3D 效果都离不开它。
一点点基础知识
众所周知, WebGL 使用的 Shader 语言其实是 OpenGL 的一个嵌入式版本OpenGL ES 所定义的,这一 Shader 语言使用了类似 C 语言的语法,但是有下面几个区别:
- Shader 语言没有动态分配内存的机制,所有内存 (变量) 的空间都是静态分配的
- Shader 语言是强类型的,不同类型的数不能隐式转换 (比如整形不能隐式转换为浮点型)
- Shader 语言提供的一些数据结构,如向量类型
vec2
、vec3
、vec4
和矩阵类型mat2
、mat2
、mat4
是直接可以使用加减乘除运算符进行操作的。
在 WebGL 中,我们可以自己编写的 Shader 有两种类型
- Vertex Shader: 模型的每个顶点上调用
- Fragment Shader: 模型三个顶点组成的面上显示出来的每个像素上执行
在渲染时,GPU 会先在每个顶点上执行 Vertex Shader,再在每个像素上执行 Fragment Shader。Vertex Shader 主要用来计算每个定点投影在视平面上的位置,但是也可以用来进行一些颜色的计算并将结果传送给 Fragment Shader。Fragment Shader 则决定了最终显示出来的每个像素的颜色。
接下来介绍 Shader 的变量修饰词。Shader 的变量修饰词可以分为 5 种:
- (无): 默认的变量修饰符,作用域只限本地
-
const
: 只读常量 -
attribute
: 用来将每个节点的数据和 Vertex Shader 联系起来的变量,简单来说就是在某一个顶点上执行Vertex Shader 时,变量的值就是这个顶点对应的值。这种对应关系是在初始化 WebGL 的程序时手动指定的。不过幸好 Three.js 已经为我们完成这一任务了。 -
uniform
: 这种类型的变量也是运行在 CPU 的主程序向 Shader 传递数据的一个途径,主要用于与所处理的 Vertex 和 Fragment无关的值,比如摄像机的位置、灯光光源的位置方向等,这些参数在每一帧的渲染时都不变,因此使用uniform
传递进来。 -
varying
: 用来从 Vertex Shader 向 Fragment Shader 传递数据的变量。在 Vertex Shader 和 Fragment Shader上定义相同变量名的varying
变量,在运行时 Fragment Shader 中变量的值将会是组成这个面的三个顶点所提供的值的线性插值。
Three.js 已经为我们预设了必要的attribute
和uniform
,预设变量列表可以参见文档。
两种 Shader 都有一个main
函数,不过执行的参数并非通过main
函数的参数传入程序,输出结果也不是通过main
函数的返回值返回的。实际上,OpenGL 已经固定了每种 Shader 的默认输入变量和输出变量的名称与类型,程序可以直接访问和设置这些变量。当然,外部程序也可以通过attribute
和uniform
机制来指定额外的输入。
一个典型的 Vertex Shader 如下面的代码所示:
1 |
void main(void) { |
其中,position
、projectionMatrix
、modelViewMatrix
这些变量都是 Three.js 默认设置好并传递进 Shader 的。position
是attribute
类型,它代表了每个 Vertex 在 3D 空间中的坐标,另外两个变量是uniform
,是 Three.js根据场景的属性而设定的。gl_Position
就是 OpenGL 指定的 Vertex Shader 的输出值。
一个典型的 Vertex Shader 是通过给出的顶点position
,以及相关的一些变换投影矩阵,计算出这个顶点做透视投影后显示在屏幕中的 2D 坐标。因此在这里也可以实现各种透视效果,如常见的投影透视 (近大远小)、平视透视 (远近一样大),甚至超现实的反投影透视 (近小远大) 等。
Fragment Shader 的主要用处是确定某个像素的颜色,其已经指定的输出值为gl_FragColor
,这是一个vec4
类型的变量,代表了 RGBA 类型的颜色表示,为每一个表面输出白色的 Fragment Shader 如下:
1 |
void main(void) { |
除了直接计算颜色,还可以通过贴图 (texture) 来确定某个 Fragment 的颜色。在 WebGL 中,贴图是通过uniform
的方式传递进Shader 里的,其类型是sample2D
。随后,我们可以使用texture2D(texture, uv)
函数获得某一个像素的颜色,这里的uv
是一个二维向量,可以通过 Vertex Shader 获得。
在 Three.js 实现访问贴图的一个简单的例子是:
1 |
// Vertex Shader |
在 Vertex Shader 中使用的uv
变量,也是 Three.js 中已经提供好的attribute
。接下来就是在 Three.js 中使用 Shader 的方法了。
在 Three.js 中使用 Shader
Three.js 提供了ShaderMaterial
用于实现自定义 Shader 的Material。下面是一个来自其官方文档的例子。
1 |
var material = new THREE.ShaderMaterial( { |
你可以通过设置uniforms
和attributes
等参数向 Shader 传递数据,传递的格式文档中都有介绍。我们也是在这里将 Shader 需要用到的 Texture 通过uniforms
传递进去的。Texture 写在 unifroms 里的type
是t
,value
可以是一个 Three.js 的Texture
对象,也可以是WebGLRenderTarget
。
这里只是将值传递了进去,你还是要在 Shader 源码里自己声明这些变量才能访问他们,在 Shader 里定义的名称应该与你在 JavaScript 中给出的键名相同。
显示模型的 Outline
模型的 Outline 就是在卡通风格的图画中围绕在物体边缘的线,因为卡通风格中物体的总体色调都比较平面化,所以需要这样的线来强调物体与物体之间的区分。
实现这种 Outline 有两种简单直观的方法:
- 使用深度作为特征,将深度变化大的地方标记出来
- 使用表面法线的方向作为特征,将发现变化大的地方标记出来
这两种方法都各自有自己的缺点。比如深度特征时,很容易将一个与观察方向夹角比较小的面全部标记为黑色;而法线特征时,又无法将前后两个法线相近但是距离较远的表面区分开。这里参考另一篇相关内容的英文博客Sketch Rendering 的方法来实现。
这种方法结合了深度和法线,假设有两个点 A 和 B,通过计算 A 的空间位置到 B 的法线所构成的平面的距离作为衡量,判断是否应该标记为 Outline。A 和 B 的空间位置则需要通过 A 和 B 的深度来计算出来。因此,我们需要先将我们的 3D 场景的深度和法线渲染图输出出来。
Three.js 已经提供了MeshDepthMaterial
和MeshNormalMaterial
分别用来输出深度和法线渲染图。我们直接使用这两个类就好了。假设我们已经初始化了一个depthMaterial
和一个normalMaterial
,那么将整个场景里的物体都用某一个 Material 进行渲染的话,我们可以使用
1 |
objectScene.overrideMaterial = depthMaterial; // 或 normalMaterial |
这样的方法实现。
此外,我们不希望渲染结果直接输出到屏幕,因此我们需要先新建一个 WebGLRenderTarget
作为一个 FrameBuffer 来存放结果。此后这个WebGLRenderTarget
可以直接作为贴图传入用于合成的 2D 场景。
1 |
var pars = { |
使用下面的代码,将渲染结果输出到 FrameBuffer 里:
1 |
// render depth |
在输出之前,别忘记使用renderer
的clearTarget
函数将 Buffer 清空。如果将我们在这一步生成的贴图显示出来的话,大概是下面的样子:
生成素描笔触
接下来就是在物体的表面生成绘制的素描线条效果了。这个方面其实比想象中更简单一点,我们的素描效果是使用的是如下一系列贴图组成的:
接下来的问题就是找一种方法将这种不同密度的贴图融合在一起,这种问题被称为 Hatching。这里使用的 Hatching 方法是 MicroSoft Research在 2001 年发表的一篇论文中给出的。
不同于原文中使用 6 张贴图合成的方法,这里采用了使用 3 张贴图合成,然后将贴图旋转 90 度再合成一次,从而获得交叉的笔划。
1 |
void main() { |
shade
函数就是用合成多个贴图的函数,具体代码可以参见 GitHub上的这个文件。可以注意到,我其实使用了之前 bake 出来的明暗来作为素描线条深浅的参考因素,这样就可以表现出明暗和阴影了。
最后的合成
最后就是要在我们的二维场景里进行最后的合成了。构造这样一个二维场景的代码很简单:
1 |
var composeCamera = new THREE.OrthographicCamera(-width / 2, width / 2, height / 2, -height / 2, -10, 10); |
场景的主要构造就是一个和视口一样大小的矩形几何体,摄像机则是一个OrthographicCamera
,这种摄像机没有透视效果,正合适用于我们这种合成的需求。
将前几步输出到 FrameBuffer (也就是WebGLRenderTarget
) 的结果作为这个矩形表面的贴图,然后我们编写一个 Shader 来进行合成。
这一次,我们不再需要输出到 Buffer 上,而是直接输出到屏幕。而 Outline 的生成也是在这一步完成的。用来计算 Outline 的函数是:
1 |
float planeDistance(const in vec3 positionA, const in vec3 normalA, |
在当前坐标周围取一个十字形的采样,对于上下和左右取出的点分别执行上面的函数,最后使用smoothstep
来获得 Outline 的颜色:
1 |
vec2 planeDist = vec2( |
在最后实现的版本里,我还尝试了再混入法线方式生成的边缘线的效果。最终生成的 Outline 效果如下:
最后,将 Hatching 过程输出的结果混合进来:
1 |
vec4 hatch = texture2D(hatchtexture, vUv); |
完整的实现可以参见我放在 GitHub上的源码。
大功告成!最后的合成效果如图:
各位可以访问我使用简单添加了一点交互之后得到的 Live Demo(请使用支持 WebGL 的现代浏览器进行访问,加载模型和全部贴图可能需要一小会,请耐心等待)。
我实现的所有代码以及模型都已经以 BSD 协议发布到 GitHub上了 (这里)。
总结一下
虽然是作为我在学校一门课程的 Final Project 的一部分完成的项目,但是在这个过程中我总算是对于 Shader 的编写方面有所入门。此外,这次进行 Blender进行建模也感觉比以前顺利了许多。
虽然对 Blender 和 WebGL 的爱好现在看起来还没有什么现实价值,但是能够自己完成一个有趣的 Project还是很有成就感的!