[译]使用Three.js制做有粘稠感的图像悬停效果

这是一篇关于glsl在web动效交互中的应用。文章质量比较高,翻译过来你们一块儿学习借鉴。css

原文连接:tympanus.net/codrops/201…html

学习如何使用噪声在着色器中建立粘稠的悬停效果。git

查看在线演示or下载源码github

做为Flash的替代者webGL在近几年随着像Three.js, PIXI.js, OGL.js这样的库而变得愈来愈火。它们对于建立空白板很是有用,惟一的限制只有你的想象力。咱们看到愈来愈多的WebGL建立的效果微妙地集成到交互界面中,以进行悬停,滚动或显示效果。好比 Hello Monday 或者是 cobosrl.co.web

在本教程中,咱们将使用Three.js建立特殊的粘稠纹理,将其用于在悬停时显示另外一幅图像。你如今就能够点击演示连接,去看看真实的效果!对于演示自己,我建立了一个更实际的示例,该示例显示了带有图像的水平可滚动布局,其中每一个图像都有不一样的效果。你能够单击图像,它将变换为更大的版本,同时显示一些其余内容(Mock出的内容)。咱们将会带你了解这个效果最有趣的部分,这样你就能够知道它是如何工做的,而且能够本身建立更多的效果!npm

我假设你对Javascript, Three.js以及着色器有必定的了解。若是你不了解,那么你能够先看看 Three.js documentation, The Book of Shaders, Three.js Fundamentals 或者 Discover Three.js.canvas

**注意:**本教程涵盖了许多部分。若是愿意,能够跳过HTML / CSS / JavaScript部分,直接转到着色器部分。异步

在 DOM 中建立场景(scene)

在咱们建立有趣的东西以前,须要在HTML中插入图片。在HTML / CSS中设置初始位置和尺寸,比在JavaScript中定位全部内容更容易处理场景大小。此外,样式部分应该只在CSS中定义,而不要在Javascript中。例如,若是咱们的图片在桌面端的比例为16:9,而在移动设备上的比例为4:3,咱们只应该使用CSS来处理。 JavaScript将仅用于请求更新数据。函数

// index.html

<section class="container">
	<article class="tile">
		<figure class="tile__figure">
			<img data-src="path/to/my/image.jpg" data-hover="path/to/my/hover-image.jpg" class="tile__image" alt="My image" width="400" height="300" />
		</figure>
	</article>
</section>

<canvas id="stage"></canvas>
复制代码
// style.css

.container {
	display: flex;
	align-items: center;
	justify-content: center;
	width: 100%;
	height: 100vh;
	z-index: 10;
}

.tile {
	width: 35vw;
	flex: 0 0 auto;
}

.tile__image {
	width: 100%;
	height: 100%;
	object-fit: cover;
	object-position: center;
}

canvas {
	position: fixed;
	left: 0;
	top: 0;
	width: 100%;
	height: 100vh;
	z-index: 9;
}
复制代码

正如你在上面看到的,咱们已经建立了一个位于在屏幕居中的图像。稍后咱们将利用data-src和data-hover属性,经过延迟加载在脚本中加载这两个图像。布局

标签用法

在 JavaScript 中建立场景(scene)

让咱们从不那么容易但也不算难的部分开始吧!首先,咱们将建立场景,灯光和渲染器。

// Scene.js

import * as THREE from 'three'

export default class Scene {
	constructor() {
		this.container = document.getElementById('stage')

		this.scene = new THREE.Scene()
		this.renderer = new THREE.WebGLRenderer({
			canvas: this.container,
			alpha: true,
	  })

		this.renderer.setSize(window.innerWidth, window.innerHeight)
		this.renderer.setPixelRatio(window.devicePixelRatio)

		this.initLights()
	}

	initLights() {
		const ambientlight = new THREE.AmbientLight(0xffffff, 2)
		this.scene.add(ambientlight)
	}
}
复制代码

这是一个很是基本的场景。可是咱们在场景中还须要一个基本的元素:相机。咱们有两种能够供选择的相机:正射或透视。若是咱们想让图片保持形状不变,咱们能够选择第一种。可是对于旋转效果,咱们但愿在移动鼠标时具备必定的透视效果。

在带有透视相机的Three.js(或者其余用于WebGL的库)中,屏幕上的10个单位值并不等于10px。所以,这里的技巧是使用一些数学运算将1单位转换为1px,并更改视角以增长或减小失真效果。

