- 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
的调整
html {
background: #1e1a20;
}
/* canvas需要始终位于视图后面 */
canvas {
position: fixed;
left: 0;
top: 0;
}
-
Set up
- no
OrbitControl
- some HTML content
-
document.body.prepend(renderer.domElement)
将canvas插入在根节点之前,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) // 层级关系,将canvas插入在根节点之前,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, 部分用户会出现上拉页面时,页面底部会弹一下,导致出现与当前页面不一样的色差,我们用以下方式解决,让webgl
呈现透明
/**
* 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({ // 卡通着色材质,需要结合light使用
color: parameters.materialColor,
})
// mesh
const mesh1 = new THREE.Mesh(
new THREE.TorusGeometry(1, 0.4, 16, 60), // 圆环环半径,管道半径,分段数
material
)
const mesh2 = new THREE.Mesh(
new THREE.ConeGeometry(1, 2, 32), // 圆锥底部半径,高度,侧周围分段
material
)
const mesh3 = new THREE.Mesh(
new THREE.TorusKnotGeometry(0.8, 0.35, 100, 16), // 圆环扭结环半径,管道半径
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)
})
-
观察上图,我们发现当前使用了MeshToonMaterial
的mesh
,默认情况下只能展示颜色和阴影,但其实这里可以展示更多样的颜色,下面我们使用3种颜色的渐变纹理
/**
* objects
*/
// texture
const textureLoader = new THREE.TextureLoader()
const gradientTexture = textureLoader.load('../public/imgs/scroll-animation/3.jpg')
gradientTexture.magFilter = THREE.NearestFilter // 纹理覆盖大于1像素时,使用放大滤镜
// material
const material = new THREE.MeshToonMaterial({ // 卡通着色材质,需要结合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()
-
添加旋转后,接下来解决滚动的问题,当我们滚动页面时,如何能看到对应section
的mesh
呢,那就需要让camera
跟着一起滚动
- 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个重要的问题,那就是camera
的移动方向和移动距离
/**
* 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 是滚动的像素值,但camera移动的是单位
// 3. 当camera滚动了window.innerHeight的距离后,应该展示下一个section,
// 也就是说camera应该到达下一个mesh了
camera.position.y = - scrollY / window.innerHeight * objectDistance
// animate meshes
...
...
}
tick()
-
Position Objects 当前所有的mesh
都是水平居中的,修改一下mesh
的位置与每部分的文字合理分布
/**
* objects
*/
...
...
mesh1.position.x = 2
mesh2.position.x = -2
mesh3.position.x = 2
...
...
-
Parallax 视差,具体要做的就是让camera
跟随cursor
移动,然后就可以从不同角度看到整个场景
- we need to retrieve the
cursor
position, 这一步需要注意的是,浏览器提供的坐标系是左上角为(0, 0),那也就意味着cursor
的像素位置信息始终是正数,而camera
最终需要的是可以上下左右移动,也就是正负值都要有,因此在这一步我们需要做一个坐标系转换,将左上角为(0, 0)转换为中心点为(0, 0),特别注意这里只是原点位置变更了,但是坐标轴的方向是不变的,依然还是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
, 创建视差变量,在这一步完成之后移动光标时,会发现2个问题,第一是光标左右移动时mesh
会对应分别向左、向右,但上下移动时,mesh
的位移和光标同步了;第二,滚动页面时,仅仅只是页面滚动,camera
并未跟随滚动
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轴正轴是垂直向下的,因此当
cursor
向下移动时,camera.position.y
的值是正数,那么camera
也对应向上移动(此时camera
呈现的mesh
是向下的)
const tick = () => {
...
// animate camera
camera.position.y = - scrollY / window.innerHeight * objectDistance
// parallax
// cursor移动的距离*0.5 减小视差变化的幅度
const parallaxX = cursor.x * 0.5
const parallaxY = - cursor.y * 0.5 // 浏览器作坐标系与camera所处的坐标系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 // 当前帧和上一帧的时间差
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 是滚动的像素值,但camera移动的是单位
// 3. 当camera滚动了window.innerHeight的距离后,应该展示下一个section,
// 也就是说camera应该到达下一个mesh了
camera.position.y = - scrollY / window.innerHeight * objectDistance
// parallax
// cursor移动的距离*0.5 减小视差变化的幅度
const parallaxX = cursor.x * 0.5
const parallaxY = - cursor.y * 0.5 // 浏览器作坐标系与camera所处的坐标系y轴方向相反
// 使动画更缓慢流畅,而不是一下就完成动画
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
的同时修改particle
的颜色
/**
* gui
* **/
const gui = new dat.GUI()
const parameters = {
materialColor: '#ffeded'
}
gui.addColor(parameters, 'materialColor').onChange(() => {
material.color.set(parameters.materialColor) // 拖动颜色后重置
particlesMaterial.color.set(parameters.materialColor)
})
-
Trigger rotation 当页面滚动至某个section
的时候,对应的mesh
发生rotation(请自行安装gsap
, 当前版本"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) // 滚动至第几个section
if(newSection !== currentSection) {
currentSection = newSection
// sectionMeshes[currentSection] 得到一个mesh
gsap.to(sectionMeshes[currentSection].rotation, {
duration: 1.5,
ease: 'power2.inOut', // start slowly and end slowly
x: '+=6',
y: '+=3',
z: '+=1.5'
})
}
})