Three铅笔手绘效果

在这个教程中,我们将学习如何使用 Three.js 后处理创建铅笔手绘效果。 我们将完成创建自定义后处理渲染通道、在 WebGL 中实现边缘检测、将法线缓冲区重新渲染到渲染目标以及使用生成和导入的纹理调整最终结果的步骤。

这就是最终结果的样子,让我们开始吧!

1、Three.js 中的后处理

Three.js 中的后处理是一种在绘制场景后将效果应用于渲染场景的方法。 除了 Three.js 提供的所有开箱即用的后处理效果外,还可以通过创建自定义渲染通道来添加您自己的滤镜。

自定义渲染过程本质上是一个函数,它接收场景图像并返回一个新图像,并应用所需的效果。 你可以将这些渲染通道想象成 Photoshop 中的图层效果——每个渲染通道都基于之前的效果输出应用新的滤镜。 生成的图像是所有不同效果的组合。

2、在 Three.js 中启用后处理

要向我们的场景添加后处理,需要设置场景渲染在 WebGLRenderer之外还使用 EffectComposer。 效果器合成器将后处理效果按传递顺序堆叠在一起。 如果我们想让渲染场景传递给下一个效果,需要先添加RenderPass后处理pass传递。

然后,在启动渲染循环的 tick 函数中,我们调用 composer.render() 而不是 renderer.render(scene, camera)

const renderer = new THREE.WebGLRenderer()
// ... settings for the renderer are available in the Codesandbox below

const composer = new EffectComposer(renderer)
const renderPass = new RenderPass(scene, camera)

composer.addPass(renderPass)

function tick() {
	requestAnimationFrame(tick)
	composer.render()
}

tick()

有两种创建自定义后处理效果的方法:

  • 创建自定义着色器并将其传递给 ShaderPass 实例,或者
    通过扩展 Pass 类创建自定义渲染通道。
  • 因为我们希望我们的后处理效果获得比制服和属性更多的信息,所以我们将创建一个自定义渲染通道。

3、创建自定义渲染通道

虽然目前没有太多关于如何在 Three.js 中编写您自己的自定义后处理通道的文档,但库中已有大量示例可供学习。 自定义通道继承自通道类,具有三个方法:setSize、render 和dispose。 正如您可能已经猜到的那样,我们将主要关注渲染方法。

首先,我们将从创建自己的 PencilLinesPass 开始,它扩展了 Pass 类,稍后将实现我们自己的渲染逻辑。

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,
		writeBuffer: THREE.WebGLRenderTarget,
		readBuffer: THREE.WebGLRenderTarget
	) {
		if (this.renderToScreen) {
			renderer.setRenderTarget(null)
		} else {
			renderer.setRenderTarget(writeBuffer)
			if (this.clear) renderer.clear()
		}
	}
}

如你所见,render 方法接受一个 WebGLRenderer 和两个 WebGLRenderTargets,一个用于写入缓冲区,另一个用于读取缓冲区。 在 Three.js 中,渲染目标基本上是我们可以渲染场景的纹理,它们用于在通道之间发送数据。 读取缓冲区从先前的渲染通道接收数据,在我们的例子中是默认的渲染通道。 写入缓冲区将数据发送到下一个渲染通道。

如果 renderToScreen 为真,则意味着我们要将缓冲区发送到屏幕而不是渲染目标。 渲染器的渲染目标设置为 null,因此它默认为屏幕画布。

在这一点上,我们实际上并没有渲染任何东西,甚至没有通过 readBuffer 传入的数据。 为了渲染事物,我们需要创建一个 FullscreenQuad 和一个负责渲染的着色器材质。 着色器材质被渲染到 FullscreenQuad。

为了测试一切设置是否正确,我们可以使用内置的 CopyShader 来显示我们放入其中的任何图像。 在这种情况下,在这种情况下是 readBuffer 的纹理。

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)
		}
	}
}
注意:我们将统一的 tDiffuse 传递给着色器材质。 CopyShader 已经内置了这种制服,它代表要在屏幕上显示的图像。 如果你正在编写自己的 ShaderPass,此uniform将自动传递到您的着色器。

剩下的就是通过将自定义渲染通道添加到 EffectComposer 来将自定义渲染通道连接到场景中——当然是在 RenderPass 之后!

const renderPass = new RenderPass(scene, camera)
const pencilLinesPass = new PencilLinesPass()

composer.addPass(renderPass)
composer.addPass(pencilLinesPass)

现在我们已经设置好了一切,我们实际上可以开始创建我们的特殊效果了!

4、用于创建轮廓的 Sobel 算子

我们需要能够告诉计算机根据我们的输入图像检测线条,在本例中是渲染场景。 我们将使用的这种边缘检测称为 Sobel 算子,它只包含几个步骤。

Sobel 算子通过观察图像一小部分的梯度来进行边缘检测——本质上是从一个值到另一个值的过渡有多尖锐。 图像被分解成更小的“内核”,或 3px x 3px 的正方形,其中中心像素是当前正在处理的像素。 下图显示了它的样子:中心的红色方块代表当前正在评估的像素,其余方块是它的邻居。

3 x 3px 核

然后通过获取像素值(亮度)并将其乘以基于其相对于被评估像素的位置的权重来计算每个邻居的加权值。 这是通过权重在水平和垂直方向上偏置梯度来完成的。 取两个值的平均值,如果它超过某个阈值,我们认为该像素表示边缘。

Sobel 算子的水平和垂直梯度