// Scene.js

const perspective = 800

constructor() {
	// ...
	this.initCamera()
}

initCamera() {
	const fov = (180 * (2 * Math.atan(window.innerHeight / 2 / perspective))) / Math.PI

	this.camera = new THREE.PerspectiveCamera(fov, window.innerWidth / window.innerHeight, 1, 1000)
	this.camera.position.set(0, 0, perspective)
}
复制代码

咱们将透视值设置为800,以便在旋转平面时不会产生太大的变形。咱们增长的视角越大,咱们对扭曲的感知就越少,反之亦然。而后,咱们须要作的最后一件事是在每一帧中渲染场景。

// Scene.js

constructor() {
	// ...
	this.update()
}

update() {
	requestAnimationFrame(this.update.bind(this))
	
	this.renderer.render(this.scene, this.camera)
}
复制代码

若是你的屏幕不是黑色的,则说明方法正确!

用正确的尺寸建立平面

如上所述,咱们必须从DOM中的图像上检索一些其余信息,例如其尺寸和在页面上的位置。

// Scene.js

import Figure from './Figure'

constructor() {
	// ...
	this.figure = new Figure(this.scene)
}
复制代码
// Figure.js

export default class Figure {
	constructor(scene) {
		this.$image = document.querySelector('.tile__image')
		this.scene = scene

		this.loader = new THREE.TextureLoader()

		this.image = this.loader.load(this.$image.dataset.src)
		this.hoverImage = this.loader.load(this.$image.dataset.hover)
		this.sizes = new THREE.Vector2(0, 0)
		this.offset = new THREE.Vector2(0, 0)

		this.getSizes()

		this.createMesh()
	}
}
复制代码

首先,咱们建立另外一个类,将场景做为属性传递给该类。咱们设置了两个新的矢量,尺寸和偏移,用于存储DOM图像的尺寸和位置。

此外,咱们将使用TextureLoader来“加载”图像并将其转换为纹理。咱们须要这样作,由于咱们想在着色器中使用这些图片。

咱们须要在类中建立一个方法来处理图像的加载并等待回调。咱们可使用异步功能来实现这一目标,但对于本教程而言,咱们将其保持简单。请记住,您可能须要出于自身目的对它进行一些重构。

// Figure.js

// ...
	getSizes() {
		const { width, height, top, left } = this.$image.getBoundingClientRect()

		this.sizes.set(width, height)
		this.offset.set(left - window.innerWidth / 2 + width / 2, -(top - window.innerHeight / 2 + height / 2))
	}
// ...
复制代码

咱们在getBoundingClientRect对象中获取图像信息。而后,将它们传递给两个变量。这里的偏移量用于计算屏幕中心与页面上的对象之间的距离。(译者:能够补充解释)

// Figure.js

// ...
	createMesh() {
		this.geometry = new THREE.PlaneBufferGeometry(1, 1, 1, 1)
		this.material = new THREE.MeshBasicMaterial({
			map: this.image
		})

		this.mesh = new THREE.Mesh(this.geometry, this.material)

		this.mesh.position.set(this.offset.x, this.offset.y, 0)
		this.mesh.scale.set(this.sizes.x, this.sizes.y, 1)

		this.scene.add(this.mesh)
	}
// ...
复制代码

以后,咱们将在平面上设置值。如您所见,咱们在1px上建立了一个平面,该平面上有1行1列。因为咱们不想使平面变形,因此咱们不须要不少面或顶点。所以,让咱们保持简单。

既然咱们能够直接设置网格的大小,为何要用缩放的方式来实现?

其实这么作主要是为了更加便于调整网格的大小。若是咱们以后要更改网格的大小,除了用scale没有什么更好的方法。虽然更改网格的比例更容易直接实现,可是用来调整尺寸并不太方便。(译者:做者这里实际上是一个很巧妙的作法:直接将原来的大小设置为1x1,而后采用缩放API来让网格变换为实际大小,这样缩放的比例也就等于实际的长宽值)

目前,咱们设置了MeshBasicMaterial,看来一切正常。

获取鼠标坐标

如今,咱们已经使用网格构建了场景,咱们想要获取鼠标坐标,而且为了使事情变得简单,咱们将其归一化。为何要归一化?看看着色器的坐标系统你就明白了。

如上图所示,咱们已经将两个着色器的值标准化了。为简单起见,咱们将转化鼠标坐标以匹配顶点着色器坐标。

