- 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显示在上
- 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) // 层级关系,将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>
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, 部分用户会出现上拉页面时,页面底部会弹一下,导致出现与当前页面不一样的色差,我们用以下方式解决,让
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)
-
Tweaks
gui.addColor(parameters, 'materialColor').onChange(() => {
material.color.set(parameters.materialColor)
})
MeshToonMaterial.png
-
观察上图,我们发现当前使用了
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, // 渐变纹理
})
...
...
texture.png
-
Position
- in the three.js, the field of view is vertical, 也就是说我们的相机视野是在纵向延伸的
- 当object位置固定时,即使调整视口尺寸,物体的相对位置依然不变
- create an
objectDistance
variable with a random value
/** * objects */ ... ... // distance const objectDistance = 4 // 4个单位 ...
- use the
objectDistance
to position the meshes on they
axis, 注意我们的object的位置是往下排列的,因此要去负值
mesh1.position.y = - objectDistance * 0 mesh2.position.y = - objectDistance * 1 mesh3.position.y = - objectDistance * 2
- 这时我们看到第一部分只有
mesh1
了,但当我们滚动页面时,只有html
部分在滚动,objects
部分并没有一起滚动,稍后会解决这个问题
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()
-
添加旋转后,接下来解决滚动的问题,当我们滚动页面时,如何能看到对应
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, usescrollY
to make thecamera
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()
scroll.png
-
Position Objects 当前所有的
mesh
都是水平居中的,修改一下mesh
的位置与每部分的文字合理分布
/**
* objects
*/
...
...
mesh1.position.x = 2
mesh2.position.x = -2
mesh3.position.x = 2
...
...
Position Objects1.png
Position Objects2.png
-
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 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 // 当前帧和上一帧的时间差 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()
- 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
的同时修改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'
})
}
})
-
Done
done.png