- 创建一个时钟对象:
javascript
const clock = new THREE.Clock();
这行代码创建了一个新的THREE.Clock
对象,它用于跟踪经过的时间。这在动画和物理模拟中很有用。
- 创建场景:
javascript
const scene = new THREE.Scene();
这行代码创建了一个新的3D场景。所有的物体(如模型、灯光等)都会添加到这个场景中。
- 设置场景的背景和雾:
javascript
scene.background = new THREE.Color( 0x88ccee );
scene.fog = new THREE.Fog( 0x88ccee, 0, 50 );
这里设置了场景的背景色为浅蓝色,并添加了一个雾效果,使远处的物体看起来更模糊。
- 创建相机:
javascript
const camera = new THREE.PerspectiveCamera( 70, window.innerWidth / window.innerHeight, 0.1, 1000 );
camera.rotation.order = 'YXZ';
这行代码创建了一个新的透视相机,它定义了观察3D场景的角度和范围。70
是视野角度,window.innerWidth / window.innerHeight
定义了相机的宽高比,0.1
和1000
是相机的近裁剪面和远裁剪面。camera.rotation.order = 'YXZ';
设置了相机旋转的顺序。
- 添加半球光:
javascript
const fillLight1 = new THREE.HemisphereLight( 0x8dc1de, 0x00668d, 1.5 );
fillLight1.position.set( 2, 1, 1 );
scene.add( fillLight1 );
这部分代码创建了一个半球光,并将其添加到场景中。半球光模拟了一个柔和的环境光,由一个天空色和一个地面色组成。
- 添加方向光:
javascript
const directionalLight = new THREE.DirectionalLight( 0xffffff, 2.5 );
// ... (设置方向光的各种属性)
scene.add( directionalLight );
这部分代码创建了一个方向光,并设置了其颜色、强度、位置、阴影属性等。方向光是从一个特定方向照射的光,通常用于模拟太阳光。
- 创建渲染器:
javascript
const container = document.getElementById( 'container' );
const renderer = new THREE.WebGLRenderer( { antialias: true } );
renderer.setPixelRatio( window.devicePixelRatio );
renderer.setSize( window.innerWidth, window.innerHeight );
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.VSMShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
container.appendChild( renderer.domElement );
这部分代码首先获取页面上的container
元素,然后创建一个WebGL渲染器。渲染器用于在网页上显示3D场景。这里还设置了渲染器的抗锯齿、像素比、尺寸、阴影映射类型和色调映射。
- 添加性能监控:
javascript
const stats = new Stats();
stats.domElement.style.position = 'absolute';
stats.domElement.style.top = '0px';
container.appendChild( stats.domElement );
这部分代码添加了一个性能监控器,用于显示渲染器的帧率和其他相关信息。
- 常量定义:
javascript
const GRAVITY = 30;
const NUM_SPHERES = 100;
const SPHERE_RADIUS = 0.2;
const STEPS_PER_FRAME = 5;
这里定义了一些常量,用于控制球体的数量、半径、重力加速度和每帧的模拟步骤数。
- 创建球体几何体和材质:
javascript
const sphereGeometry = new THREE.IcosahedronGeometry( SPHERE_RADIUS, 5 );
const sphereMaterial = new THREE.MeshLambertMaterial( { color: 0xdede8d } );
使用THREE.IcosahedronGeometry
创建了一个二十面体几何体,用于作为球体的基础形状。然后,定义了一个Lambert材质,这种材质适用于模拟非直接光照的情况,并设置了球体的颜色。
- 创建和添加球体到场景中:
javascript
const spheres = [];
let sphereIdx = 0;
for ( let i = 0; i < NUM_SPHERES; i ++ ) {
// 创建球体网格
const sphere = new THREE.Mesh( sphereGeometry, sphereMaterial );
sphere.castShadow = true;
sphere.receiveShadow = true;
// 将球体添加到场景中
scene.add( sphere );
// 将球体和其他相关数据添加到数组中
spheres.push({
mesh: sphere,
collider: new THREE.Sphere( new THREE.Vector3( 0, - 100, 0 ), SPHERE_RADIUS ),
velocity: new THREE.Vector3()
});
}
这段代码循环创建了指定数量的球体,并将它们添加到场景中。每个球体都有一个与之关联的碰撞体(一个Three.js的球体),用于物理模拟,以及一个速度向量。
- 创建八叉树:
javascript
const worldOctree = new Octree();
八叉树(Octree)是一种用于3D空间分割的数据结构,常用于碰撞检测和空间索引。这里创建了一个新的八叉树实例,但代码中没有显示其如何使用。
- 创建玩家碰撞体和速度向量:
javascript
const playerCollider = new Capsule( new THREE.Vector3( 0, 0.35, 0 ), new THREE.Vector3( 0, 1, 0 ), 0.35 );
const playerVelocity = new THREE.Vector3();
const playerDirection = new THREE.Vector3();
这里创建了玩家的碰撞体(一个胶囊形状),速度向量和方向向量。注意,Capsule
可能是一个自定义类,不是Three.js库的一部分。
- 初始化其他变量:
javascript
let playerOnFloor = false;
let mouseTime = 0;
const keyStates = {};
const vector1 = new THREE.Vector3();
const vector2 = new THREE.Vector3();
const vector3 = new THREE.Vector3();
这些变量可能用于控制玩家的状态(如是否在地板上)、鼠标交互、键盘输入以及临时存储向量计算的结果。
- 键盘按键监听:
javascript
document.addEventListener( 'keydown', ( event ) => {
keyStates[ event.code ] = true;
} );
document.addEventListener( 'keyup', ( event ) => {
keyStates[ event.code ] = false;
} );
这里为文档对象(整个页面)添加了两个事件监听器,分别用于处理键盘的keydown
和keyup
事件。keydown
事件在用户按下键时触发,keyup
则在键被释放时触发。event.code
提供了被按下或释放的键的标识符。keyStates
是一个对象,用于存储每个键的当前状态(按下或释放)。
- 鼠标按下监听:
javascript
container.addEventListener( 'mousedown', () => {
document.body.requestPointerLock();
mouseTime = performance.now();
} );
当在container
元素上按下鼠标时,这段代码请求将指针锁定到页面上,这样当鼠标移动时,鼠标指针将不再显示,而页面的其他部分也不会接收鼠标事件。mouseTime
变量存储了鼠标按下的时间,可能是为了计算后续鼠标移动的持续时间或速度。
- 鼠标释放监听:
javascript
document.addEventListener( 'mouseup', () => {
if ( document.pointerLockElement !== null ) throwBall();
} );
当鼠标按钮释放时,如果指针已经被锁定(即document.pointerLockElement
不为null),则调用throwBall
函数。从这段代码看不出throwBall
函数的实现细节,但可以猜测它的作用是抛出某种对象(可能是一个球体)。
- 鼠标移动监听:
javascript
document.body.addEventListener( 'mousemove', ( event ) => {
if ( document.pointerLockElement === document.body ) {
camera.rotation.y -= event.movementX / 500;
camera.rotation.x -= event.movementY / 500;
}
} );
当鼠标在文档体上移动时,如果指针被锁定到文档体上,这段代码会更新相机的旋转。event.movementX
和event.movementY
分别表示鼠标在X和Y轴上的移动量。通过除以500,代码将鼠标的移动量转换为较小的相机旋转量,从而实现平滑的相机控制。
- 窗口大小调整监听:
javascript
window.addEventListener( 'resize', onWindowResize );
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize( window.innerWidth, window.innerHeight );
}
这段代码监听窗口的大小调整事件。当窗口大小变化时,它会调用onWindowResize
函数,该函数会更新相机的纵横比,并更新其投影矩阵。此外,它还调用renderer.setSize
来确保渲染器的大小与窗口的大小相匹配。
这段代码是用于处理三维场景中的球体和玩家(或相机)的交互和移动。具体来说,它定义了三个函数:throwBall
、playerCollisions
和updatePlayer
。以下是对这些函数的详细解析:
throwBall
函数
这个函数用于投掷一个球体(可能是一个游戏对象,如球)。
-
获取球体:
javascriptconst sphere = spheres[ sphereIdx ];
从
spheres
数组中获取一个球体,sphereIdx
是当前的球体索引。 -
设置投掷方向:
javascriptcamera.getWorldDirection( playerDirection ); sphere.collider.center.copy( playerCollider.end ).addScaledVector( playerDirection, playerCollider.radius * 1.5 );
首先获取相机的世界方向(玩家面向的方向),然后将球体的位置设置为玩家碰撞体的末端,并沿着玩家方向移动一定距离(玩家碰撞体半径的1.5倍)。
-
计算投掷力度:
javascriptconst impulse = 15 + 30 * ( 1 - Math.exp( ( mouseTime - performance.now() ) * 0.001 ) );
这里计算投掷的力度(或冲量)。力度基于鼠标按下的时间(
mouseTime
)和当前时间(performance.now()
)的差值来计算。时间差越大,力度越大。 -
设置球体的速度和方向:
javascriptsphere.velocity.copy( playerDirection ).multiplyScalar( impulse ); sphere.velocity.addScaledVector( playerVelocity, 2 );
球体的速度被设置为玩家方向,并乘以计算出的力度。然后,再基于玩家的速度进行微调。
-
更新球体索引:
javascriptsphereIdx = ( sphereIdx + 1 ) % spheres.length;
更新球体索引,以便下一次投掷时可以使用下一个球体。
playerCollisions
函数
这个函数处理玩家(或相机的碰撞体)与场景中的其他物体之间的碰撞。
-
检测碰撞:
javascriptconst result = worldOctree.capsuleIntersect( playerCollider );
使用
worldOctree
(可能是一个空间划分的数据结构,用于加速碰撞检测)检测玩家的碰撞体是否与任何物体相交。 -
判断是否在地面上:
javascriptplayerOnFloor = false; if ( result ) { playerOnFloor = result.normal.y > 0; // ... }
如果发生碰撞,检查碰撞的法线(
result.normal
)的y分量是否大于0来判断玩家是否在地面上。 -
处理非地面碰撞:
javascriptif ( ! playerOnFloor ) { playerVelocity.addScaledVector( result.normal, - result.normal.dot( playerVelocity) ); }
如果玩家不在地面上,更新玩家的速度以反映碰撞的影响。
-
移动碰撞体以处理穿透:
javascriptplayerCollider.translate( result.normal.multiplyScalar( result.depth) );
移动玩家的碰撞体以处理任何可能发生的穿透。
updatePlayer
函数
这个函数用于更新玩家的位置和速度。
-
计算阻尼:
javascriptlet damping = Math.exp( - 4 * deltaTime ) - 1;
阻尼用于模拟玩家速度的逐渐减小(例如空气阻力或摩擦)。
-
处理玩家在空中的移动:
javascriptif ( ! playerOnFloor ) { playerVelocity.y -= GRAVITY * deltaTime; damping *= 0.1; }
如果玩家不在地面上,更新玩家的y速度以模拟重力,并减小阻尼。
-
更新玩家速度:
javascriptplayerVelocity.addScaledVector( playerVelocity, damping );
根据阻尼更新玩家的速度。
-
移动玩家碰撞体:
javascriptconst deltaPosition = playerVelocity.clone().multiplyScalar( deltaTime ); playerCollider.translate( deltaPosition );
计算玩家应该移动的距离,并更新玩家的碰撞体位置。
-
检测碰撞并更新相机位置:
javascriptplayerCollisions(); camera.position.copy( playerCollider.end );
调用
playerCollisions
函数来处理可能的碰撞,然后将相机的位置设置为玩家碰撞体的末端。
playerSphereCollision
函数
这个函数处理玩家球体与另一个球体的碰撞。
-
计算玩家碰撞体的中心:
javascriptconst center = vector1.addVectors( playerCollider.start, playerCollider.end ).multiplyScalar( 0.5 );
玩家碰撞体可能是一个胶囊体(capsule),这里计算其中心点。
-
获取球体的中心:
javascriptconst sphere_center = sphere.collider.center;
-
计算碰撞半径:
javascriptconst r = playerCollider.radius + sphere.collider.radius; const r2 = r * r;
这是玩家碰撞体和球体碰撞体半径之和,以及它的平方。
-
碰撞检测:
javascriptfor ( const point of [ playerCollider.start, playerCollider.end, center ] ) { // ... }
这里将玩家碰撞体近似为三个点(开始点、结束点和中心点)进行碰撞检测。对于每个点,它计算到球体中心的距离平方,并检查这个距离是否小于两个碰撞体半径之和的平方。
-
碰撞响应 :
如果发生碰撞,代码计算碰撞法线(normal),然后根据法线和两个物体的速度来计算碰撞后的新速度。同时,调整球体的位置以解决任何可能的穿透。
spheresCollisions
函数
这个函数处理球体之间的碰撞。
-
双层循环遍历球体:
javascriptfor ( let i = 0, length = spheres.length; i < length; i ++ ) { // ... for ( let j = i + 1; j < length; j ++ ) { // ... } }
使用两个嵌套的循环来遍历所有球体对。这样可以确保每个球体对只被检查一次。
-
计算球体间的距离平方:
javascriptconst d2 = s1.collider.center.distanceToSquared( s2.collider.center );
-
检查碰撞 :
如果两个球体中心之间的距离小于它们半径之和,则发生碰撞。
-
碰撞响应 :
类似于
playerSphereCollision
函数,根据碰撞法线和两个球体的速度来计算新的速度,并调整球体的位置。
这段代码实现了三维空间中的碰撞检测和响应。它使用了简化的碰撞检测(将玩家碰撞体近似为三个点)和基于物理的碰撞响应(改变物体的速度和位置)。这种碰撞处理在实时渲染和物理模拟中非常常见,特别是在游戏开发中。
这段代码主要实现了三维空间中多个球体的更新和碰撞处理。以下是对代码的详细解析:
updateSpheres
函数
这个函数负责更新所有球体的位置和速度,并处理它们与场景中其他物体的碰撞。
-
更新球体位置:
javascriptsphere.collider.center.addScaledVector( sphere.velocity, deltaTime );
根据球体的速度和经过的时间(
deltaTime
)来更新球体的位置。 -
检查球体与场景中的碰撞:
javascriptconst result = worldOctree.sphereIntersect( sphere.collider );
使用
worldOctree
(可能是一个用于空间划分的数据结构)来检查球体是否与场景中的其他物体发生碰撞。 -
处理碰撞 :
如果发生碰撞(
result
为真),则调整球体的速度和位置以响应碰撞。这里使用了基于物理的碰撞响应,通过沿着碰撞法线(result.normal
)反向推动球体来解决穿透问题。 -
处理重力 :
如果球体没有碰撞到任何东西(
result
为假),则球体的y轴速度(竖直方向)会受到重力的影响而减小。 -
速度阻尼:
javascriptconst damping = Math.exp( - 1.5 * deltaTime ) - 1; sphere.velocity.addScaledVector( sphere.velocity, damping );
对球体的速度应用阻尼,使其逐渐减小。这可以模拟空气阻力或其他形式的能量损失。
-
检查与玩家的碰撞:
javascriptplayerSphereCollision( sphere );
调用
playerSphereCollision
函数来检查球体是否与玩家发生碰撞,并相应地更新它们的速度和位置。 -
处理球体间的碰撞:
javascriptspheresCollisions();
调用
spheresCollisions
函数来处理球体之间的碰撞。 -
更新球体的视觉表示:
javascriptsphere.mesh.position.copy( sphere.collider.center );
将球体的视觉表示(
mesh
)的位置更新为其碰撞体的中心位置,以确保视觉和物理状态一致。
getForwardVector
函数
这个函数返回相机(或玩家)的前方向量。
-
获取相机的世界方向:
javascriptcamera.getWorldDirection( playerDirection );
获取相机在世界坐标系中的方向。
-
调整方向并归一化:
javascriptplayerDirection.y = 0; playerDirection.normalize();
将方向向量的y分量设为0(即忽略竖直方向),然后归一化向量。
getSideVector
函数
这个函数返回相机(或玩家)的侧向量。
-
获取相机的世界方向:
javascriptcamera.getWorldDirection( playerDirection );
同样获取相机在世界坐标系中的方向。
-
计算侧向量:
javascriptplayerDirection.cross( camera.up );
通过计算相机方向向量与上方向向量的叉积来得到侧向量。
-
玩家控制(
controls
函数):- 根据
deltaTime
(上一次渲染到当前渲染的时间差)和玩家是否在地面(playerOnFloor
)来设定玩家的移动速度(speedDelta
)。 - 根据按键状态(
keyStates
)来更新玩家的速度(playerVelocity
)。具体来说,如果按下'W'键,玩家会向前移动;如果按下'S'键,玩家会向后移动;如果按下'A'键,玩家会向左移动;如果按下'D'键,玩家会向右移动。 - 如果玩家在地面并且按下空格键,玩家的垂直速度(y轴)会设置为15,实现跳跃功能。
- 根据
-
模型加载和场景设置:
- 使用
GLTFLoader
来加载一个名为collision-world.glb
的3D模型。 - 将加载的模型(
gltf.scene
)添加到场景(scene
)中。 - 创建一个
worldOctree
数据结构来存储场景的碰撞信息,并通过gltf.scene
来初始化它。 - 遍历模型的每一个子节点,如果它是网格(
isMesh
),则设置它的阴影投射和接收属性,并优化其贴图的采样(通过设置anisotropy
)。 - 创建一个
OctreeHelper
对象,这是一个用于可视化worldOctree
的辅助对象,但默认是隐藏的。 - 使用
GUI
库来创建一个界面元素,用户可以通过这个界面来切换OctreeHelper
的可见性。
- 使用
-
玩家位置重置(
teleportPlayerIfOob
函数):- 如果相机(代表玩家)的位置在y轴上的值小于或等于-25(可能表示玩家掉出了世界边界),则将玩家的碰撞体(
playerCollider
)的位置和大小重置,并将相机位置设置为新的碰撞体位置,同时将相机的旋转重置。这实际上是将玩家"传送"回世界中的某个安全位置。
- 如果相机(代表玩家)的位置在y轴上的值小于或等于-25(可能表示玩家掉出了世界边界),则将玩家的碰撞体(
整体上,这段代码为3D场景中的玩家控制、模型加载和场景设置以及玩家位置重置提供了实现。其中,controls
函数负责根据玩家的输入更新玩家的速度,而模型加载和场景设置部分则通过加载一个3D模型并设置其相关属性来初始化场景。最后,teleportPlayerIfOob
函数提供了一个机制来确保玩家不会掉出世界的边界。
animate
函数是3D渲染应用中常见的动画循环函数,用于在每个动画帧中更新场景的状态并渲染场景。以下是对这段代码的详细解析:
代码功能概述
-
计算时间差(
deltaTime
) : 通过clock.getDelta()
获取从上一次动画帧到现在的时间差(以秒为单位),并限制其最大值为0.05秒。之后,将这个时间差除以STEPS_PER_FRAME
,得到一个子步长的时间差。 -
碰撞检测的子步处理 : 通过一个循环,执行
STEPS_PER_FRAME
次更新操作,每次循环中都会调用玩家控制和更新函数,以此来降低物体快速移动导致碰撞检测失败的风险。 -
更新函数:
controls( deltaTime )
: 根据当前的时间差deltaTime
更新玩家的速度和位置。updatePlayer( deltaTime )
: 更新玩家的状态或位置,可能还包括与环境的交互。updateSpheres( deltaTime )
: 更新场景中的球体(或其他物体)的状态。teleportPlayerIfOob()
: 如果玩家超出边界,则将其传送回安全位置。
-
渲染场景 : 使用
renderer.render( scene, camera )
渲染当前的场景和相机视图。 -
更新性能统计 :
stats.update()
可能用于更新一些性能统计信息,比如帧率等。 -
请求下一帧 : 使用
requestAnimationFrame( animate )
请求浏览器的下一帧动画,并将animate
函数作为回调函数,从而形成一个连续的动画循环。
细节分析
-
deltaTime
的计算考虑了STEPS_PER_FRAME
,这通常用于减少快速移动物体错过碰撞检测的机会。通过将总的deltaTime
分割成多个子步,每个子步中更新物体的位置,可以提高碰撞检测的准确性。 -
controls
函数负责根据用户的输入和当前的时间差来更新玩家的移动状态。 -
updatePlayer
和updateSpheres
函数分别更新玩家和场景中球体的状态。这些更新可能包括位置、速度、动画状态等。 -
teleportPlayerIfOob
函数用于处理玩家超出边界的情况,确保玩家不会离开游戏世界。 -
renderer.render( scene, camera )
是渲染命令,它使用当前的场景和相机状态来生成图像。 -
stats.update()
可能是用于更新性能统计信息的,例如帧率、渲染时间等,这对于调试和优化非常有用。 -
requestAnimationFrame( animate )
确保animate
函数在每个浏览器动画帧被调用,从而保持动画的流畅性和同步性。
总结
这段代码定义了一个 animate
函数,它是3D应用中典型的动画循环。它首先计算时间差,并在多个子步中更新玩家和场景中物体的状态,然后进行渲染和性能统计的更新。最后,它使用 requestAnimationFrame
来请求下一帧的动画,从而形成一个连续的动画循环。这个循环确保了游戏或应用的流畅运行和实时交互。
全部源码
html
<!DOCTYPE html>
<html lang="en">
<head>
<title>three.js - misc - octree collisions</title>
<meta charset=utf-8 />
<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
<link type="text/css" rel="stylesheet" href="main.css">
</head>
<body>
<div id="info">
Octree threejs demo - basic collisions with static triangle mesh<br />
MOUSE to look around and to throw balls<br/>
WASD to move and SPACE to jump
</div>
<div id="container"></div>
<script type="importmap">
{
"imports": {
"three": "../build/three.module.js",
"three/addons/": "./jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import Stats from 'three/addons/libs/stats.module.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { Octree } from 'three/addons/math/Octree.js';
import { OctreeHelper } from 'three/addons/helpers/OctreeHelper.js';
import { Capsule } from 'three/addons/math/Capsule.js';
import { GUI } from 'three/addons/libs/lil-gui.module.min.js';
const clock = new THREE.Clock();
const scene = new THREE.Scene();
scene.background = new THREE.Color( 0x88ccee );
scene.fog = new THREE.Fog( 0x88ccee, 0, 50 );
const camera = new THREE.PerspectiveCamera( 70, window.innerWidth / window.innerHeight, 0.1, 1000 );
camera.rotation.order = 'YXZ';
const fillLight1 = new THREE.HemisphereLight( 0x8dc1de, 0x00668d, 1.5 );
fillLight1.position.set( 2, 1, 1 );
scene.add( fillLight1 );
const directionalLight = new THREE.DirectionalLight( 0xffffff, 2.5 );
directionalLight.position.set( - 5, 25, - 1 );
directionalLight.castShadow = true;
directionalLight.shadow.camera.near = 0.01;
directionalLight.shadow.camera.far = 500;
directionalLight.shadow.camera.right = 30;
directionalLight.shadow.camera.left = - 30;
directionalLight.shadow.camera.top = 30;
directionalLight.shadow.camera.bottom = - 30;
directionalLight.shadow.mapSize.width = 1024;
directionalLight.shadow.mapSize.height = 1024;
directionalLight.shadow.radius = 4;
directionalLight.shadow.bias = - 0.00006;
scene.add( directionalLight );
const container = document.getElementById( 'container' );
const renderer = new THREE.WebGLRenderer( { antialias: true } );
renderer.setPixelRatio( window.devicePixelRatio );
renderer.setSize( window.innerWidth, window.innerHeight );
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.VSMShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
container.appendChild( renderer.domElement );
const stats = new Stats();
stats.domElement.style.position = 'absolute';
stats.domElement.style.top = '0px';
container.appendChild( stats.domElement );
const GRAVITY = 30;
const NUM_SPHERES = 100;
const SPHERE_RADIUS = 0.2;
const STEPS_PER_FRAME = 5;
const sphereGeometry = new THREE.IcosahedronGeometry( SPHERE_RADIUS, 5 );
const sphereMaterial = new THREE.MeshLambertMaterial( { color: 0xdede8d } );
const spheres = [];
let sphereIdx = 0;
for ( let i = 0; i < NUM_SPHERES; i ++ ) {
const sphere = new THREE.Mesh( sphereGeometry, sphereMaterial );
sphere.castShadow = true;
sphere.receiveShadow = true;
scene.add( sphere );
spheres.push( {
mesh: sphere,
collider: new THREE.Sphere( new THREE.Vector3( 0, - 100, 0 ), SPHERE_RADIUS ),
velocity: new THREE.Vector3()
} );
}
const worldOctree = new Octree();
const playerCollider = new Capsule( new THREE.Vector3( 0, 0.35, 0 ), new THREE.Vector3( 0, 1, 0 ), 0.35 );
const playerVelocity = new THREE.Vector3();
const playerDirection = new THREE.Vector3();
let playerOnFloor = false;
let mouseTime = 0;
const keyStates = {};
const vector1 = new THREE.Vector3();
const vector2 = new THREE.Vector3();
const vector3 = new THREE.Vector3();
document.addEventListener( 'keydown', ( event ) => {
keyStates[ event.code ] = true;
} );
document.addEventListener( 'keyup', ( event ) => {
keyStates[ event.code ] = false;
} );
container.addEventListener( 'mousedown', () => {
document.body.requestPointerLock();
mouseTime = performance.now();
} );
document.addEventListener( 'mouseup', () => {
if ( document.pointerLockElement !== null ) throwBall();
} );
document.body.addEventListener( 'mousemove', ( event ) => {
if ( document.pointerLockElement === document.body ) {
camera.rotation.y -= event.movementX / 500;
camera.rotation.x -= event.movementY / 500;
}
} );
window.addEventListener( 'resize', onWindowResize );
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize( window.innerWidth, window.innerHeight );
}
function throwBall() {
const sphere = spheres[ sphereIdx ];
camera.getWorldDirection( playerDirection );
sphere.collider.center.copy( playerCollider.end ).addScaledVector( playerDirection, playerCollider.radius * 1.5 );
// throw the ball with more force if we hold the button longer, and if we move forward
const impulse = 15 + 30 * ( 1 - Math.exp( ( mouseTime - performance.now() ) * 0.001 ) );
sphere.velocity.copy( playerDirection ).multiplyScalar( impulse );
sphere.velocity.addScaledVector( playerVelocity, 2 );
sphereIdx = ( sphereIdx + 1 ) % spheres.length;
}
function playerCollisions() {
const result = worldOctree.capsuleIntersect( playerCollider );
playerOnFloor = false;
if ( result ) {
playerOnFloor = result.normal.y > 0;
if ( ! playerOnFloor ) {
playerVelocity.addScaledVector( result.normal, - result.normal.dot( playerVelocity ) );
}
playerCollider.translate( result.normal.multiplyScalar( result.depth ) );
}
}
function updatePlayer( deltaTime ) {
let damping = Math.exp( - 4 * deltaTime ) - 1;
if ( ! playerOnFloor ) {
playerVelocity.y -= GRAVITY * deltaTime;
// small air resistance
damping *= 0.1;
}
playerVelocity.addScaledVector( playerVelocity, damping );
const deltaPosition = playerVelocity.clone().multiplyScalar( deltaTime );
playerCollider.translate( deltaPosition );
playerCollisions();
camera.position.copy( playerCollider.end );
}
function playerSphereCollision( sphere ) {
const center = vector1.addVectors( playerCollider.start, playerCollider.end ).multiplyScalar( 0.5 );
const sphere_center = sphere.collider.center;
const r = playerCollider.radius + sphere.collider.radius;
const r2 = r * r;
// approximation: player = 3 spheres
for ( const point of [ playerCollider.start, playerCollider.end, center ] ) {
const d2 = point.distanceToSquared( sphere_center );
if ( d2 < r2 ) {
const normal = vector1.subVectors( point, sphere_center ).normalize();
const v1 = vector2.copy( normal ).multiplyScalar( normal.dot( playerVelocity ) );
const v2 = vector3.copy( normal ).multiplyScalar( normal.dot( sphere.velocity ) );
playerVelocity.add( v2 ).sub( v1 );
sphere.velocity.add( v1 ).sub( v2 );
const d = ( r - Math.sqrt( d2 ) ) / 2;
sphere_center.addScaledVector( normal, - d );
}
}
}
function spheresCollisions() {
for ( let i = 0, length = spheres.length; i < length; i ++ ) {
const s1 = spheres[ i ];
for ( let j = i + 1; j < length; j ++ ) {
const s2 = spheres[ j ];
const d2 = s1.collider.center.distanceToSquared( s2.collider.center );
const r = s1.collider.radius + s2.collider.radius;
const r2 = r * r;
if ( d2 < r2 ) {
const normal = vector1.subVectors( s1.collider.center, s2.collider.center ).normalize();
const v1 = vector2.copy( normal ).multiplyScalar( normal.dot( s1.velocity ) );
const v2 = vector3.copy( normal ).multiplyScalar( normal.dot( s2.velocity ) );
s1.velocity.add( v2 ).sub( v1 );
s2.velocity.add( v1 ).sub( v2 );
const d = ( r - Math.sqrt( d2 ) ) / 2;
s1.collider.center.addScaledVector( normal, d );
s2.collider.center.addScaledVector( normal, - d );
}
}
}
}
function updateSpheres( deltaTime ) {
spheres.forEach( sphere => {
sphere.collider.center.addScaledVector( sphere.velocity, deltaTime );
const result = worldOctree.sphereIntersect( sphere.collider );
if ( result ) {
sphere.velocity.addScaledVector( result.normal, - result.normal.dot( sphere.velocity ) * 1.5 );
sphere.collider.center.add( result.normal.multiplyScalar( result.depth ) );
} else {
sphere.velocity.y -= GRAVITY * deltaTime;
}
const damping = Math.exp( - 1.5 * deltaTime ) - 1;
sphere.velocity.addScaledVector( sphere.velocity, damping );
playerSphereCollision( sphere );
} );
spheresCollisions();
for ( const sphere of spheres ) {
sphere.mesh.position.copy( sphere.collider.center );
}
}
function getForwardVector() {
camera.getWorldDirection( playerDirection );
playerDirection.y = 0;
playerDirection.normalize();
return playerDirection;
}
function getSideVector() {
camera.getWorldDirection( playerDirection );
playerDirection.y = 0;
playerDirection.normalize();
playerDirection.cross( camera.up );
return playerDirection;
}
function controls( deltaTime ) {
// gives a bit of air control
const speedDelta = deltaTime * ( playerOnFloor ? 25 : 8 );
if ( keyStates[ 'KeyW' ] ) {
playerVelocity.add( getForwardVector().multiplyScalar( speedDelta ) );
}
if ( keyStates[ 'KeyS' ] ) {
playerVelocity.add( getForwardVector().multiplyScalar( - speedDelta ) );
}
if ( keyStates[ 'KeyA' ] ) {
playerVelocity.add( getSideVector().multiplyScalar( - speedDelta ) );
}
if ( keyStates[ 'KeyD' ] ) {
playerVelocity.add( getSideVector().multiplyScalar( speedDelta ) );
}
if ( playerOnFloor ) {
if ( keyStates[ 'Space' ] ) {
playerVelocity.y = 15;
}
}
}
const loader = new GLTFLoader().setPath( './models/gltf/' );
loader.load( 'collision-world.glb', ( gltf ) => {
scene.add( gltf.scene );
worldOctree.fromGraphNode( gltf.scene );
gltf.scene.traverse( child => {
if ( child.isMesh ) {
child.castShadow = true;
child.receiveShadow = true;
if ( child.material.map ) {
child.material.map.anisotropy = 4;
}
}
} );
const helper = new OctreeHelper( worldOctree );
helper.visible = false;
scene.add( helper );
const gui = new GUI( { width: 200 } );
gui.add( { debug: false }, 'debug' )
.onChange( function ( value ) {
helper.visible = value;
} );
animate();
} );
function teleportPlayerIfOob() {
if ( camera.position.y <= - 25 ) {
playerCollider.start.set( 0, 0.35, 0 );
playerCollider.end.set( 0, 1, 0 );
playerCollider.radius = 0.35;
camera.position.copy( playerCollider.end );
camera.rotation.set( 0, 0, 0 );
}
}
function animate() {
const deltaTime = Math.min( 0.05, clock.getDelta() ) / STEPS_PER_FRAME;
// we look for collisions in substeps to mitigate the risk of
// an object traversing another too quickly for detection.
for ( let i = 0; i < STEPS_PER_FRAME; i ++ ) {
controls( deltaTime );
updatePlayer( deltaTime );
updateSpheres( deltaTime );
teleportPlayerIfOob();
}
renderer.render( scene, camera );
stats.update();
requestAnimationFrame( animate );
}
</script>
</body>
</html>