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

news2024/11/25 12:45:25

本文使用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: 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 示例

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

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

  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.coloradmin.cn/o/346194.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

1.9 实践项目——爬取学生信息

1. 项目简介设计一个 Web 服务器 server.py,它读取 students.txt 文件中的学生数据,以表格的形式呈现在网页上,其中 students.txt 的格式如下:No,Name,Gender,Age1001,张三,男,201002,李四,女,191003,王五,男,21设计一个客户端的爬…

【Junit5】就这篇,带你从入门到进阶

目录 前言 1.前置工作 2、注解 2、断言(Assertions类) 2.1、断言 匹配/不匹配 2.2、断言结果 为真/为假 2.3、断言结果 为空/不为空 3、用例的执行顺序 3.1、用例执行顺序是怎样的? 3.2、通过order注解来排序 4、参数化 4.1、单…

Firefox 110, Chrome 110, Chromium 110 官网离线下载 (macOS, Linux, Windows)

Mozilla Firefox, Google Chrome, Chromium, Apple Safari 请访问原文链接:https://sysin.org/blog/chrome-firefox-download/,查看最新版。原创作品,转载请保留出处。 作者主页:www.sysin.org 天下只剩三种(主流&am…

feign技巧 - form方式传值

feign技巧 - form方式传值。 0. 文章目录1. 前言2. 调用样例3. 原理解析3.1 feign端序列化参数3.2 SpringMVC服务端解析参数3.3 补充 - 继承关系不会被传递的原因3.4 补充 - 不能使用GET。4. 总结1. 前言 直接正题。 如何使用feign进行fom表单方式的请求调用,以及其…

leaflet 上传KMZ文件,并在map上显示(062)

第062个 点击查看专栏目录 本示例的目的是介绍演示如何在vue+leaflet中本地上传包kmz文件,解析并在地图上显示图形。在制作本示例的过程中,还有点缺憾,就是kmz中的图片文件没有在jszip中处理好,只能先解压缩后,将图片文件放到public/文件加下,暂时留一个遗憾点,以后再做…

又发现一个ChatGPT体验站,辅助写代码真方便

♥️ 作者:Hann Yang ♥️ 主页:CSDN主页 ♥️ 2022博客之星Top58,原力榜Top10/作者周榜Top13 ♥️ “抢走你工作的不会是 AI ,而是先掌握 AI 能力的人” ChatGPT 美国OpenAI研发的聊天机器人程序,于2022年11月30日发…

【刷题笔记】--两数之和Ⅳ,从二叉树中找出两数之和

法一:深度搜索中序遍历双指针 思路:通过中序遍历二叉树得到一个递增的数列,再在这个递增的二叉树中找到这两数。 主要学到双指针这个方法。 对于一般数列,我们要找到两数满足其之和等于目标数,我们一般会进行暴力&a…

C++请求SpringBoot的接口问题记录

问题描述最近忙一个小东西,遇到一个很有意思的问题,记录一下。 需求非常简单,就是java侧提供一个接口给C侧调用。 接口按照业务规范提供出来了,在postman中请求一下,出入参都正常。 关于这个接口请求方式为postJson方式…

C++:提高篇: 栈-寄存器和函数状态:栈指针帧指针详解

栈指针和帧指针前言1、EBP和ESP详解2、push ,leave ,call汇编指令分析3、下面用一个图总结前言 🚗🚗🚗:在刚接触 ESP和EBP概念时,我一直认为:ESP指向栈顶指针,EBP指向栈…

为什么说百度下个月推出文心一言会被ChatGPT完全碾压

作者,姚远: Oracle ACE(Oracle和MySQL数据库方向)华为云MVP 《MySQL 8.0运维与优化》的作者中国唯一一位Oracle高可用大师拥有包括 Oracle 10g和12c OCM在内的20数据库相关认证。曾任IBM公司数据库部门经理现在一家第三方公司任首…

操作系统——2.操作系统的特征

这篇文章,我们来讲一讲操作系统的特征 目录 1.概述 2.并发 2.1并发概念 2.1.1操作系统的并发性 3.共享 3.1共享的概念 3.2共享的方式 4.并发和共享的关系 5.虚拟 5.1虚拟的概念 5.2虚拟小结 6.异步 6.1异步概念 7.小结 1.概述 上一篇文章,我们…

实时数据仓库

1 为什么选择kafka? ① 实时写入,实时读取 ② 消息队列适合,其他数据库受不了 2 ods层 1)存储原始数据 埋点的行为数据 (topic :ods_base_log) 业务数据 (topic :ods_base_db) 2)业务数据的有序性&#x…

论文阅读 - Early Detection of Fake News by Utilizing the Credibility of News

论文链接:https://arxiv.org/pdf/2012.04233.pdf 目录 摘要 1 简介 2 相关工作 2.1 基于特征的方法 2.2 深度学习方法 3 问题表述 4 拟议的框架 4.2 用户可信度预测 4.3 虚假新闻分类 4.3.1 新闻内容表示 4.3.2 融合注意力单元 5 实验 5.1 数…

工厂模式--设计模式

分类: 1、简单工厂:可根据自变量的不同返回不同类的实例 应用:将类名和类的全路径放入到配置文件,通过文件流将内容读取放入到map集合中保存,通过反射读取类全路径读取到该类,然后调用类方法。 详细设计&…

山东大学2022算法期末

接力:山东大学2021算法期末 2022 SDU算法导论期末考试 2020 计科 计算题 三道 35’ (1) 画BFS树 (2) 做DFS说明各种边的分类使用floyd或者矩阵乘法求全源最短路,求最短路矩阵以及前驱矩阵(3个点,比较友好,应该没有…

idea推送镜像到desktop报错:Cannot run program “docker-credential-desktop“ 系统找不到指定的文件。

windows Docker 搭建仓库 打开docker desktop 。 打开windows cmd窗口或powershell窗口。 输入"docker run -d -p 5000:5000 --name test registry:2 "运行一个名字叫test的registry容器。 idea配置springboot项目的docker插件 在pom.xml中的plugins中加入下面代码…

Kaldi语音识别技术(五) ----- 特征提取

Kaldi语音识别技术(五) ----- 特征提取 文章目录Kaldi语音识别技术(五) ----- 特征提取一、识别流程二、MFCC特征提取概述三、文件格式文件格式说明提取部分数据修复提取数据提取剩余部分数据四、特征提取特征提取—C特征提取—并行提取特征提取—特征查看五、CMVNCMVN—脚本CM…

SpringMVC执行流程(面试题)

SpringMVC是Spring框架中的组成成员之一,是一个针对于Web开发的一个类似于Servlet技术的一个web应用框架,它包含了MVC架构的特点,让Web变得更加简单。在SpringMVC框架中,一个比较核心的组件就是他的前端控制器,这个前端…

sql复习(子查询、创建和管理表)

一、子查询 子查询 (内查询) 在主查询之前一次执行完成 子查询的结果被主查询(外查询)使用 1.单行子查询 只返回一行,使用单行比较操作符。 --谁的工资比Able高? select last_name,salary from employees where salary > (select salaryfrom empl…

idea插件生成dao类service类controller类以及mapper.xml

idea插件生成dao类service类controller类以及mapper.xml 安装插件Easycode和MybatisX,不用自己写代码 1.Files——》Settings——》Plugins,分别搜索Easycode和MybatisX,点击下载。 2.新建一个springboot模板,选择的依赖如下 3.…