若是你在这里以为理解有困难, 我建议你去看一看 Book of ShadersThree.js Fundamentals的各个章节。 二者都有很好的建议,并提供了许多示例来帮助你理解。

// Figure.js

// ...

this.mouse = new THREE.Vector2(0, 0)
window.addEventListener('mousemove', (ev) => { this.onMouseMove(ev) })

// ...

onMouseMove(event) {
	TweenMax.to(this.mouse, 0.5, {
		x: (event.clientX / window.innerWidth) * 2 - 1,
		y: -(event.clientY / window.innerHeight) * 2 + 1,
	})

	TweenMax.to(this.mesh.rotation, 0.5, {
		x: -this.mouse.y * 0.3,
		y: this.mouse.x * (Math.PI / 6)
	})
}
复制代码

对于补间部分,我将使用GreenSock的TweenMax。这是有史以来最好的库。并且很是适合咱们想要达到的目的。咱们不须要处理两个状态之间的转换,TweenMax会为咱们完成。每次移动鼠标时,TweenMax都会平滑更新位置坐标和旋转角度。

标签用法

在进行后面的步骤以前还有一件事:咱们将材质从MeshBasicMaterial更新为ShaderMaterial,并传递一些值(均匀值)和着色器代码。

// Figure.js

// ...

this.uniforms = {
	u_image: { type: 't', value: this.image },
	u_imagehover: { type: 't', value: this.hover },
	u_mouse: { value: this.mouse },
	u_time: { value: 0 },
	u_res: { value: new THREE.Vector2(window.innerWidth, window.innerHeight) }
}

this.material = new THREE.ShaderMaterial({
	uniforms: this.uniforms,
	vertexShader: vertexShader,
	fragmentShader: fragmentShader
})

update() {
	this.uniforms.u_time.value += 0.01
}
复制代码

咱们传递了两个纹理,以及鼠标的位置,屏幕的大小和一个名为u_time的变量,该变量将在每一帧进行递增。

可是请记住,这不是最好的方法。咱们只须要当咱们将鼠标悬停在图形上时增长,而没必要在每一帧上增长。出于性能,最好仅在须要时更新着色器。

技巧背后的原理及如何使用噪声

我不会解释什么是噪声以及噪声的来源。若是你有兴趣,请探究《 The Shader of Shaders》中的相关章节,它进行了很好的解释。

长话短说,噪声是一个函数,它根据传递的值为咱们提供介于-1和1之间的值。它将输出随机但却又相关的值。

多亏了噪声,咱们才能生成许多不一样的形状,例如地图,随机图案等。

让咱们从2D噪声开始。仅经过传递纹理的坐标,咱们就能够获得相似云的纹理。

但事实上有好几种噪声函数。咱们使用3D噪声,再给一个参数,例如…时间?噪声图形将随着时间的流逝而变化。经过更改频率和幅度,咱们能够进行一些变化并增长对比度。

其次,咱们将建立一个圆。在片断着色器中构建像圆形这样的简单形状很是容易。咱们只是采用了《 The Shader of Shaders:Shapes》中的功能来建立一个模糊的圆,增长对比度和视觉效果!

最后,咱们将这两个加在一块儿,使用一些变量,让它对纹理进行“切片”:

这个混合以后的结果是否是很让人兴奋,让咱们深刻到代码层面继续探究!

着色器

咱们这里其实不须要顶点着色器,这是咱们的代码:

// vertexShader.glsl
varying vec2 v_uv;

void main() {
	v_uv = uv;

	gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}

复制代码

Three.js的ShaderMaterial提供了一些有用的默认变量,便于初学者使用:

  • 位置(vec3):网格每一个顶点的坐标
  • uv(vec2):纹理的坐标
  • 法线(vec3):网格中每一个顶点的法线。

在这里,咱们只是将UV坐标从顶点着色器传递到片断着色器。

建立圆形

让咱们使用 The Book of Shaders中的函数来构建圆并添加一个变量来控制边缘的模糊性。

此外,咱们将用鼠标位置来同步圆心坐标。这样,只要咱们将鼠标移到图像上,圆就会跟随鼠标移动。

// fragmentShader.glsl
uniform vec2 u_mouse;
uniform vec2 u_res;

float circle(in vec2 _st, in float _radius, in float blurriness){
	vec2 dist = _st;
	return 1.-smoothstep(_radius-(_radius*blurriness), _radius+(_radius*blurriness), dot(dist,dist)*4.0);
}

