当前位置: 首页 > news >正文

使用 Three.js 后处理的粗略铅笔画效果

本文使用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类,并具有三个方法:setSizerenderdispose,我们将主要关注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: 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)}}
}

注意:我们将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: FullScreenQuadmaterial: PencilLinesMaterialconstructor({ width, height }: { width: number; height: number }) {super()// 将材质更改为我们新的PencilLinesMaterialthis.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 = normalBufferthis.normalMaterial = new THREE.MeshNormalMaterial()

为了渲染通道内的场景,我们还需要通过渲染通道的构造函数来传入scene和camera。

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

在渲染通道的render方法中,我们想要使用覆盖默认材质的法线材质重新渲染场景。我们将renderTarget设置为normalBuffer,并像往常一样使用WebGLRenderer渲染场景。唯一的区别是,渲染器不是使用场景的默认材质渲染到屏幕,而是使用法线材质渲染到我们的渲染目标(此处即为我们的normalBuffer)。然后我们将normalBuffer.texture传递给着色器材质。overrideMaterial参数表示强制使用定义的材质渲染场景中的所有内容。

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

查看 Codesandbox 示例

为着色和波浪线添加生成的纹理噪声

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

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

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

http://www.lryc.cn/news/6980.html

相关文章:

  • 推荐一些不常见的搜索引擎
  • RabbitMQ工作模式
  • 机器学习在预测脊髓型颈椎病中的应用:一项28名参与者的事后初步研究
  • 【智能计算数学】微积分
  • win10+RTX4070ti+libtorch部署
  • 【Python百日进阶-Web开发-Vue3】Day518 - Vue+ts后台项目5:用户列表
  • Linux内核转储---kdump原理梳理
  • 【C++】从0到1入门C++编程学习笔记 - 实战篇:演讲比赛流程管理系统
  • 04 OpenCV位平面分解
  • Onvif协议如何判断摄像机支持 —— 筑梦之路
  • 情人节new一个对象给你
  • linux篇【15】:应用层-网络https协议
  • 索引-性能分析-explain
  • mbedtls加密组件使用示例
  • 如何量测太阳光模拟器的光谱致合度?
  • 网络安全领域中CISP证书八大类都有什么
  • 17- 梯度提升回归树GBRT (集成算法) (算法)
  • 05 OpenCV色彩空间处理
  • 【CS224图机器学习】task1 图机器学习导论
  • Powershell Install SQL Server 2022
  • Jetson NX2 装机过程
  • 初始C++(四):内联函数
  • 九、初识卷积
  • 【Linux】【编译】编译调试过程中如何打印出实际的编译命令
  • linux安装jdk
  • 迅为iTOP-3A5000龙芯开发板安装UOS操作系统
  • Firefox 110, Chrome 110, Chromium 110 官网离线下载 (macOS, Linux, Windows)
  • 如何使用ArcGIS转换坐标
  • 链表基本原理
  • 基于JAVA+SpringBoot+Vue+ElementUI中学化学实验室耗材管理系统