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

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

推荐:将 NSDT场景编辑器 加入你的3D开发工具链。

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 belowconst 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: FullScreenQuadmaterial: THREE.ShaderMaterialconstructor() {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.textureif (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 lateruResolution: {value: new THREE.Vector2(1, 1)}},fragmentShader, // to be imported from another filevertexShader // to be imported from another file})}
}

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

// PencilLinesPass.ts
export class PencilLinesPass extends Pass {fsQuad: FullScreenQuadmaterial: PencilLinesMaterialconstructor({ width, height }: { width: number; height: number }) {super()// change the material from to our new PencilLinesMaterialthis.material = new PencilLinesMaterial() this.fsQuad = new FullScreenQuad(this.material)// set the uResolution uniform with the current canvas's width and heightthis.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 kernelconst mat3 Gy = mat3(-1, 0, 1, -2, 0, 2, -1, 0, 1);// y direction kernel// fetch the 3x3 neighbourhood of a fragment// first columnfloat tx0y0 = getValue(-1, -1);float tx0y1 = getValue(-1, 0);float tx0y2 = getValue(-1, 1);// second columnfloat tx1y0 = getValue(0, -1);float tx1y1 = getValue(0, 0);float tx1y2 = getValue(0, 1);// third columnfloat tx2y0 = getValue(1, -1);float tx2y1 = getValue(1, 0);float tx2y2 = getValue(1, 1);// gradient value in x directionfloat 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 directionfloat 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 gradientfloat 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 = normalBufferthis.normalMaterial = new THREE.MeshNormalMaterial()

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

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

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

renderer.setRenderTarget(this.normalBuffer)
const overrideMaterialValue = this.scene.overrideMaterialthis.scene.overrideMaterial = this.normalMaterial
renderer.render(this.scene, this.camera)
this.scene.overrideMaterial = overrideMaterialValuethis.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 运算符应用于深度缓冲区,完全忽略漫反射缓冲区,以获得没有轮廓阴影的轮廓对象。 此外,还可以根据场景中的照明信息而不是基于对象的法线来添加生成的噪声。 可能性是无限的,我希望本教程能激励你深入研究!


原文链接:Three铅笔手绘效果 — BimAnt

Three.js铅笔手绘效果实现相关推荐

  1. 铅笔手绘教育教学PPT模板-优页文档

    模板介绍 铅笔手绘教育教学PPT模板-优页文档.一套,教学课件,安全教育,幻灯片模板,内含灰色多种配色,风格设计,动态播放效果,精美实用. 希望下面这份精美的PPT模板能给你带来帮助,温馨提示:本资源 ...

  2. PS效果教程——冒充手绘效果

    PS效果教程--冒充手绘效果 先来发个原图和效果图: 图1 图2 开始拉! 1.先将原图复制一份 2.选择滤镜--风格化--查找边缘 图3 #p#副标题#e# 3.再选择滤镜--艺术效果--粗糙蜡笔, ...

  3. Python 数据分析与展示笔记2 -- 图像手绘效果

    Python 数据分析与展示笔记2 – 图像手绘效果 Python 数据分析与展示系列笔记是笔者学习.实践Python 数据分析与展示的相关笔记 课程链接: Python 数据分析与展示 参考文档: ...

  4. Python实现图像的手绘效果

      用Python实现手绘图像的效果 1.图像的RGB色彩模式   图像一般使用RGB色彩模式,即每个像素点的颜色由红®.绿(G).蓝(B)组成.RGB三个颜色通道的变化和叠加得到各种颜色,其中: R ...

  5. python数据分析与展示--图像的手绘效果

    目录 一.图像的数组表示 1.图像的RGB色彩模式 2.PIL库 二.图像变换 1.image转换成array 2.array转换成image 三.图像的手绘效果 1.实例介绍  ​ 2.编程实例 一 ...

  6. 利用Numpy+PIL读取图像实现手绘效果

    读取图像+简单处理 import numpy as np from PIL import Imagepath = "" #图像路径im = np.array(Image.open( ...

  7. Python 神仙姐姐图像手绘效果实现

    文章目录 一.图像的 RGB 色彩模式 二.Python的 PIL 库 三.图像的数组表示 四.图像的变换 五.图像的手绘效果实现 一.图像的 RGB 色彩模式 图像一般使用 RGB 色彩模式,即每个 ...

  8. Python实现图片手绘效果

    Python实现图片手绘效果 在图像处理领域中,手绘效果是一个非常有趣的特效.在这篇文章中,我们将讨论如何使用Python和PIL库来实现图片的手绘效果. 准备工作 在开始之前,我们需要安装PIL库. ...

  9. [原创] 人物仿手绘效果--美女篇(超详细哦)

    [转自]http://68ps.5d6d.com/thread-11541-1-4.html 大家好!第一次写教程,写的不好,但还是希望大家能够给我点鼓励! 虽然效果处理的不尽人意,然而,我还是厚着脸 ...

最新文章

  1. 自己写的一个启动JBoss服务器的bat批处理
  2. 成功解决TypeError: Scalar value for argument 'color' is not numeric
  3. LaTeX引用多篇bibtex格式文献
  4. flutter tab选项卡appbar下的选项卡
  5. linux下mysql数据库备份与恢复(全量+增量)
  6. 大数据Hadoop集群搭建
  7. 启动XMind8报错The configuration area
  8. PhpSpreadsheet导入
  9. 对接百度api之银行卡识别
  10. mysql 执行顺序 SQL语句执行顺序分析
  11. 【LeetCode】476. 数字的补数 Number Complement
  12. JavaScript說分明
  13. matlab2019使用仿真,光学仿真matlab中的handles怎么用 终于明白了
  14. 社交类app开发( 仿陌陌 客户端+服务器端)
  15. Google Java编程风格指南(献给那些没有良好编码习惯的程序员们)
  16. 为什么炒外汇总是不断的亏钱?
  17. 移动端列表分页,删除交互优化
  18. python多线程下载编程软件_python抖音多线程下载无水印视频
  19. Wonderware-InTouch Historian WorkBook部件制作简易SPC
  20. KoobooJson在asp.net core中的使用

热门文章

  1. CSS学习笔记-07-字体与背景
  2. JS中~偏移量设定方式与案例分析
  3. java 设置精度_java中的设置精度(小数位数)向上取整 BigDecimal
  4. FlutterEasyLoading 导致粘贴时红屏,修复红屏时文字出现黄色双下划线问题修复方案
  5. 阿里云CN域名注册、续费、转入和赎回价格表
  6. 海店湾养生:睡前做这4件事,让你拥有好睡眠!
  7. 变废为宝--Android手机变服务器
  8. 手机测试充电宝软件,记者随机测试5款产品 “有共享充电宝半小时只充了11%”...
  9. Keycloak之Gerrit安装与集成-yellowcong
  10. win7 64位 系统中“打开或关闭Windows功能”列表空白