threejs在页面内的应用
透明背景
默认情况下,使用threejs
的renderer
渲染出来的背景是黑色的,可以通过给renderer
设置alpha: true
来让其背景透明
const renderer = new THREE.WebGLRenderer({
canvas: canvas,
alpha: true
})
通常情况下我们应该给页面html、body
元素设置背景色或background-img
,而不是在threejs
里给scene
设置背景色或背景图片,否则可能会某些用户的电脑上出现滚动bug
页面滚动摄像机跟随
核心代码:
let scrollY = window.scrollY;
window.addEventListener("scroll", () => { // 1. 监听滚动事件
scrollY = window.scrollY;
});
const tick = () => {
// 按比例移动摄像机位置
camera.position.y = (-scrollY / window.innerHeight) * objectDistance;
// Render
renderer.render(scene, camera);
// Call tick again on the next frame
window.requestAnimationFrame(tick);
};
- 监听滚动事件,记录下滚动的像素
- 计算滚动的比例,移动摄像机位置
objectDistance
代表几何体与几何体之间的距离,初始生成几何体时,它们均坐落在原点位置,上例中,修改几何体的位置为:
第一个几何体:(2, 0, 0)
第二个几何体:(-2, -4, 0)
第三个几何体:(2, -8, 0)
所以,随着页面滚动,摄像机移动的数值应为-已滚动的像素 / 视口高度 * 几何体间距
初始时,摄像机在Y轴的位置为0,页面滚动的值是从
0 -> ∞
的,所以滚动行为一开始,就要对摄像机移动的距离取负数
物体进入视野判断
物体滚入视野后添加一段旋转动画
:
在当前案例中,几何体之间的间距是固定的,在页面上的体现约等于视口的高度,所以要判断屏幕滚动到了第几个几何体,可以通过对滚动距离 / 视口高度
进行四舍五入来判断,如:
// ...
const sectionMeshes = [mesh1, mesh2, mesh3];
scene.add(mesh1, mesh2, mesh3);
let scrollY = window.scrollY;
let before = 0
window.addEventListener("scroll", () => {
scrollY = window.scrollY;
// 四舍五入判断滚动到了第几个几何体
const current=Math.round(scrollY /sizes.height)
if (current !== before) {
before = current
// 使用gsap库来滚动对应的几何体
gsap.to(sectionMeshes[current].rotation, {
duration: 1.5,
ease: 'power2.inOut',
x: "+=6",
y: "+=3",
})
}
});
鼠标移动摄像机跟随
鼠标移动时,场景跟随移动
鼠标移动的同步也是通过修改摄像机的位置来实现的,但是上例中已经在每一帧里修改了camera.position
,如果鼠标移动也修改camera.position
就无法兼容同时滚动且鼠标移动的场景
要解决这个问题,可以将摄像机放进一个Group
里,在鼠标移动时,修改这个Group
的位置,即可避免页面滚动时修改摄像机位置的冲突,如:
const camera = new THREE.PerspectiveCamera(
35,
sizes.width / sizes.height,
0.1,
100
);
camera.position.z = 6;
const cameraGroup = new THREE.Group();
cameraGroup.add(camera);
scene.add(cameraGroup);
然后到核心实现,要实现鼠标移动的同步,本质就是监听mousemove
事件,将屏幕的鼠标位移转为threejs
坐标系内的移动幅度,如:
const cursor = {};
cursor.x = 0;
cursor.y = 0;
window.addEventListener("mousemove", (event) => {
cursor.x = event.clientX / sizes.width - 0.5;
cursor.y = event.clientY / sizes.height - 0.5;
});
在页面中,鼠标从左上角开始,水平和垂直方向的移动都是从0
到正∞
,但在threejs
坐标系中,从左上角开始水平和垂直方向应该是负∞
到正∞
,所以应该用鼠标在屏幕上的位移 / 视口宽高
获取到鼠标移动的幅度(0到1),减去0.5
即为鼠标在threejs
坐标系内实际移动的幅度
所以,每一帧中要处理的代码如下
const tick = () => {
const parallaxX = cursor.x;
const parallaxY = -cursor.y;
cameraGroup.position.x = parallaxX
cameraGroup.position.y = parallaxY
// Render
renderer.render(scene, camera);
window.requestAnimationFrame(tick);
};
tick();
注意在Y轴方向的取负数操作,因为我们移动的是摄像机,所以相对于原点而言,水平方向鼠标往左 = 摄像机往左 = 物体往右
移动
假如Y轴不取反,相对于原点而言,就等于垂直方向鼠标往上 = 摄像机往下 = 物体往上
移动
所以为了便于用户理解,要么对X轴取反,要么对Y轴取反,但是对Y轴取反才更符合人对于视角的认知
平滑移动
按照上面的代码实现后会发现,视角移动起来比较“僵硬”,如图:
在现实生活中,人移动摄像机或者将摄像机架在滑轨上移动都不可能实现一步到位
的效果,甚至会因为惯性的存在,让摄像机移动后有一点滞后性的效果
所以,为了优化用户体验,我们可以优化摄像机在每一帧移动的实现,比如:
let previousTime = 0;
const tick = () => {
const elapsedTime = clock.getElapsedTime();
const deltaTime = elapsedTime - previousTime;
previousTime = elapsedTime;
const parallaxX = cursor.x;
const parallaxY = cursor.y;
cameraGroup.position.x += (parallaxX - cameraGroup.position.x) * deltaTime;
cameraGroup.position.y += (parallaxY - cameraGroup.position.y) * deltaTime;
// cameraGroup.position.x = parallaxX
// cameraGroup.position.y = parallaxY
// Render
renderer.render(scene, camera);
// Call tick again on the next frame
window.requestAnimationFrame(tick);
};
tick();
对代码的解释如下:
首先,平滑移动其实就是在移动速度上呈现出先快后慢,越来越慢
的效果
deltaTime
是上一帧到当前帧的时间差,基本上等于一个固定值,通过控制台可以看到其变化:
计算的基准量是parallexX - cameraGroup.position.x
,开始时,parallexX = 0.5
,cameraGroup.position.x = 0
,此时(parallaxX - cameraGroup.position.x) * deltaTime;
计算出来的值最大,摄像机在第一帧要加上的移动的距离最大
随着逐帧的计算进行,因为每次加上的值是一个“差值”,差值会越来愈小,所以每帧移动的幅度会越来越小,从控制台打印也可以看出这种趋势:
先快后慢,越来越慢
的效果实现了,但是快与慢的区别还不够明显,所以这里可以再调整一下变化的幅度,通过乘上一个倍数
来实现,比如5倍:
cameraGroup.position.x += (parallaxX - cameraGroup.position.x) * deltaTime * 5;
cameraGroup.position.y += (parallaxY - cameraGroup.position.y) * deltaTime * 5;
效果是很明显的:
完整的代码实现:
import * as THREE from "three";
import GUI from "lil-gui";
import gsap from "gsap";
/**
* Debug
*/
const gui = new GUI();
const parameters = {
materialColor: "#ffeded",
};
gui.addColor(parameters, "materialColor").onChange(() => {
material.color.set(parameters.materialColor);
// particleMaterial.color.set(parameters.materialColor)
});
/**
* Base
*/
// Canvas
const canvas = document.querySelector("canvas.webgl");
// Scene
const scene = new THREE.Scene();
scene.add(new THREE.AxesHelper(30));
// TEXTURE
const textureLoader = new THREE.TextureLoader();
const gradientTexture = textureLoader.load("textures/gradients/3.jpg");
gradientTexture.magFilter = THREE.NearestFilter;
// Objects
const objectDistance = 4;
const material = new THREE.MeshToonMaterial({
color: parameters.materialColor,
gradientMap: gradientTexture,
});
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
);
mesh1.position.x = 2;
mesh2.position.x = -2;
mesh3.position.x = 2;
mesh1.position.y = -objectDistance * 0;
mesh2.position.y = -objectDistance * 1;
mesh3.position.y = -objectDistance * 2;
const sectionMeshes = [mesh1, mesh2, mesh3];
scene.add(mesh1, mesh2, mesh3);
// particles
const particlesCount = 200;
const positions = new Float32Array(particlesCount * 3);
const colors = new Float32Array(particlesCount * 3);
for (let i = 0; i < particlesCount; i++) {
const i3 = i * 3;
positions[i3] = (Math.random() - 0.5) * 10;
positions[i3 + 1] =
objectDistance * 0.5 -
Math.random() * objectDistance * sectionMeshes.length;
positions[i3 + 2] = (Math.random() - 0.5) * 10;
colors[i3] = Math.random();
colors[i3 + 1] = Math.random();
colors[i3 + 2] = Math.random();
}
const particleGeometry = new THREE.BufferGeometry();
particleGeometry.setAttribute(
"position",
new THREE.BufferAttribute(positions, 3)
);
particleGeometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
const particleMaterial = new THREE.PointsMaterial({
// color: parameters.materialColor,
sizeAttenuation: true,
size: 0.2,
vertexColors: true,
});
const particles = new THREE.Points(particleGeometry, particleMaterial);
scene.add(particles);
// lights
const directionalLight = new THREE.DirectionalLight("#ffffff", 1);
directionalLight.position.set(1, 1, 0);
scene.add(directionalLight);
scene.add(new THREE.DirectionalLightHelper(directionalLight));
/**
* Sizes
*/
const sizes = {
width: window.innerWidth,
height: window.innerHeight,
};
window.addEventListener("resize", () => {
// Update sizes
sizes.width = window.innerWidth;
sizes.height = window.innerHeight;
// Update camera
camera.aspect = sizes.width / sizes.height;
camera.updateProjectionMatrix();
// Update renderer
renderer.setSize(sizes.width, sizes.height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
});
/**
* Camera
*/
// Base camera
const camera = new THREE.PerspectiveCamera(
35,
sizes.width / sizes.height,
0.1,
100
);
camera.position.z = 6;
const cameraGroup = new THREE.Group();
cameraGroup.add(camera);
scene.add(cameraGroup);
/**
* Renderer
*/
const renderer = new THREE.WebGLRenderer({
canvas: canvas,
alpha: true,
});
renderer.setSize(sizes.width, sizes.height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
/**
* Animate
*/
const clock = new THREE.Clock();
// cursor
const cursor = {};
cursor.x = 0;
cursor.y = 0;
window.addEventListener("mousemove", (event) => {
cursor.x = event.clientX / sizes.width - 0.5;
cursor.y = event.clientY / sizes.height - 0.5;
});
// scroll
let scrollY = window.scrollY;
let before = 0
window.addEventListener("scroll", () => {
scrollY = window.scrollY;
const current=Math.round(scrollY /sizes.height)
if (current !== before) {
before = current
gsap.to(sectionMeshes[current].rotation, {
duration: 1.5,
ease: 'power2.inOut',
x: "+=6",
y: "+=3",
})
}
});
let previousTime = 0;
const tick = () => {
const elapsedTime = clock.getElapsedTime();
const deltaTime = elapsedTime - previousTime;
previousTime = elapsedTime;
camera.position.y = (-scrollY / sizes.height) * objectDistance;
const parallaxX = cursor.x;
const parallaxY = cursor.y;
cameraGroup.position.x += (parallaxX - cameraGroup.position.x) * deltaTime * 5;
cameraGroup.position.y += (parallaxY - cameraGroup.position.y) * deltaTime * 5;
// Render
renderer.render(scene, camera);
sectionMeshes.forEach((mesh) => {
mesh.rotation.x += deltaTime * 0.1;
mesh.rotation.y += deltaTime * 0.12;
});
// Call tick again on the next frame
window.requestAnimationFrame(tick);
};
tick();
物理效果
一般的物理效果都可以理解成物体的位置
以某种规律或运动曲线进行运动,从而看起来像是现实世界中的物理效果
所以,页面上的物理效果实际上就是在每一帧更新物体的位置从而实现物理效果,关键就在于如何获取到每一帧物体的位置
目前市面上成熟的方案是模拟一个物理世界
,在物理世界中模拟物体
的碰撞、运动等行为,然后在每一时刻获物体模拟取该行为运动的位置,同步更新到threejs
的场景中以实现
比如在上面这张图中,左边是threejs
中的场景,右边是假想的一个物理世界,我们在物理世界中模拟小球从空中落下,砸到平面后停止的行为。在这段时间中,我们可以通过物理第三方库提供的API获取到每一帧或每一时刻小球的位置,将位置更新到我们实际的threejs
场景中,即可实现所谓的物理效果
下面是常用的一些3D或2D物理效果的库:
3D物理效果:
Ammo.js
:没文档,https://github.com/kripken/ammo.js
Cannon.js(推荐)
:有文档,但是全英,体积小,基于Ammojs
和threejs
实现的物理库,https://schteppe.github.io/cannon.js/
Oimo.js
:没文档,https://lo-th.github.io/Oimo.js/#basic
2D物理效果:
Matter.js(推荐)
: 比较完善且轻量,全英文档 https://brm.io/matter-js/
P2.js
Planck.js
Box2D.js
3D - 小球落到平面
使用Cannon.js
作为示例,实现以下效果:
首先,按照其实现原理,场景中初始时有一个平面当作”地板“,有一个位于(0,0.5,0)
的小球,其半径是0.5
(这样就可以让小球初始时处于平面之上),如图:
然后,按照原理,需要一个物理世界来模拟物体运动,项目中执行npm install cannon
安装cannon.js
,然后进行初始化:
import CANNON from "cannon";
// 创建一个物理效果世界
const world = new CANNON.World();
// 声明y轴方向的重力,取负数是因为Y轴向下为负
world.gravity.set(0, -9.82, 0);
这里创建了一个物理世界,然后给物理世界设置垂直方向也就是Y轴的加速度为9.82
,取负数是因为Y轴向下为负
然后在物理世界中需要创建我们的物理对象,和threejs
的场景一样,也需要一个平面和小球,代码如下:
// Sphere
// 创建一个半径0.5的球形状
const sphereShape = new CANNON.Sphere(0.5);
// 使用球形状创建球体
const sphereBody = new CANNON.Body({
mass: 1, // 球的质量
position: new CANNON.Vec3(0, 3, 0), // 球的初始位置
shape: sphereShape,
});
// 将球体放到世界中
world.addBody(sphereBody);
// physic Floor
// 创建一个平面形状
const floorShape = new CANNON.Plane();
// 创建平面“体”
const floorBody = new CANNON.Body({
mass: 0, // 质量为0 相当于一堵空气墙
shape: floorShape,
});
world.addBody(floorBody);
现在,在屏幕中,就存在一个threejs
的场景(肉眼可见)和一个物理世界了(不可见)
然后,我们需要threejs
渲染的每一帧里,在物理世界中触发运动,添加如下代码:
const clock = new THREE.Clock();
let oldElapsedTime = 0;
const tick = () => {
const elapsedTime = clock.getElapsedTime();
const deltaTime = elapsedTime - oldElapsedTime;
oldElapsedTime = elapsedTime;
// update physics world
world.step(1 / 60, deltaTime, 3);
console.log('sphereBody.position', sphereBody.position.y)
controls.update();
renderer.render(scene, camera);
window.requestAnimationFrame(tick);
};
tick();
world.step
用于推进物理世界的状态,第一个参数表示固定的逻辑时间步长
,即每次step
调用之间模拟的时间间隔,通常设为1 / 60
,表示每秒60次更新,即符合屏幕的刷新率
第二个参数代表实际经过的时间,通常设为上一帧与当前帧的时间差,可以用来更准确地模拟物理状态的变化
第三个参数是可选的, 表示在每一帧内进行的子步骤数量,增加这个数量可以在物理模拟过程中提供更高的精度
通过打印物理世界中小球的位置(sphereBody.position.y
)可以看到具体数值的变化,它的值从3一直无线递减:
这是因为当前物理世界中,并没有东西将他挡住或,所以它会无限地朝着虚空进行下落,虽然我们之前创建了一个地板floor
,但是floor
默认情况是下垂直于X轴的,这个和threejs
是一样的(初始化创建的plane
也是竖直的)
所以,我们需要在物理世界中对屏幕进行旋转,使其能够平行于X轴,添加代码如下:
// 创建一个平面形状
const floorShape = new CANNON.Plane();
// 创建平面“体”
const floorBody = new CANNON.Body({
mass: 0, // 质量为0 相当于一堵空气墙
shape: floorShape,
});
// 旋转平面使其水平(初始时默认是垂直的)
floorBody.quaternion.setFromAxisAngle(new CANNON.Vec3(-1, 0, 0), Math.PI / 2);
在物理世界旋转物体比较复杂,需要使用四元数
来实现,不懂暂时也没事,这时候再打印就会发现数值是从3到0.5左右保持了:
最后,将这个数值和threejs
里的数值关联起来,即可实现小球的掉落效果了:
// update physics world
world.step(1 / 60, deltaTime,3);
// 根据物理世界的值更新threejs里球的位置
sphere.position.copy(sphereBody.position);