浏览器事件触发webgl动画
页面骨架及样式
<style>
html
{
background: #1e1a20;
}
.webgl
{
position: fixed;
top: 0;
left: 0;
outline: none;
}
.section
{
display: flex;
align-items: center;
height: 100vh;
position: relative;
font-family: 'Cabin', sans-serif;
color: #ffeded;
text-transform: uppercase;
font-size: 7vmin;
padding-left: 10%;
padding-right: 10%;
}
section:nth-child(odd)
{
justify-content: flex-end;
}
</style>
<section>第一块</section>
<section>第二块</section>
<section>第三块</section>
渲染器设置
// 为保证体验,将页面的背景颜色设置为与渲染器的clearColor相同的颜色,或者使clearColor透明并仅设置页面上的背景颜色
const renderer = new THREE.WebGLRenderer({
canvas: canvas,
alpha: true
})
// 设置渲染器canvas的透明度
renderer.setClearAlpha(0)
const parameters = {
materialColor: '#ffeded'
}
// 卡通材质,并为其增加更多渐变
const textureLoader = new THREE.TextureLoader()
const gradientTexture = textureLoader.load('textures/gradients/3.jpg')
const material = new THREE.MeshToonMaterial({
color: parameters.materialColor,
gradientMap: gradientTexture
})
// 默认情况下,webGL在使用材质贴图,在贴图像素小于屏幕像素时,会自动对中间像素进行插值,这会出现比较柔和的过度效果
// 但是,这里希望展现的是硬边的卡通效果,所以要改变滤波器NearestFilter,返回与指定纹理坐标最接近的纹理元素的值,而不再进行插值
gradientTexture.magFilter = THREE.NearestFilter
// 创建几何体
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)
const sectionMeshes = [mesh1, mesh2, mesh3]
// 卡通材质默认不显示,需要增加灯光
const directionalLight = new THREE.DirectionalLight('#ffffff', 1)
directionalLight.position.set(1, 1, 0)
scene.add(directionalLight)
// debug材质颜色
gui.addColor(parameters, 'materialColor').onChange(() => material.color.set(parameters.materialColor))
// 调整模型位置和大小
const objectsDistance = 4
mesh1.position.x = 2
mesh2.position.x = - 2
mesh3.position.x = 2
mesh1.position.y = - objectsDistance * 0
mesh2.position.y = - objectsDistance * 1
mesh3.position.y = - objectsDistance * 2
// 创建光点场景
const particlesCount = 200
const positions = new Float32Array(particlesCount * 3)
for (let i = 0; i < particlesCount; i++) {
positions[i * 3 + 0] = (Math.random() - 0.5) * 10
// 保证粒子在三个模型的背景中都均匀分布例子,下式约等于:(-0.5, 2.5)区间值 * 模型间距
positions[i * 3 + 1] = (0.5 - Math.random() * sectionMeshes.length) * objectsDistance
positions[i * 3 + 2] = (Math.random() - 0.5) * 10
}
const particlesGeometry = new THREE.BufferGeometry()
particlesGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))
const particles = new THREE.Points(
particlesGeometry,
new THREE.PointsMaterial({
color: parameters.materialColor,
sizeAttenuation: true,
size: 0.03
})
)
scene.add(particles)
// 窗口尺寸
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))
})
// 小技巧,创建分组是为了方便分离对摄像机position属性的两种动画,因为在requestAnimationFrame中,同时对position进行更新,会导致覆盖
const cameraGroup = new THREE.Group()
const camera = new THREE.PerspectiveCamera(35, sizes.width / sizes.height, 0.1, 100)
camera.position.z = 6
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))
/**
* Scroll
*/
let scrollY = window.scrollY
let currentSection = 0
window.addEventListener('scroll', () => {
scrollY = window.scrollY
const newSection = Math.round(scrollY / sizes.height)
if (newSection != currentSection) {
currentSection = newSection
// 滚动到某个模型,使其做旋转动画
gsap.to(
sectionMeshes[currentSection].rotation,
{
duration: 1.5,
ease: 'power2.inOut',
x: '+=6',
y: '+=3',
z: '+=1.5'
}
)
}
})
/**
* 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
})
const clock = new THREE.Clock()
let previousTime = 0
const tick = () => {
const elapsedTime = clock.getElapsedTime()
const deltaTime = elapsedTime - previousTime
previousTime = elapsedTime
// 移动摄像机
// scrollY 包含已滚动的像素数量。如果我们滚动 1000 个像素(这并不算多),那么场景中的相机将下降 1000 个单位(这很多)
camera.position.y = - scrollY / sizes.height * objectsDistance
// 使用deltaTime来控制相机的移动,保证在不同帧率的屏幕下,运动距离一致
const parallaxX = cursor.x * 0.5
const parallaxY = - cursor.y * 0.5
cameraGroup.position.x += (parallaxX - cameraGroup.position.x) * 5 * deltaTime
cameraGroup.position.y += (parallaxY - cameraGroup.position.y) * 5 * deltaTime
// 让几何体自转
for (const mesh of sectionMeshes) {
mesh.rotation.x += deltaTime * 0.1
mesh.rotation.y += deltaTime * 0.12
}
renderer.render(scene, camera)
window.requestAnimationFrame(tick)
}
tick()
基于物理引擎来实现webgl的物理碰撞
webgl是3d的,但也可以呈现出2d的物理效果,意思是使用2d引擎来模拟碰撞及弹跳,将其映射到3d空间的某个平面,类似例子有 (http://letsplay.ouigo.com/)
3d的物理引擎
- Ammo.js
- Website : http://schteppe.github.io/ammo.js-demos/
- Git repository: https://github.com/kripken/ammo.js/
- Bullet 的直接 JavaScript 移植(a physics engine written in C++)
- A little heavy
- Still updated by a community
- Cannon.js
- Website: https://schteppe.github.io/cannon.js/
- Git repository: https://github.com/schteppe/cannon.js
- Documentation: http://schteppe.github.io/cannon.js/docs/
- Lighter than Ammo.js
- More comfortable to implement than Ammo.js
- Mostly maintained by one developer
- Hasn’t been updated for many years
- There is a maintained fork
- Oimo.js
- Website: https://lo-th.github.io/Oimo.js/
- Git repository: https://github.com/lo-th/Oimo.js
- Documentation: http://lo-th.github.io/Oimo.js/docs.html
- Lighter than Ammo.js
- Easier to implement than Ammo.js
- Mostly maintained by one developer
- Hasn’t been updated for 2 years
2D Physics
-
Matter.js Website: https://brm.io/matter-js/ Git repository: https://github.com/liabru/matter-js Documentation: https://brm.io/matter-js/docs/ Mostly maintained by one developer Still kind of updated
-
P2.js Website: https://schteppe.github.io/p2.js/ Git repository: https://github.com/schteppe/p2.js Documentation: http://schteppe.github.io/p2.js/docs/ Mostly maintained by one developer (Same as Cannon.js) Hasn’t been update for 2 years
-
Planck.js Website: https://piqnt.com/planck.js/ Git repository: https://github.com/shakiba/planck.js Documentation: https://github.com/shakiba/planck.js/tree/master/docs Mostly maintained by one developer Still updated nowadays
随机生成大小不一的球和长方体,来让他们产生碰撞
这个例子使用的cannonjs来模拟碰撞,需要知道的是,要提前去看一下,不同形状的物体之间的碰撞是否支持 这是cannonjs的文档链接
预置物理世界的材质、物理世界的地面等相关内容
import CANNON from 'cannon'
// 创建3d物理的世界
const world = new CANNON.World()
// Cannon.js为了避免无所谓的碰撞计算, 选择用3种可用的宽相算法:
// NaiveBroadphase:将每个物体与其他物体进行测试
// GridBroadphase:四分格世界只测试同一方格内或相邻方格内的其他物体
// SAPBroadphase(扫描和修剪 Broadphase):在多个步骤中测试任意轴上的实体。
// 默认的 Broadphase 是 NaiveBroadphase,建议切换到SAPBroadphase。使用这种宽相最终可能会产生不发生碰撞的错误,但这种情况很少见,而且它涉及诸如快速移动物体之类的事情。
// 要切换到SAPBroadphase,只需在world.broadphase属性中实例化它,并使用相同的世界作为参数
world.broadphase = new CANNON.SAPBroadphase(world)
// 即使使用改进的宽相算法,也要对所有 "body"进行测试,即使是那些不再移动的 "body"。我们可以使用一种名为 "sleep"的功能。
// 当 "body"的速度变得非常慢时(慢到你看不到它在移动),"body"就会进入睡眠状态,除非代码对它施加了足够的力,或者其他"body"撞到了它,否则不会对它进行测试。
// 要激活此功能,只需将 "world"上的 allowSleep 属性设置为 true
// 还可以使用 sleepSpeedLimit 和 sleepTimeLimit 属性来控制 "body"入睡的可能性
world.allowSleep = true
// 设置重力
world.gravity.set(0, - 9.82, 0)
// 在物理世界中创建材质
const defaultMaterial = new CANNON.Material('default')
// const concreteMaterial = new CANNON.Material('concrete')
// const plasticMaterial = new CANNON.Material('plastic')
// 创建一个 ContactMaterial。它是两种材质的组合,包含对象碰撞时的属性。
// 前两个参数是材料。第三个参数是一个对象 {},它包含两个重要属性:friction 摩擦系数(摩擦的程度)和 restitution 弹跳系数(弹跳的程度)——两者的默认值都是 0.3
const defaultMaterial = new CANNON.ContactMaterial(
defaultMaterial,
defaultMaterial,
{
friction: 0.1, // 摩擦力
restitution: 0.7 // 弹跳系数
}
)
// 在物理世界中增添材质关系
// world.addContactMaterial(defaultMaterial)
// 设置物理世界的物体所使用的默认材质,可以不用为每个物体重新上材质
world.defaultContactMaterial = defaultMaterial
// 在物理世界创建一个用来放置物体的平面,平面不要有重力,默认平面是垂直于地面的,还要将其进行旋转,与地面平行
const floorShape = new CANNON.Plane()
const floorBody = new CANNON.Body()
floorBody.mass = 0 // 质量设为0
floorBody.addShape(floorShape)
// 使用 Cannon.js 进行旋转比使用 Three.js 稍微困难一些,因为必须使用四元数。旋转 Body 的方法有多种,但必须使用其四元数属性。
// 我们将使用 setFromAxisAngle(...),第一个参数是轴。你可以把它想象成一根钉子穿过身体。第二个参数是角度。这是你围绕那个尖峰旋转身体的程度。
floorBody.quaternion.setFromAxisAngle(new CANNON.Vec3(- 1, 0, 0), Math.PI * 0.5)
world.addBody(floorBody)
// 每次碰撞都产生碰撞的声音
const hitSound = new Audio('/sounds/hit.mp3')
const playHitSound = (collision) =>{
// 一个立方体与另一个立方体轻微接触,不想听到太多撞击声。我们需要知道撞击的力度,如果力度不够,就不要播放任何声音。
// 要获得撞击强度,我们首先需要获得有关碰撞的信息
// 可以通过调用接触属性上的 getImpactVelocityAlongNormal() 方法来找到冲击强度:
const impactStrength = collision.contact.getImpactVelocityAlongNormal()
if( impactStrength > 1.5 ) {
// 随机控制音量的大小
hitSound.volume = Math.random()
// 每次碰撞音量都要从0开始播放
hitSound.currentTime = 0
hitSound.play()
}
}
预置threejs的对应内容
// 环境盒
const cubeTextureLoader = new THREE.CubeTextureLoader()
const environmentMapTexture = cubeTextureLoader.load([
'/textures/environmentMaps/0/px.png',
'/textures/environmentMaps/0/nx.png',
'/textures/environmentMaps/0/py.png',
'/textures/environmentMaps/0/ny.png',
'/textures/environmentMaps/0/pz.png',
'/textures/environmentMaps/0/nz.png'
])
// 地面
const floor = new THREE.Mesh(
new THREE.PlaneGeometry(10, 10),
new THREE.MeshStandardMaterial({
color: '#777777',
metalness: 0.3,
roughness: 0.4,
envMap: environmentMapTexture,
envMapIntensity: 0.5
})
)
floor.receiveShadow = true
floor.rotation.x = - Math.PI * 0.5
scene.add(floor)
// 环境光和直射光
const ambientLight = new THREE.AmbientLight(0xffffff, 0.7)
scene.add(ambientLight)
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.2)
directionalLight.castShadow = true
directionalLight.shadow.mapSize.set(1024, 1024)
directionalLight.shadow.camera.far = 15
directionalLight.shadow.camera.left = - 7
directionalLight.shadow.camera.top = 7
directionalLight.shadow.camera.right = 7
directionalLight.shadow.camera.bottom = - 7
directionalLight.position.set(5, 5, 5)
scene.add(directionalLight)
// 窗口resize...
// 摄像机...
// OrbitControls控件...
// 渲染相关函数...
随机的创建球和长方体
const objectsToUpdate = []
// 在threejs中创建球
const sphereGeometry = new THREE.SphereGeometry(1, 20, 20)
const sphereMaterial = new THREE.MeshStandardMaterial({
metalness: 0.3,
roughness: 0.4,
envMap: environmentMapTexture,
envMapIntensity: 0.5
})
// 在cannonjs中创建球
const createSphere = (radius, position) =>
{
// Three.js mesh
const mesh = new THREE.Mesh(sphereGeometry, sphereMaterial)
mesh.castShadow = true
mesh.scale.set(radius, radius, radius)
mesh.position.copy(position)
scene.add(mesh)
// Cannon.js body
const shape = new CANNON.Sphere(radius)
const sphereBody = new CANNON.Body({
mass: 1,
position: new CANNON.Vec3(0, 3, 0),
shape: shape,
material: defaultMaterial
})
sphereBody.position.copy(position)
// body可以监听的事件有'colide', 'sleep' or 'wakeup'
sphereBody.addEventListener('collide', playHitSound)
world.addBody(sphereBody)
// 保存 Three.js 和 Cannon.js 的物体,方便后续更新他们的位置和角度
objectsToUpdate.push({ mesh, body: sphereBody })
}
// 在threejs中创建box
const boxGeometry = new THREE.BoxGeometry(1, 1, 1)
const boxMaterial = new THREE.MeshStandardMaterial({
metalness: 0.3,
roughness: 0.4,
envMap: environmentMapTexture,
envMapIntensity: 0.5
})
// 在cannonjs中创建box
const createBox = (width, height, depth, position) =>
{
// Three.js mesh
const mesh = new THREE.Mesh(boxGeometry, boxMaterial)
mesh.scale.set(width, height, depth)
mesh.castShadow = true
mesh.position.copy(position)
scene.add(mesh)
// Cannon.js body
const shape = new CANNON.Box(new CANNON.Vec3(width * 0.5, height * 0.5, depth * 0.5))
const body = new CANNON.Body({
mass: 1,
position: new CANNON.Vec3(0, 3, 0),
shape: shape,
material: defaultMaterial
})
body.position.copy(position)
body.addEventListener('collide', playHitSound)
world.addBody(body)
// Save in objects
objectsToUpdate.push({ mesh, body })
}
// 随机的创建球和长方体
createSphere(
Math.random() * 0.5,
{
x: (Math.random() - 0.5) * 3,
y: 3,
z: (Math.random() - 0.5) * 3
}
)
createBox(
Math.random(),
Math.random(),
Math.random(),
{
x: (Math.random() - 0.5) * 3,
y: 3,
z: (Math.random() - 0.5) * 3
}
)
// 删除所有产生的几何体
// for(const object of objectsToUpdate)
// {
// // 移除监听事件
// object.body.removeEventListener('collide', playHitSound)
// world.removeBody(object.body)
// // Remove mesh
// scene.remove(object.mesh)
// }
// 根据时钟来更新物理
const clock = new THREE.Clock()
let oldElapsedTime = 0
const tick = () =>
{
const elapsedTime = clock.getElapsedTime()
const deltaTime = elapsedTime - oldElapsedTime
oldElapsedTime = elapsedTime
// 根据时钟tick更新物理世界,将物理世界的时间向前推进。
// 有两种模式。简单模式是不插值的固定时间步进。在这种情况下,只需使用第一个参数。
// 第二种模式使用插值。在这种情况下,你还需要提供函数上次使用的时间,以及最大固定时间步长
// step ( dt [timeSinceLastCalled] [maxSubSteps=10] )
// dt 要使用的固定时间步长
// timeSinceLastCalled 自上次调用该函数以来所经过的时间
// maxSubSteps 每次函数调用的最大固定步数
world.step(1 / 60, deltaTime, 3)
// 同步物理世界物体的位置和旋转
for(const object of objectsToUpdate)
{
object.mesh.position.copy(object.body.position)
object.mesh.quaternion.copy(object.body.quaternion)
}
// Update controls
controls.update()
// Render
renderer.render(scene, camera)
// Call tick again on the next frame
window.requestAnimationFrame(tick)
}
tick()
解释deltaTime的作用原理
屏幕的物理刷新率在不同设备上是不一样的,比如普通显示器是60Hz,高刷屏可以到120Hz或144Hz。为了让游戏或应用在不同刷新率的设备上运动能够同步,就需要使用 deltaTime 和 fixed timestep 的方法。 原理是:
- 每一帧渲染前,程序记录本帧和上一帧的时间间隔,这就是 deltaTime。
- 用一个固定的时间步长(timestep),比如1/60秒,和deltaTime进行比较。
- 如果deltaTime大于timestep,就多更新几步模拟,以便把模拟赶上渲染。
- 如果小于,就省掉一些更新步骤,放慢模拟速度。
- 这样每次模拟的时间进度就能和屏幕刷新保持一致。 例如60Hz屏幕,timestep是1/60秒:
- 如果某帧deltaTime是1/30秒,就更新2步,总时间正好是1/60秒。
- 如果某帧deltaTime是1/100秒,就更新1步,总时间是1/100秒,没有赶上屏幕刷新。
- 通过这种机制,不同Hz的屏幕都可以用一个固定的timestep,从而实现游戏运行速度的同步。
在cannonjs中给物体添加力或者速度
有多种方法可以对物体施加力:
- applyForce 从空间中的指定点(不一定在身体表面)向身体施加力,就像风一样,总是稍微推动一切,对多米诺骨牌施加小而突然的推动,或者突然施加更大的力以形成多米诺骨牌。愤怒的小鸟跳向敌人的城堡。
- applyImpulse 与 applyForce 类似,但它不是添加导致速度变化的力,而是直接应用于速度。
- applyLocalForce 与 applyForce 相同,但坐标是 Body 的本地坐标(意味着 0, 0, 0 将是 Body 的中心)。
- applyLocalImpulse 与 applyImpulse 相同,但坐标是 Body 的本地坐标
// 给球施加一个x轴正向的力
sphereBody.applyLocalForce(new CANNON.Vec3(150, 0, 0), new CANNON.Vec3(0, 0, 0))
const tick = () =>{
// ...
// 给球再施加一个x轴负向的力,类似与吹风
sphereBody.applyForce(new CANNON.Vec3(- 0.5, 0, 0), sphereBody.position)
world.step(1 / 60, deltaTime, 3)
// ...
}
cannonjs的约束
HingeConstraint(铰链约束):作用类似于门的铰链。 DistanceConstraint(距离约束):强制两个体之间保持一定距离。 LockConstraint(锁定约束):将物体合并,就像它们是一个整体。 PointToPointConstraint(点对点约束):将主体粘合到一个特定的点上。
ammo.js
Ammo.js 是 Bullet 的移植版,Bullet 是一个用 C++ 编写的著名且运行良好的物理引擎。 支持 WebAssembly (wasm)。WebAssembly 是一种低级语言,大多数最新浏览器都支持它。因为是低级语言,所以性能更好。 它更受欢迎,你可以找到更多 Three.js 的示例。它支持更多功能。 如果你需要最好的性能,或者你的项目有特殊功能,你可能应该选择 Ammo.js