[譯]使用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.));

// ...
複製代碼

標籤用法

在這裏能夠找到完整的源碼

最後

很高興你能讀到這。這篇教程並不完美,我可能忽略了一些細節,可是我但願你仍然喜歡本教程。基於此,你能夠盡情的使用更多變量,嘗試其餘噪聲函數,並嘗試使用鼠標方向或滾動發揮你的想象力來實現其餘效果!

參考以及感謝

相關文章
相關標籤/搜索