- 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顯示在上
- no
<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>
Set up.png
-
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)
-
Tweaks
gui.addColor(parameters, 'materialColor').onChange(() => {
material.color.set(parameters.materialColor)
})
MeshToonMaterial.png
-
觀察上圖钧大,我們發(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, // 漸變紋理
})
...
...
texture.png
-
Position
- in the three.js, the field of view is vertical, 也就是說(shuō)我們的相機(jī)視野是在縱向延伸的
- 當(dāng)object位置固定時(shí)劣挫,即使調(diào)整視口尺寸册养,物體的相對(duì)位置依然不變
- create an
objectDistance
variable with a random value
/** * objects */ ... ... // distance const objectDistance = 4 // 4個(gè)單位 ...
- use the
objectDistance
to position the meshes on they
axis, 注意我們的object的位置是往下排列的,因此要去負(fù)值
mesh1.position.y = - objectDistance * 0 mesh2.position.y = - objectDistance * 1 mesh3.position.y = - objectDistance * 2
- 這時(shí)我們看到第一部分只有
mesh1
了压固,但當(dāng)我們滾動(dòng)頁(yè)面時(shí)球拦,只有html
部分在滾動(dòng),objects
部分并沒(méi)有一起滾動(dòng),稍后會(huì)解決這個(gè)問(wèn)題
objectDistance.png
-
Permanent Rotation
/**
* 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, usescrollY
to make thecamera
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()
scroll.png
-
Position Objects 當(dāng)前所有的
mesh
都是水平居中的日戈,修改一下mesh
的位置與每部分的文字合理分布
/**
* objects
*/
...
...
mesh1.position.x = 2
mesh2.position.x = -2
mesh3.position.x = 2
...
...
Position Objects1.png
Position Objects2.png
-
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 thecamera
in aGroup
and apply theparallax
on theGroup
/** * 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()
- we need to retrieve the
-
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'
})
}
})
-
Done
done.png