虽然 Sobel 算子的实现几乎直接遵循上面的图像表示,但仍然需要时间来掌握。 值得庆幸的是,我们不必自己实现,因为 Three.js 已经为我们提供了 SobelOperatorShader 中的代码。 我们会将这段代码复制到我们的着色器材质中。

5、实现 Sobel 算子

我们现在需要添加自己的 ShaderMaterial 而不是 CopyShader,以便我们可以控制顶点和片段着色器,以及发送到这些着色器的uniform。

// PencilLinesMaterial.ts
export class PencilLinesMaterial extends THREE.ShaderMaterial {
	constructor() {
		super({
			uniforms: {
				// we'll keep the naming convention here since the CopyShader
				// also used a tDiffuse texture for the currently rendered scene.
				tDiffuse: { value: null },
				// we'll pass in the canvas size here later
				uResolution: {
					value: new THREE.Vector2(1, 1)
				}
			},
			fragmentShader, // to be imported from another file
			vertexShader // to be imported from another file
		})
	}
}

我们很快就会接触到片段和顶点着色器,但首先我们需要在场景中使用我们的新着色器材质。 我们通过换出 CopyShader 来做到这一点。 不要忘记将分辨率(画布大小)作为着色器的uniform传递。 虽然超出了本教程的范围,但在画布调整大小时更新此uniform也很重要。

// PencilLinesPass.ts
export class PencilLinesPass extends Pass {
	fsQuad: FullScreenQuad
	material: PencilLinesMaterial

	constructor({ width, height }: { width: number; height: number }) {
		super()
		
		// change the material from to our new PencilLinesMaterial
		this.material = new PencilLinesMaterial() 
		this.fsQuad = new FullScreenQuad(this.material)

		// set the uResolution uniform with the current canvas's width and height
		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() {
    // kernel definition (in glsl matrices are filled in column-major order)
    const mat3 Gx = mat3(-1, -2, -1, 0, 0, 0, 1, 2, 1);// x direction kernel
    const mat3 Gy = mat3(-1, 0, 1, -2, 0, 2, -1, 0, 1);// y direction kernel

    // fetch the 3x3 neighbourhood of a fragment

    // first column
    float tx0y0 = getValue(-1, -1);
    float tx0y1 = getValue(-1, 0);
    float tx0y2 = getValue(-1, 1);

    // second column
    float tx1y0 = getValue(0, -1);
    float tx1y1 = getValue(0, 0);
    float tx1y2 = getValue(0, 1);

    // third column
    float tx2y0 = getValue(1, -1);
    float tx2y1 = getValue(1, 0);
    float tx2y2 = getValue(1, 1);

    // gradient value in x direction
    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;

    // gradient value in y direction
    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;

    // magnitude of the total gradient
    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 函数采用任何纹理(漫反射或法线)并返回指定点的灰度值。 亮度向量用于计算颜色的亮度,从而将颜色转换为灰度。 实现来自 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);
    }
}
使用 Sobel 算子进行边缘检测的渲染场景

6、创建法线缓冲区渲染

为了获得合适的轮廓,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()

为了渲染通道内的场景,渲染通道实际上需要对场景和相机的引用。 我们还需要通过渲染通道的构造函数发送它们。

// PencilLinesPass.ts constructor
constructor({ ..., scene, camera}: { ...; scene: THREE.Scene; camera: THREE.Camera }) {
	super()
	this.scene = scene
	this.camera = camera
    ...
}

在 pass 的渲染方法中,我们想要用覆盖默认材质的普通材质重新渲染场景。 我们将 renderTarget 设置为 normalBuffer 并像往常一样使用 WebGLRenderer 渲染场景。 唯一的区别是,渲染器不是使用场景的默认材质渲染到屏幕,而是使用普通材质渲染到我们的渲染目标。 然后我们将 normalBuffer.texture 传递给着色器材质。

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);
}

结果看起来与上一步相似,但我们将能够在下一步中使用此法线数据添加额外的噪声和粗略度。

应用于漫反射和法线缓冲区的 Sobel 算子

7、为阴影和波浪线添加生成的纹理噪声

此时有两种方法可以将噪声带入后处理效果:

  • 通过在着色器中按程序生成噪声,或者
  • 通过使用带有噪声的现有图像并将其应用为纹理。

两者都提供不同级别的灵活性和控制。 对于噪声函数,我使用了 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;
}

结果是在法向量值发生变化的对象曲线上形成带纹理的铅笔线和点画效果。 请注意,平面对象(如飞机)不会产生这些效果,因为它们的法线值没有任何变化。

此效果的下一步也是最后一步是为线条添加失真。 为此,我使用了在 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;
}

你还可以研究如何单独应用每个缓冲区的效果。 这会导致线条相互偏移,从而产生更好的手绘效果。

最终效果包括基于正常缓冲区的“阴影”和线条失真

8、结束语

有许多技术可以在 3D 中创建手绘或素描效果,本教程仅列出其中的一部分。 从这里开始,有多种方法可以前进。 可以通过基于噪声纹理调制被认为是边缘的阈值来调整线条粗细。 还可以将 Sobel 运算符应用于深度缓冲区,完全忽略漫反射缓冲区,以获得没有轮廓阴影的轮廓对象。 此外,还可以根据场景中的照明信息而不是基于对象的法线来添加生成的噪声。 可能性是无限的,我希望本教程能激励你深入研究!


原文链接:Sketchy Pencil Effect with Three.js Post-Processing

BimAnt翻译整理,转载请标明出处