- how to use three.js as a background of a classic HTML page
- make the camera to translate follow the scroll
- discover some tricks to make it more immersive
- add a parallax animation based on the cursor position
- trigger some animations when arriving at the corresponding sections
-
style.css
的調(diào)整
html {
background: #1e1a20;
}
/* canvas需要始終位于視圖后面 */
canvas {
position: fixed;
left: 0;
top: 0;
}
-
Set up
- no
OrbitControl
- some HTML content
-
document.body.prepend(renderer.domElement)
將canvas插入在根節(jié)點(diǎn)之前,html顯示在上
<script setup>
import * as THREE from 'three'
import * as dat from 'dat.gui'
import gsap from 'gsap';
/**
* gui
*/
const gui = new dat.GUI()
/**
* scene
*/
const scene = new THREE.Scene()
/**
* camera
*/
const camera = new THREE.PerspectiveCamera(
35,
window.innerWidth / window.innerHeight,
0.1,
100
)
camera.position.z = 6
/**
* objects
*/
const geometry = new THREE.BoxGeometry(1, 1, 1)
const material = new THREE.MeshBasicMaterial({
color: 'red'
})
const mesh = new THREE.Mesh(geometry, material)
scene.add(mesh)
/**
* renderer
*/
const renderer = new THREE.WebGLRenderer()
renderer.setSize(window.innerWidth, window.innerHeight)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
document.body.prepend(renderer.domElement) // 層級(jí)關(guān)系玄渗,將canvas插入在根節(jié)點(diǎn)之前座菠,html顯示在上
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight
camera.updateProjectionMatrix()
renderer.setSize(window.innerWidth, window.innerHeight)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
})
/**
* tick
*/
const tick = () => {
renderer.render(scene, camera)
requestAnimationFrame(tick)
}
tick()
</script>
<template>
<div>
<section class="section">
<h1>my portfolio</h1>
</section>
<section class="section">
<h1>my projects</h1>
</section>
<section class="section">
<h1>contact me</h1>
</section>
</div>
</template>
<style scoped>
.section {
display: flex;
align-items: center;
position: relative;
height: 100vh;
font-family: 'Cabin', sans-serif;
color: #ffeded;
text-transform: uppercase;
font-size: 7vmin;
padding-left: 10%;
padding-right: 10%;
}
section:nth-child(even) {
justify-content: flex-end;
}
</style>
-
you might notice that, if you scroll too far, you get a kind of elastic animation when the page goes beyond the limit, 部分用戶會(huì)出現(xiàn)上拉頁(yè)面時(shí),頁(yè)面底部會(huì)彈一下藤树,導(dǎo)致出現(xiàn)與當(dāng)前頁(yè)面不一樣的色差浴滴,我們用以下方式解決,讓webgl
呈現(xiàn)透明
/**
* renderer
*/
const renderer = new THREE.WebGLRenderer({
alpha: true
})
...
...
-
Create objects for each section
/**
* gui
*/
const gui = new dat.GUI()
const parameters = {
materialColor: '#ffeded'
}
/**
* objects
*/
// material
const material = new THREE.MeshToonMaterial({ // 卡通著色材質(zhì)岁钓,需要結(jié)合light使用
color: parameters.materialColor,
})
// mesh
const mesh1 = new THREE.Mesh(
new THREE.TorusGeometry(1, 0.4, 16, 60), // 圓環(huán)環(huán)半徑升略,管道半徑,分段數(shù)
material
)
const mesh2 = new THREE.Mesh(
new THREE.ConeGeometry(1, 2, 32), // 圓錐底部半徑屡限,高度品嚣,側(cè)周圍分段
material
)
const mesh3 = new THREE.Mesh(
new THREE.TorusKnotGeometry(0.8, 0.35, 100, 16), // 圓環(huán)扭結(jié)環(huán)半徑,管道半徑
material
)
scene.add(mesh1, mesh2, mesh3)
/**
* light
*/
const directionalLight = new THREE.DirectionalLight('#ffffff', 1)
directionalLight.position.set(1, 1, 0)
scene.add(directionalLight)
gui.addColor(parameters, 'materialColor').onChange(() => {
material.color.set(parameters.materialColor)
})
-
觀察上圖钧大,我們發(fā)現(xiàn)當(dāng)前使用了MeshToonMaterial
的mesh
腰根,默認(rèn)情況下只能展示顏色和陰影,但其實(shí)這里可以展示更多樣的顏色拓型,下面我們使用3種顏色的漸變紋理
/**
* objects
*/
// texture
const textureLoader = new THREE.TextureLoader()
const gradientTexture = textureLoader.load('../public/imgs/scroll-animation/3.jpg')
gradientTexture.magFilter = THREE.NearestFilter // 紋理覆蓋大于1像素時(shí)额嘿,使用放大濾鏡
// material
const material = new THREE.MeshToonMaterial({ // 卡通著色材質(zhì),需要結(jié)合light使用
color: parameters.materialColor,
gradientMap: gradientTexture, // 漸變紋理
})
...
...
/**
* objects
*/
...
...
const sectionMeshes = [mesh1, mesh2, mesh3]
/**
* tick
*/
const clock = new THREE.Clock()
const tick = () => {
const elapsedTime = clock.getElapsedTime()
// animate meshes
for(const mesh of sectionMeshes) {
mesh.rotation.x = elapsedTime * 0.1
mesh.rotation.y = elapsedTime * 0.12
}
renderer.render(scene, camera)
requestAnimationFrame(tick)
}
tick()
-
添加旋轉(zhuǎn)后坎炼,接下來(lái)解決滾動(dòng)的問(wèn)題愧膀,當(dāng)我們滾動(dòng)頁(yè)面時(shí),如何能看到對(duì)應(yīng)section
的mesh
呢谣光,那就需要讓camera
跟著一起滾動(dòng)
- get and listen into scrolling direction and scrolling distance
/**
* scroll
*/
let scrollY = window.scrollY
window.addEventListener('scroll', () => {
scrollY = window.scrollY
})
- in the
tick
function, use scrollY
to make the camera
move, 這一步需要明確2個(gè)重要的問(wèn)題檩淋,那就是camera
的移動(dòng)方向和移動(dòng)距離
/**
* tick
*/
const clock = new THREE.Clock()
const tick = () => {
const elapsedTime = clock.getElapsedTime()
// animate camera
// 1. when scrolling down,scrollY is positive and getting bigger萄金,
// but the camera should be go down on the y negative axis
// 2. scrollY 是滾動(dòng)的像素值蟀悦,但camera移動(dòng)的是單位
// 3. 當(dāng)camera滾動(dòng)了window.innerHeight的距離后,應(yīng)該展示下一個(gè)section氧敢,
// 也就是說(shuō)camera應(yīng)該到達(dá)下一個(gè)mesh了
camera.position.y = - scrollY / window.innerHeight * objectDistance
// animate meshes
...
...
}
tick()
-
Position Objects 當(dāng)前所有的mesh
都是水平居中的日戈,修改一下mesh
的位置與每部分的文字合理分布
/**
* objects
*/
...
...
mesh1.position.x = 2
mesh2.position.x = -2
mesh3.position.x = 2
...
...
-
Parallax 視差,具體要做的就是讓camera
跟隨cursor
移動(dòng)孙乖,然后就可以從不同角度看到整個(gè)場(chǎng)景
- we need to retrieve the
cursor
position, 這一步需要注意的是浙炼,瀏覽器提供的坐標(biāo)系是左上角為(0, 0),那也就意味著cursor
的像素位置信息始終是正數(shù)唯袄,而camera
最終需要的是可以上下左右移動(dòng)弯屈,也就是正負(fù)值都要有,因此在這一步我們需要做一個(gè)坐標(biāo)系轉(zhuǎn)換越妈,將左上角為(0, 0)轉(zhuǎn)換為中心點(diǎn)為(0, 0)季俩,特別注意這里只是原點(diǎn)位置變更了钮糖,但是坐標(biāo)軸的方向是不變的梅掠,依然還是x正軸水平向右,y軸正軸垂直向下
/**
* cursor
*/
const cursor = {
x: 0,
y: 0
}
// 取值范圍 [-0.5, 0.5]
window.addEventListener('mousemove', event => {
cursor.x = event.clientX / window.innerWidth - 0.5
cursor.y = event.clientY / window.innerHeight - 0.5
})
- use these value to move the
camera
, 創(chuàng)建視差變量店归,在這一步完成之后移動(dòng)光標(biāo)時(shí)阎抒,會(huì)發(fā)現(xiàn)2個(gè)問(wèn)題,第一是光標(biāo)左右移動(dòng)時(shí)mesh
會(huì)對(duì)應(yīng)分別向左消痛、向右且叁,但上下移動(dòng)時(shí),mesh
的位移和光標(biāo)同步了秩伞;第二逞带,滾動(dòng)頁(yè)面時(shí),僅僅只是頁(yè)面滾動(dòng)纱新,camera
并未跟隨滾動(dòng)
const tick = () => {
...
// animate camera
camera.position.y = - scrollY / window.innerHeight * objectDistance
// parallax
const parallaxX = cursor.x
const parallaxY = cursor.y
camera.position.x = parallaxX
camera.position.y = parallaxY
...
...
}
- y軸正軸是垂直向下的展氓,因此當(dāng)
cursor
向下移動(dòng)時(shí),camera.position.y
的值是正數(shù)脸爱,那么camera
也對(duì)應(yīng)向上移動(dòng)(此時(shí)camera
呈現(xiàn)的mesh
是向下的)
const tick = () => {
...
// animate camera
camera.position.y = - scrollY / window.innerHeight * objectDistance
// parallax
// cursor移動(dòng)的距離*0.5 減小視差變化的幅度
const parallaxX = cursor.x * 0.5
const parallaxY = - cursor.y * 0.5 // 瀏覽器作坐標(biāo)系與camera所處的坐標(biāo)系y軸方向相反
camera.position.x = parallaxX
camera.position.y = parallaxY
...
...
}
- for the scroll, the problem is that we update the
camera.position.y
twice and the second one will replace the first one; to fix that, we're going to put the camera
in a Group
and apply the parallax
on the Group
/**
* camera
*/
// group
const cameraGroup = new THREE.Group()
scene.add(cameraGroup)
const camera = new THREE.PerspectiveCamera(
35,
window.innerWidth / window.innerHeight,
0.1,
100
)
camera.position.z = 6
cameraGroup.add(camera)
/**
* tick
*/
const clock = new THREE.Clock()
let previousTime = 0
const tick = () => {
const elapsedTime = clock.getElapsedTime()
const deltaTime = elapsedTime - previousTime // 當(dāng)前幀和上一幀的時(shí)間差
previousTime = elapsedTime
// animate camera
// 1. when scrolling down遇汞,scrollY is positive and getting bigger,
// but the camera should be go down on the y negative axis
// 2. scrollY 是滾動(dòng)的像素值,但camera移動(dòng)的是單位
// 3. 當(dāng)camera滾動(dòng)了window.innerHeight的距離后空入,應(yīng)該展示下一個(gè)section络它,
// 也就是說(shuō)camera應(yīng)該到達(dá)下一個(gè)mesh了
camera.position.y = - scrollY / window.innerHeight * objectDistance
// parallax
// cursor移動(dòng)的距離*0.5 減小視差變化的幅度
const parallaxX = cursor.x * 0.5
const parallaxY = - cursor.y * 0.5 // 瀏覽器作坐標(biāo)系與camera所處的坐標(biāo)系y軸方向相反
// 使動(dòng)畫更緩慢流暢,而不是一下就完成動(dòng)畫
cameraGroup.position.x += (parallaxX - cameraGroup.position.x) * 5 * deltaTime
cameraGroup.position.y += (parallaxY - cameraGroup.position.y) * 5 * deltaTime
// animate meshes
for(const mesh of sectionMeshes) {
mesh.rotation.x = elapsedTime * 0.1
mesh.rotation.y = elapsedTime * 0.12
}
renderer.render(scene, camera)
requestAnimationFrame(tick)
}
tick()
-
Particles - a good way to make experience more immersive
/**
* particles
*/
const particlesCount = 200
const positions = new Float32Array(particlesCount * 3)
for(let i = 0; i < particlesCount; i++) {
positions[i * 3] = (Math.random() - 0.5) * 10
positions[i * 3 + 1] = objectDistance * 0.5 - Math.random() * objectDistance * sectionMeshes.length
positions[i * 3 + 2] = (Math.random() - 0.5) * 10
}
const particlesGeometry = new THREE.BufferGeometry()
particlesGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))
const particlesMaterial = new THREE.PointsMaterial({
color: parameters.materialColor,
sizeAttenuation: true,
size: 0.03
})
const particles = new THREE.Points(particlesGeometry, particlesMaterial)
scene.add(particles)
- 更改
materialColor
的同時(shí)修改particle
的顏色
/**
* gui
* **/
const gui = new dat.GUI()
const parameters = {
materialColor: '#ffeded'
}
gui.addColor(parameters, 'materialColor').onChange(() => {
material.color.set(parameters.materialColor) // 拖動(dòng)顏色后重置
particlesMaterial.color.set(parameters.materialColor)
})
-
Trigger rotation 當(dāng)頁(yè)面滾動(dòng)至某個(gè)section
的時(shí)候歪赢,對(duì)應(yīng)的mesh
發(fā)生rotation(請(qǐng)自行安裝gsap
化戳, 當(dāng)前版本"gsap": "^3.12.2"
)
/**
* scroll
* **/
let scrollY = window.scrollY
let currentSection = 0
window.addEventListener('scroll', () => {
scrollY = window.scrollY
const newSection = Math.round(scrollY / window.innerHeight) // 滾動(dòng)至第幾個(gè)section
if(newSection !== currentSection) {
currentSection = newSection
// sectionMeshes[currentSection] 得到一個(gè)mesh
gsap.to(sectionMeshes[currentSection].rotation, {
duration: 1.5,
ease: 'power2.inOut', // start slowly and end slowly
x: '+=6',
y: '+=3',
z: '+=1.5'
})
}
})