void main() {
	vec2 st = gl_FragCoord.xy / u_res.xy - vec2(1.);
	// tip: use the following formula to keep the good ratio of your coordinates
	st.y *= u_res.y / u_res.x;

	vec2 mouse = u_mouse;
	// tip2: do the same for your mouse
	mouse.y *= u_res.y / u_res.x;
	mouse *= -1.;
	
	vec2 circlePos = st + mouse;
	float c = circle(circlePos, .03, 2.);

	gl_FragColor = vec4(vec3(c), 1.);
}

复制代码

标签用法

建立一些噪噪噪噪声声声

正如咱们在上面看到的,噪声函数具备多个参数,并为咱们生成了逼真的云图案。那么咱们是如何获得的呢?

对于这一部分,我将使用glslifyglsl-noise,以及两个npm包来包含其余功能。它使咱们的着色器更具可读性,而且隐藏了不少咱们根本不会使用的显示函数。

// fragmentShader.glsl
#pragma glslify: snoise2 = require('glsl-noise/simplex/2d')

//...

varying vec2 v_uv;

uniform float u_time;

void main() {
	// ...

	float n = snoise2(vec2(v_uv.x, v_uv.y));

	gl_FragColor = vec4(vec3(n), 1.);
}

复制代码

经过更改噪声的幅度和频率(好比于sin / cos函数),咱们能够更改渲染。

// fragmentShader.glsl

float offx = v_uv.x + sin(v_uv.y + u_time * .1);
float offy = v_uv.y - u_time * 0.1 - cos(u_time * .001) * .01;

float n = snoise2(vec2(offx, offy) * 5.) * 1.;

复制代码

但这并时间的函数!它失真了,咱们想要出色的效果。所以,咱们将改成使用noise3d并传递第三个参数:时间。

float n = snoise3(vec3(offx, offy, u_time * .1) * 4.) * .5;

复制代码

合并纹理

只要将它们叠加在一块儿,咱们就能够看到随时间变化的有趣的形状。

为了解释其背后的原理,让咱们假设噪声就像是在-1和1之间浮动的值。可是咱们的屏幕没法显示负值或大于1(纯白色)的像素,所以咱们只能看到0到1之间的值。

咱们的圆形则像这样:

相加以后的近似结果:

咱们很是白的像素是可见光谱以外的像素。

若是咱们减少噪声并减去少许噪声,它将逐渐沿波浪向下移动,直到其消失在可见颜色的范围以内。

float n = snoise(vec3(offx, offy, u_time * .1) * 4.) - 1.;

复制代码

咱们的圆形仍然存在,只是可见度比较低。若是咱们增长乘以它的值,它将造成更大的对比。

float c = circle(circlePos, 0.3, 0.3) * 2.5;

复制代码

咱们就实现咱们最想要的效果了!可是正如你看到的,仍然缺乏一些细节。并且咱们的边缘一点也不锐利。

为了解决这个问题,咱们将使用 built-in smoothstep function

float finalMask = smoothstep(0.4, 0.5, n + c);

gl_FragColor = vec4(vec3(finalMask), 1.);
复制代码

借助此功能,咱们将在0.4到0.5之间切出一部分图案。这些值之间的间隔越短,边缘越锐利。

标签用法

最后,咱们能够将混合两个纹理用做遮罩。

uniform sampler2D u_image;
uniform sampler2D u_imagehover;

// ...

vec4 image = texture2D(u_image, uv);
vec4 hover = texture2D(u_imagehover, uv);

vec4 finalImage = mix(image, hover, finalMask);

gl_FragColor = finalImage;
复制代码

咱们能够更改一些变量以产生更强的粘稠效果:

// ...

float c = circle(circlePos, 0.3, 2.) * 2.5;

float n = snoise3(vec3(offx, offy, u_time * .1) * 8.) - 1.;

float finalMask = smoothstep(0.4, 0.5, n + pow(c, 2.));

// ...
复制代码

标签用法

在这里能够找到完整的源码

最后

很高兴你能读到这。这篇教程并不完美,我可能忽略了一些细节,可是我但愿你仍然喜欢本教程。基于此,你能够尽情的使用更多变量,尝试其余噪声函数,并尝试使用鼠标方向或滚动发挥你的想象力来实现其余效果!

参考以及感谢

相关文章
相关标签/搜索