本文使用Three.js的后处理创建粗略的铅笔画效果。我们将完成创建自定义后处理渲染通道、在 WebGL中实现边缘检测、将法线缓冲区重新渲染到渲染目标以及使用生成和导入的纹理调整最终结果的步骤。翻译自Codrops,有改动。
Three.js 中的后处理
Three.js中的后处理是一种在绘制场景后将效果应用于渲染场景的方法。除了Three.js提供的所有开箱即用的后处理效果外,还可以通过创建自定义渲染通道来添加我们自己的滤镜。
自定义渲染过程本质上是一个函数,它接收场景图像并返回一个新图像,并应用所需的效果。我们可以将这些渲染通道想象成Photoshop中的图层效果————每个渲染通道都基于之前的效果输出应用新的滤镜。生成的图像是所有不同效果(滤镜)的组合。
在 Three.js 中启用后处理
要向我们的场景添加后处理效果,我们需要设置EffectComposer
来进行场景渲染。这个EffectComposer
将后处理效果按传递顺序叠加在一起。如果我们想让我们渲染的场景传递给下一个效果,我们需要先利用RenderPass
创建一个后处理通道。
然后,在启动渲染循环的tick函数中,我们调用composer.render()
来代替renderer.render(scene, camera)
。
const renderer = new THREE.WebGLRenderer()
const composer = new EffectComposer(renderer)
const renderPass = new RenderPass(scene, camera)
composer.addPass(renderPass)
function tick() {
requestAnimationFrame(tick)
composer.render()
}
tick()
有两种创建自定义后处理效果的方法:
1.创建自定义着色器并将其传递给ShaderPass
实例,或者
2.通过扩展Pass
类来创建自定义渲染通道。
因为我们希望我们的后处理效果获得比uniform和attribute更多的信息,所以我们将创建一个自定义渲染通道。
创建自定义渲染通道
一个自定义通道继承自Pass
类,并具有三个方法:setSize
、render
和dispose
,我们将主要关注render
方法。
首先,我们扩展Pass
类来创建自己的PencilLinesPass
类,然后再实现我们自己的渲染逻辑。
import { Pass, FullScreenQuad } from 'three/examples/jsm/postprocessing/Pass'
import * as THREE from 'three'
export class PencilLinesPass extends Pass {
constructor() {
super()
}
render(
renderer: THREE.WebGLRenderer,
: THREE.WebGLRenderTarget,
readBuffer: THREE.WebGLRenderTarget
) {
if (this.renderToScreen) {
renderer.setRenderTarget(null)
} else {
renderer.setRenderTarget(writeBuffer)
if (this.clear) renderer.clear()
}
}
}
从上面代码中可以看出该render
方法接受一个WebGLRenderer
对象和两个WebGLRenderTarget
对象(一个用于写入缓冲区,另一个用于读取缓冲区)。在Three.js中,渲染目标一般是我们可以渲染到场景的纹理,它们用于在通道之间发送数据。readBuffer
从先前的渲染通道接收数据,在我们的例子中是默认的RenderPass
;writeBuffer
则是将数据发送到下一个渲染通道。
当renderToScreen
为true的时候,则意味着我们要将缓冲区发送到屏幕而不是渲染目标。渲染器的渲染目标设置为null的时候,默认就是为屏幕画布。
在这一点上,我们实际上并没有渲染任何东西,甚至没有通过readBuffer
传入数据。为了渲染场景事物,我们需要创建一个FullscreenQuad
和一个负责渲染的着色器材质,然后将着色器材质渲染到FullscreenQuad
。
为了测试一切设置是否正确,我们可以使用threejs内置的CopyShader
来显示我们放入其中的任何图像。
import { Pass, FullScreenQuad } from 'three/examples/jsm/postprocessing/Pass'
import { CopyShader } from 'three/examples/jsm/shaders/CopyShader'
import * as THREE from 'three'
export class PencilLinesPass extends Pass {
fsQuad: FullScreenQuad
material: THREE.ShaderMaterial
constructor() {
super()
this.material = new THREE.ShaderMaterial(CopyShader)
this.fsQuad = new FullScreenQuad(this.material)
}
dispose() {
this.material.dispose()
this.fsQuad.dispose()
}
render(
renderer: THREE.WebGLRenderer,
writeBuffer: THREE.WebGLRenderTarget,
readBuffer: THREE.WebGLRenderTarget
) {
this.material.uniforms['tDiffuse'].value = readBuffer.texture
if (this.renderToScreen) {
renderer.setRenderTarget(null)
this.fsQuad.render(renderer)
} else {
renderer.setRenderTarget(writeBuffer)
if (this.clear) renderer.clear()
this.fsQuad.render(renderer)
}
}
}
注意:我们将uniform变量tDiffuse
传递给着色器材质。CopyShader
已经内置了这个uniform,它代表要在屏幕上渲染显示的图像。如果你正在编写自己的ShaderPass
,这个uniform将自动传递到你的着色器中。
剩下的就是通过将自定义渲染通道添加到EffectComposer
来将自定义渲染通道连接到场景中,而且注意要在添加完RenderPass
之后。
const renderPass = new RenderPass(scene, camera)
const pencilLinesPass = new PencilLinesPass()
composer.addPass(renderPass)
composer.addPass(pencilLinesPass)
查看 Codesandbox 示例
具有自定义渲染通道和 CopyShader 的场景
用于创建轮廓的 Sobel 算子
我们需要能够告诉计算机根据我们的输入图像(即场景图像)检测边缘线条,我们将使用的这种边缘检测称为 Sobel 算子。
Sobel 算子通过查看图像一小部分的梯度来进行边缘检测————本质上是检查从一个值到另一个值的过渡有多尖锐。图像被分解成更小的“内核”,比如说是 3px x 3px 的正方形,其中中心像素是当前正在处理的像素。下图显示了它的样子:中心的红色方块代表当前正在评估的像素,其余方块是它的邻近像素。
3px x 3px 内核
然后通过获取像素值(亮度)并将其乘以基于其相对于被评估像素的位置的权重来计算每个邻近像素的加权值。这是通过权重在水平和垂直方向上偏置梯度来完成的。取两个值的平均值,如果它超过某个阈值,我们认为该像素表示边缘。
Sobel 算子的水平和垂直梯度
Three.js 已经为我们提供了SobelOperatorShader中的代码,我们可以将这段代码复制到我们的着色器材质中。
实现 Sobel 算子
我们现在需要添加我们自己的ShaderMaterial
来代替CopyShader
,以便我们可以控制顶点和片段着色器,以及发送给那些着色器的uniform。
// PencilLinesMaterial.ts
export class PencilLinesMaterial extends THREE.ShaderMaterial {
constructor() {
super({
uniforms: {
tDiffuse: { value: null },
// 我们稍后会在这里传递画布大小
uResolution: {
value: new THREE.Vector2(1, 1)
}
},
fragmentShader,
vertexShader
})
}
}
然后我们需要在场景中使用我们的新着色器材质。
// PencilLinesPass.ts
export class PencilLinesPass extends Pass {
fsQuad: FullScreenQuad
material: PencilLinesMaterial
constructor({ width, height }: { width: number; height: number }) {
super()
// 将材质更改为我们新的PencilLinesMaterial
this.material = new PencilLinesMaterial()
this.fsQuad = new FullScreenQuad(this.material)
// 将 uResolution 设置为当前画布的宽度和高度
this.material.uniforms.uResolution.value = new THREE.Vector2(width, height)
}
}
接下来,我们可以编写顶点和片段着色器。
除了设置gl_Position
并将uv
属性传递给片段着色器之外,顶点着色器并没有做其他事情。因为我们将图像渲染到FullscreenQuad
,所以uv
信息对应于任何给定片段在屏幕上的位置。
// vertex shader
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}
片元着色器要复杂一些,所以我们逐行进行分解。首先,我们要使用Three.js已经提供的实现算法来实现Sobel算子。唯一的区别是我们想要控制我们如何计算每个像素的值,因为我们也将引入法线缓冲区的线检测。
float combinedSobelValue() {
// 内核定义(在 glsl 中,矩阵按列优先顺序填充)
const mat3 Gx = mat3(-1, -2, -1, 0, 0, 0, 1, 2, 1);// x方向内核
const mat3 Gy = mat3(-1, 0, 1, -2, 0, 2, -1, 0, 1);// y方向内核
// 获取片段的 3x3 邻域
// 第一列
float tx0y0 = getValue(-1, -1);
float tx0y1 = getValue(-1, 0);
float tx0y2 = getValue(-1, 1);
// 第二列
float tx1y0 = getValue(0, -1);
float tx1y1 = getValue(0, 0);
float tx1y2 = getValue(0, 1);
// 第三列
float tx2y0 = getValue(1, -1);
float tx2y1 = getValue(1, 0);
float tx2y2 = getValue(1, 1);
// x方向的梯度值
float valueGx = Gx[0][0] * tx0y0 + Gx[1][0] * tx1y0 + Gx[2][0] * tx2y0 +
Gx[0][1] * tx0y1 + Gx[1][1] * tx1y1 + Gx[2][1] * tx2y1 +
Gx[0][2] * tx0y2 + Gx[1][2] * tx1y2 + Gx[2][2] * tx2y2;
// y方向的梯度值
float valueGy = Gy[0][0] * tx0y0 + Gy[1][0] * tx1y0 + Gy[2][0] * tx2y0 +
Gy[0][1] * tx0y1 + Gy[1][1] * tx1y1 + Gy[2][1] * tx2y1 +
Gy[0][2] * tx0y2 + Gy[1][2] * tx1y2 + Gy[2][2] * tx2y2;
// 总梯度的大小
float G = (valueGx * valueGx) + (valueGy * valueGy);
return clamp(G, 0.0, 1.0);
}
我们将当前像素的偏移量传递给getValue
函数,在获取邻域像素的值。目前,我们仅需要评估漫反射缓冲区的值,我们将在下一步中添加法线缓冲区。
float valueAtPoint(sampler2D image, vec2 coord, vec2 texel, vec2 point) {
vec3 luma = vec3(0.299, 0.587, 0.114);
return dot(texture2D(image, coord + texel * point).xyz, luma);
}
float diffuseValue(int x, int y) {
return valueAtPoint(tDiffuse, vUv, vec2(1.0 / uResolution.x, 1.0 / uResolution.y), vec2(x, y)) * 0.6;
}
float getValue(int x, int y) {
return diffuseValue(x, y);
}
该valueAtPoint
函数可以输入任何纹理(漫反射或法线)并返回指定点的灰度值。luma
向量用于计算颜色的亮度,从而将rgb颜色转换为灰度值。这个实现来自glsl-luma。
因为getValue
函数只考虑漫反射缓冲区,这意味着场景中的任何边缘都将被检测到,包括由投射的阴影创建的边缘。这也意味着例如物体的轮廓,如果它们与周围环境(投射的阴影)融合得太好,可能会被忽略。为了捕获那些缺失的边缘,我们接下来将从法线缓冲区添加边缘检测。
最后,我们在主函数中调用 Sobel 算子,如下所示:
void main() {
float sobelValue = combinedSobelValue();
sobelValue = smoothstep(0.01, 0.03, sobelValue);
vec4 lineColor = vec4(0.32, 0.12, 0.2, 1.0);
if (sobelValue > 0.1) {
gl_FragColor = lineColor;
} else {
gl_FragColor = vec4(1.0);
}
}
查看 Codesandbox 示例
创建一个法线缓冲区渲染
为了获得合适的轮廓,Sobel算子通常应用于场景的法线和深度缓冲区,因此会捕获对象的轮廓,但不会捕获对象内的线条。Omar Shehata 在他的How to render outlines in WebGL教程中描述了这种方法。出于只是实现粗略铅笔效果的目的,我们不需要完整的边缘检测,但我们确实希望使用法线来获得更完整的边缘。
由于法线是表示对象表面每个点方向的向量,因此通常用颜色表示以获取包含场景中所有法线数据的图像。这张图被称为“法线缓冲区”。
为了创建一个法线缓冲区,首先我们需要在PencilLinesPass
构造函数中创建一个新的渲染目标。我们还需要在类上创建一个MeshNormalMaterial
,因为我们将在渲染法线缓冲区时使用它来覆盖场景的默认材质。
const normalBuffer = new THREE.WebGLRenderTarget(width, height)
normalBuffer.texture.format = THREE.RGBAFormat
normalBuffer.texture.type = THREE.HalfFloatType
normalBuffer.texture.minFilter = THREE.NearestFilter
normalBuffer.texture.magFilter = THREE.NearestFilter
normalBuffer.texture.generateMipmaps = false
normalBuffer.stencilBuffer = false
this.normalBuffer = normalBuffer
this.normalMaterial = new THREE.MeshNormalMaterial()
为了渲染通道内的场景,我们还需要通过渲染通道的构造函数来传入scene和camera。
// PencilLinesPass.ts 构造函数
constructor({ ..., scene, camera}: { ...; scene: THREE.Scene; camera: THREE.Camera }) {
super()
this.scene = scene
this.camera = camera
...
}
在渲染通道的render
方法中,我们想要使用覆盖默认材质的法线材质重新渲染场景。我们将renderTarget设置为normalBuffer,并像往常一样使用WebGLRenderer
渲染场景。唯一的区别是,渲染器不是使用场景的默认材质渲染到屏幕,而是使用法线材质渲染到我们的渲染目标(此处即为我们的normalBuffer)。然后我们将normalBuffer.texture
传递给着色器材质。overrideMaterial
参数表示强制使用定义的材质渲染场景中的所有内容。
renderer.setRenderTarget(this.normalBuffer)
const overrideMaterialValue = this.scene.overrideMaterial
this.scene.overrideMaterial = this.normalMaterial
renderer.render(this.scene, this.camera)
this.scene.overrideMaterial = overrideMaterialValue
this.material.uniforms.uNormals.value = this.normalBuffer.texture
this.material.uniforms.tDiffuse.value = readBuffer.texture
如果此时我们利用texture2D(uNormals,vUv);
将法线缓冲区的值赋给gl_FragColor
,渲染结果将是下图所示:
当前场景的法线缓冲区
在自定义材质的片段着色器中,我们修改getValue
函数,让它包含漫反射缓冲区和法线缓冲区的 Sobel 算子。如果我们在这里只计算法线缓冲区,会发现平面阴影的边缘就没有了,因为平面法线是没有过渡的。
float normalValue(int x, int y) {
return valueAtPoint(uNormals, vUv, vec2(1.0 / uResolution.x, 1.0 / uResolution.y), vec2(x, y)) * 0.3;
}
float getValue(int x, int y) {
return diffuseValue(x, y) + normalValue(x, y);
}
查看 Codesandbox 示例
为着色和波浪线添加生成的纹理噪声
有两种方法可以将噪声带入后处理效果:
- 通过在着色器中由程序生成噪声,或者
- 通过使用带有噪声的图像并将其应用为纹理。
两者都提供了不同级别的灵活性和控制。对于噪声函数,我们使用Inigo Quilez的梯度噪声实现算法,因为它在应用于“着色”效果时提供了很好的噪声均匀性。
这个噪声函数是在获取Sobel算子的值时调用的,并专门作用于法线值,所以片段着色器中getValue
的函数变化如下:
float getValue(int x, int y) {
float noiseValue = noise(gl_FragCoord.xy);
noiseValue = noiseValue * 2.0 - 1.0;
noiseValue *= 10.0;
return diffuseValue(x, y) + normalValue(x, y) * noiseValue;
}
这样得出来的结果是在法向量值发生变化时,对象曲线上形成带纹理的铅笔线和点画效果。请注意,平面对象(如Plane)不会产生这些效果,因为它们的法线值没有任何变化。
此效果的下一步也是最后一步是为线条添加扭曲。为此,我们使用了在Photoshop中使用渲染云效果创建的纹理文件。
在 Photoshop 中创建的生成的云纹理
云纹理通过一个uniform变量传递给着色器,与漫反射和法线缓冲区的方式相同。一旦着色器可以访问纹理,我们就可以对每个片段的纹理进行采样,并使用它来偏移我们在缓冲区中读取的位置。本质上,我们通过扭曲我们正在读取的图像来获得波浪线效果。因为纹理的噪点是平滑的,线条不会出现锯齿状和不规则的情况。
float normalValue(int x, int y) {
float cutoff = 50.0;
float offset = 0.5 / cutoff;
float noiseValue = clamp(texture(uTexture, vUv).r, 0.0, cutoff) / cutoff - offset;
return valueAtPoint(uNormals, vUv + noiseValue, vec2(1.0 / uResolution.x, 1.0 / uResolution.y), vec2(x, y)) * 0.3;
}
查看 Codesandbox 示例
结论
有许多技术可以在3D中创建手绘或素描效果。我们可以通过基于噪声纹理调制被认为是边缘的阈值来调整线条粗细。我们还可以将Sobel算子应用于深度缓冲区,完全忽略漫反射缓冲区,以获得没有轮廓阴影的轮廓对象。我们可以根据场景中的照明信息而不是基于对象的法线来添加生成的噪声。接下来我会将这种效果应用到cesium和mapbox上。