阅读three.js 示例 --- 酷炫3D场景中投掷球

该示例是对八叉树的综合应用,鼠标控制投球,并对球和墙体碰撞进行检测。按下 W A S D 键可以控制场景的 "前" "左" " 下" "右" 方向的移动,而按下空格键可进行跳跃。

该示例涉及的知识点比较多,下面我会先对知识点进行讲解,再对示例整体实现逻辑进行梳理总结。

一. Fog 雾

1.定义

Fog 类定义的是线性雾,雾的密度是随着距离线性增大,即场景中物体雾化效果随着距离线性变化。

2.使用

js 复制代码
var scene = new THREE.Scene(); 
//设置场景对象Scene的雾化属性.fog来模拟生活中雾化效果 
scene.fog = new THREE.Fog(0xcc0000, 1, 1000);

构造函数雾 Fog(color, near, far)三个参数

参数名 描述
color 雾的颜色,比如设置为红色,场景中远处物体为黑色,场景中最近处距离物体时自身颜色,最远和最近之间的物体颜色是物体本身颜色和雾颜色的混合效果
near 应用雾化效果的最小距离,距离活动摄像机长度小于.near的物体将不会被雾所影响
far 应用雾化效果的最大距离,距离活动摄像机长度大于.far的物体将不会被雾所影响

3. FogExp2

js 复制代码
// 指数雾(FogExp2) 
// 参数2:0.001默认值是0.00025,改变的是属性.density 
scene.fog = new THREE.FogExp2(0xcc0000,0.001);

FogExp2 类定义的是指数雾。也就是说,雾的密度是随着距离指数增大的。

FogExp2 和 Fog 的雾化算法不同,都具有颜色属性.color。 而FogExp2 没有.near 和 .far 属性,直接设置 .density 属性就可以

参数名 说明
color 雾的颜色
density 雾的密度将会增长多快,默认值是0.00025

4. 示例效果

可以对比下示例中加了 #88ccee 颜色雾和没加的区别。

(1) #88ccee 颜色

(2) 是否加Fog雾的效果对比

二. HemisphereLight 半球光

1.定义

HemisphereLight 半球光,光源直接放置于场景之上,光照颜色从天空光线颜色渐变到地面光纤颜色。注意:半球光不能投影射线。

2.创建半球光

js 复制代码
var light = new THREE.HemisphereLight( 0xffffbb, 0x080820, 1 ); 
scene.add( light );

构造器:HemisphereLight( skyColor : Integer, groundColor : Integer, intensity : Float )

参数名 说明
skyColor (可选参数)天空中发出光线的颜色。缺省值0xffffff
groundColor (可选参数) 地面发出光线的颜色。 缺省值 0xffffff
intensity (可选参数) 光照强度。 缺省值 1

3. 没添加半球光的效果

三. HemisphereLight 平行光及产生的阴影

1. 平行光

点光源 PointLight、聚光源 SpotLight 、平行光 DirectionalLight都能产生阴影,但环境光 AmbientLight 这种没方向的光源,不会产生阴影。

以下是示例里对平行光的应用:

js 复制代码
// DirectionalLight: 平行光
const directionalLight = new THREE.DirectionalLight( 0xffffff, 2.5 );
directionalLight.position.set( - 5, 25, - 1 );
// castShadow: 设置为true灯光将投射为阴影
directionalLight.castShadow = true;
// shadow.camera 设置阴影渲染范围
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;
// .shadow.mapSize阴影贴图尺寸属性
directionalLight.shadow.mapSize.width = 1024;
directionalLight.shadow.mapSize.height = 1024;
// shadow.radius 阴影半径 
directionalLight.shadow.radius = 4;
directionalLight.shadow.bias = - 0.00006;
scene.add( directionalLight );

2. 平行光产生阴影的步骤

  1. .castShadow设置产生阴影的模型对象
  2. .castShadow设置产生阴影的光源对象
  3. .receiveShadow设置接收阴影效果的模型
  4. .shadowMap.enabledWebGl渲染器允许阴影渲染
  5. .shadow.camera设置光源阴影渲染范围

具体:

(1).castShadow设置产生阴影的模型对象
js 复制代码
// 设置产生投影的网格模型
mesh.castShadow = true;
(2).castShadow设置产生阴影的光源对象

和产生阴影的模型一样,光源也有阴影投射属性 .castShadow。 光源默认不产生阴影,需要代码开启。

js 复制代码
// 平行光
const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
// 平行光设置产生阴影的光源对象,开启光源阴影的计算功能
directionalLight.castShadow = true;
(3).receiveShadow设置接收阴影效果的模型
ini 复制代码
// 设置接收阴影的投影面
planeMesh.receiveShadow = true;
(4).shadowMap.enabledWebGl渲染器允许阴影渲染
ini 复制代码
// 设置渲染器,允许光源阴影渲染
renderer.shadowMap.enabled = true; 
(5).shadow.camera设置光源阴影渲染范围

平行光DirectionalLight.shadow属性是平行光阴影对象 DirectionalLightShadow,平行光阴影对象有一个相机属性shadow.camera

平行光阴影相机属性.shadow.camera的属性值是一个正投影相机对象OrthographicCamera

arduino 复制代码
// 查看平行光阴影相机属性
console.log('阴影相机属性',directionalLight.shadow.camera);

3. CameraHelper可视化 ### .shadow.camera

csharp 复制代码
// 可视化平行光阴影对应的正投影相机对象
const cameraHelper = new THREE.CameraHelper(directionalLight.shadow.camera);
scene.add(cameraHelper);

平行光阴影相机的位置 .shadow.camera.position 默认就是平行光的位置属性 directionalLight.position

4..shadow.camera设置阴影渲染范围

.shadow.camera属性值是正投影相机OrthographicCamera,所以.shadow.camera属性的用法可以参考的正投影相机OrthographicCamera原理图。 所以其阴影计算是通过设置上下左右前后六个属性,定义了一个长方体空间。

css 复制代码
OrthographicCamera( left, right, top, bottom, near, far )

5. 示例阴影中用到的其他属性

(1) .shadow.mapSize 阴影贴图属性

如果你的阴影边缘不够清晰,有模糊感、锯齿感,可以适当提升 .mapSize 属性值

bash 复制代码
directionalLight.shadow.mapSize.set(1024,1024);
(2) 阴影半径 .shadow.radius 属性

适当提升 .shadow.radius ,可以感觉到阴影边缘和非阴影边缘是渐变过渡,或者是阴影边缘逐渐弱化或模糊化,没有很明显的边界感

ini 复制代码
directionalLight.shadow.radius = 3;

6. 注意

  • 在能覆盖包含阴影渲染范围的情况下,.shadow.camera的尺寸尽量小。
  • 如果你增加.shadow.camera长方体尺寸范围,阴影模糊锯齿感,可以适当提升 .shadow.mapSize 的大小。

7. CameraHelper 可视化平行光阴影对应的正投影相机对象

js 复制代码
const cameraHelper = new THREE.CameraHelper(directionalLight.shadow.camera);
scene.add(cameraHelper);

四. Stats 查看渲染帧率

使用stats插件后,会在左上角显示性能帧数、每次刷新所用时间、占用内存。鼠标右击可进行切换,默认图一。

1.实例化stats 组件,并添加到dom中

js 复制代码
const stats = new Stats();
stats.domElement.style.position = 'absolute';
stats.domElement.style.top = '0px';
container.appendChild( stats.domElement );

2. requestAnimationFrame()函数调用里,更新stats

js 复制代码
function animate() {
    .....
    renderer.render( scene, camera );
    stats.update();
    requestAnimationFrame( animate );
}

五. IcosahedronGeometry 二十面缓存几何体

1. 定义

js 复制代码
IcosahedronGeometry(radius : Float, detail : Integer)
参数 说明
radius 二十面体的半径,默认值为1
detail 默认值为0。将这个值设为一个大于0的数将会为它增加一些顶点,使其不再是一个二十面体。当这个值大于1的时候,实际上它将变成一个球体

2.绘制一个球

js 复制代码
const sphereGeometry = new THREE.IcosahedronGeometry( SPHERE_RADIUS, 5 );

示例detail值设置为5,绘制球体

六. Capsule 胶囊

1. 认识Capsule

Capsule(胶囊体)可以近似认为是一种3D几何体,具体来说就是上面一个半球、中间一个圆柱、下面一个半球拼接构成得胶囊形状几何体。
注意:Capsule并不会在场景中存在,他仅仅用于碰撞检测的一个数学对象。

2.创建胶囊几何体

js 复制代码
const playerCollider = new Capsule(start: Vector3, end: Vector3, radius: number);

参数说明:

参数名 说明
start 底部半球球心坐标
end 顶部半球球心坐标
radius 胶囊半径

3. 平移胶囊几何体

比如平移胶囊碰撞提,使底部半球位于y=0的平面以下。

sql 复制代码
capsule.translate(new THREE.Vector3(0, -R, 0));

七. Octree 八叉树

八叉树可以实现漫游的碰撞检测功能,比如遇到障碍物被挡住,比如爬坡和上楼梯。

网上关于该Octree八叉树的使用文章并不多,建议有空可以自己研究下源码!

1. 认识八叉树

以下是来自百度百科的定义:

八叉树(Octree)的定义是:若不为空树的话,树中任一节点的子节点恰好只会有八个,或零个,也就是子节点不会有0与8以外的数目。那么,这要用来做什么?想象一个立方体,我们最少可以切成多少个相同等分的小立方体?答案就是8个。再想象我们有一个房间,房间里某个角落藏着一枚金币,我们想很快的把金币找出来,聪明的你会怎么做?我们可以把房间当成一个立方体,先切成八个小立方体,然后排除掉没有放任何东西的小立方体,再把有可能藏金币的小立方体继续切八等份......如此下去,平均在Log8(房间内的所有物品数)的时间内就可找到金币。因此,八叉树就是用在3D空间中的场景管理,可以很快地知道物体在3D场景中的位置,或侦测与其它物体是否有碰撞以及是否在可视范围内。

2. 实现原理

(1)设置最大递归深度

(2)找出场景的最大尺寸,并以此尺寸建立第一个立方体

(3)依序将单位元元素丢入能被包含且没有子节点的立方体

(4)若没有达到最大递归深度,就进行细分八等份,再将该立方体所装的单位元元素全部分担到八个立方体

(5)若发现子立方体所分配到的单位元元素数量不为零且跟父立方体是一样的,则该子立方体停止细分,因为根据空间分割理论,细分的空间所得到的分配必定较少,若是一样数目,则再怎么切数目还是一样,会造成无穷切割的情形。

(6)重复3,直到达到最大递归深度。

3. 一些常用API介绍

名称 说明
fromGraphNode 生成二叉树,接收模型对象为参数
capsuleIntersect 捕获Capsule胶囊体与所构建了八叉树节点的场景是否进行了碰撞
sphereIntersect 捕获球体与所构建了八叉树节点的场景是否进行了碰撞
(1)fromGraphNode 生成八叉树
js 复制代码
// 实例化八叉树
const worldOctree = new Octree();
loader.load( 'collision-world.glb', ( gltf ) => {
    // 生成八叉树
     worldOctree.fromGraphNode( gltf.scene );
});

执行fromGraphNode()方法会对模型进行分割,分割为一个一个的小的长方体空间,构成一个八叉树。如下图所示

(2)capsuleIntersect 胶囊碰撞检测

capsuleIntersect方法来捕获Capsule胶囊体与所构建了八叉树节点的场景是否进行了碰撞,检测方式如下:

js 复制代码
const result = worldOctree.capsuleIntersect( playerCollider );

result为碰撞信息,他一共包含两个值: depth 和 normal.

属性 说明
depth 碰撞的深度,可以理解为物体和场景中相机的比例
normal 碰撞的法线向量,可以理解为碰撞的方向

下面可以用一张图直观理解碰撞结果

Capsule对象与场景物体碰撞后,将depthnormal法线向量相乘,得到一个新的数值。

这个数值就是我们需要将Capsule对象偏移的值,偏移该值后Capsule对象也就不再与场景物体相碰撞了。

js 复制代码
const result = worldOctree.capsuleIntersect( playerCollider );
if ( result ) {
     playerCollider.translate( result.normal.multiplyScalar( result.depth ) );
}
(3)sphereIntersect 球体碰撞检测

sphereIntersect方法来捕获球体与所构建了八叉树节点的场景是否进行了碰撞,检测方式如下:

js 复制代码
const result = worldOctree.sphereIntersect( sphere.collider );
// 发生碰撞
if ( result ) {
}

当碰撞结果result为true,则代表发生了碰撞。

4.OctreeHelper 可视化八叉树

可以使用以下代码,进行可视化八叉树,方便观察。

js 复制代码
const helper = new OctreeHelper( worldOctree );
scene.add( helper );

如果开启了可视化八叉树,会在场景中出现很多黄色网状辅助线。

八. 模型文件加载

1.GLTF格式介绍

.gltf 格式文件几乎可以包含所有的三维模型相关信息的数据,比如:模型层级关系、PBR材质、纹理贴图、骨骼动画、变形动画等。

2. GLTF格式信息

js 复制代码
{
  "asset": {
    "version": "2.0",
  },
...
// 模型材质信息
  "materials": [
    {
      "pbrMetallicRoughness": {//PBR材质
        "baseColorFactor": [1,1,0,1],
        "metallicFactor": 0.5,//金属度
        "roughnessFactor": 1//粗糙度
      }
    }
  ],
  // 网格模型数据
  "meshes": ...
  // 纹理贴图
  "images": [
        {
            // uri指向外部图像文件
            "uri": "贴图名称.png"//图像数据也可以直接存储在.gltf文件中
        }
   ],
     "buffers": [
    // 一个buffer对应一个二进制数据块,可能是顶点位置 、顶点索引等数据
    {
      "byteLength": 840,
     //这里面的顶点数据,也快成单独以.bin文件的形式存在   
      "uri": "data:application/octet-stream;base64,AAAAPwAAAD8AAAA/AAAAPwAAAD8AAAC/.......
    }
  ],
}

3. .bin文件

有些gltf文件会关联一个或多个 .bin文件, .bin文件以二进制形式存储了模型的顶点数据等信息。.bin 文件中的信息其实就是对应gltf文件中的 buffers 属性,buffers.bin中的模型数据,可以存储在.gltf文件中,也可以单独一个二进制.bin文件。

json 复制代码
"buffers": [
    {
        "byteLength": 102040,
        "uri": "文件名.bin"
    }
]

4. 二进制.glb

gltf格式文件不一定就是以扩展名.gltf结尾,.glb就是gltf格式的二进制文件。比如你可以把.gltf模型和贴图信息全部合成得到一个.glb文件中,.glb文件相对.gltf文件体积更小,网络传输自然更快。

以本demo为例,可以使用系统自带的3D查看软件,查看collision-world.glb文件

九. 一些非three.js知识

1.Element.requestPointerLock()

允许异步地请求将鼠标指针锁定在指定元素上,若想追踪请求成功还是失败,则需要在 Document 级别监听 pointerlockchangepointerlockerror 事件。

2.performance

Performance 接口可用于获取当前页面中与性能相关的信息。
Performance.now() : 返回一个表示从性能测量时刻开始经过的毫秒数 DOMHighResTimeStamp

因为示例中只使用了Performance.now(),所以着重介绍下这个API。如若相对 Performance 了解更多,可以自行查阅MDN:developer.mozilla.org/zh-CN/docs/...

十. 示例实现原理

1. 实现步骤

(1)常规步骤:声明场景(Scene)、相机(PerspectiveCamera)、渲染器(WebGLRenderer)

(2)GLTFLoader 加载场景模型collision-world.glb, 并把模型添加到场景中,根据模型生成八叉树。并使用traverse方法遍历模型,设置模型能生成和接收阴影。

js 复制代码
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 ) {
                // 沿着轴通过具有最高纹素密度的像素所采集的样本数。默认情况下,此值为1。与基本mipmap相比,值越高,结果越不模糊,代价是使用了更多纹理采样。
                child.material.map.anisotropy = 4;
            }
        }
    } );
} );

(3)场景精细化处理,包括添加:雾化(Fog)、半球光(HemisphereLight)、平行光(DirectionalLight)及阴影 。

半球光:模拟了一个柔和的环境光,由一个天空色和地面色组成

平行光: 从一个特定方向照射的光,通常用于模拟太阳光

js 复制代码
// fog: 这个类中的参数定义了线性雾。也就是说,雾的密度是随着距离线性增大的。
scene.fog = new THREE.Fog( 0x88ccee, 0, 50 );

// HemisphereLight: 半球光,光源直接放置于场景之上,光照颜色从天空光线颜色渐变到地面光线颜色。
// 半球光不能产生阴影
const fillLight1 = new THREE.HemisphereLight( 0x8dc1de, 0x00668d, 1.5 );
fillLight1.position.set( 2, 1, 1 );
scene.add( fillLight1 );

// DirectionalLight: 平行光
const directionalLight = new THREE.DirectionalLight( 0xffffff, 2.5 );
directionalLight.position.set( - 5, 25, - 1 );
// castShadow: 设置为true灯光将投射为阴影
directionalLight.castShadow = true;
// shadow.camera 设置阴影渲染范围
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;
// .shadow.mapSize阴影贴图尺寸属性
directionalLight.shadow.mapSize.width = 1024;
directionalLight.shadow.mapSize.height = 1024;
// shadow.radius 阴影半径 
directionalLight.shadow.radius = 4;
directionalLight.shadow.bias = - 0.00006;
scene.add( directionalLight );

(4) 创建八叉树用于碰撞检测

(5)创建玩家碰撞体(胶囊体),用于模拟玩家

(6)批量生成一百个能产生和接收阴影的球球,其中球体使用IcosahedronGeometry 二十面缓存几何体API来生成。

js 复制代码
// IcosahedronGeometry: 二十面缓冲几何体
const sphereGeometry = new THREE.IcosahedronGeometry( SPHERE_RADIUS, 5 );
// MeshLambertMaterial: 网格材质
const sphereMaterial = new THREE.MeshLambertMaterial( { color: 0xdede8d } );

const spheres = [];
let sphereIdx = 0;

// 批量生成一百个带阴影得球球
for ( let i = 0; i < NUM_SPHERES; i ++ ) {
    // 批量设置mesh都能产生阴影和接收阴影
    const sphere = new THREE.Mesh( sphereGeometry, sphereMaterial );
    sphere.castShadow = true; // 灯光投射阴影
    sphere.receiveShadow = true; // 接收阴影

    scene.add( sphere );

    spheres.push( {
            mesh: sphere,
            // Sphere: 球,由球心和半径定义
            collider: new THREE.Sphere( new THREE.Vector3( 0, - 100, 0 ), SPHERE_RADIUS ),
            velocity: new THREE.Vector3() // 速度
    } );

}

(7) 对鼠标键盘进行监听,鼠标点击扔球

(8) 模型加载完毕后,调用animate()方法。该方法包含一系列场景操作:controls( deltaTime )、updatePlayer( deltaTime )、updateSpheres( deltaTime )、teleportPlayerIfOob()。 后面将会分别对这几个方法进行讲解。

js 复制代码
function animate() {
    // clock.getDelta() 方法获得两帧得时间间隔
    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 ++ ) {

            // W S A D 四个方向键,控制发射方向
            controls( deltaTime );

            updatePlayer( deltaTime );

            updateSpheres( deltaTime );

            teleportPlayerIfOob();

    }

    renderer.render( scene, camera );
    stats.update();
    requestAnimationFrame( animate );
}

STEPS_PER_FRAME 值为5,控制每一帧走5步。

2. controls() 控制场景方向和跳跃动作

结合键盘 W S A D 四个方向键,控制小球 前、 后、 左、 右四个方向。 如果场景位于地面上,按下空格键进行跳跃。

注意: 根据不同的方向,相应去修改playerVelocity向量的值。playerVelocity 将和小球、场景相机的位置有关!!

js 复制代码
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;
       }
    }

}

// 获取向前的单位向量
function getForwardVector() {
    // getWorldDirection: 返回一个能够表示当前摄像机所正视的世界空间方向的Vctor3对象。(注意:摄像机俯视时,其Z轴坐标为负)
    camera.getWorldDirection( playerDirection );
    playerDirection.y = 0;
    playerDirection.normalize();

    return playerDirection;
}

3. 扔球 throwBall

  • 获取一个球体
  • 设置投球方向: 通过getWorldDirection方法获取相机的世界方向。将球体中心位置设置为玩家碰撞体end的位置,并移动一段时间(玩家碰撞体半径的1.5倍)
  • 计算发球力度(impulse): 力度基于鼠标按下时间 mouseTime 和当前时间 performance.now() 的差值来计算。时差越大,力度越大
  • 设置球体的速度和方向
  • 更新球体索引sphereIdx。索引值在0 -100之间循环
js 复制代码
// 扔球
function throwBall() {

    const sphere = spheres[ sphereIdx ];

    // getWorldDirection: 获取obj对象自身z轴正方向在世界坐标空间中的方向
    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 );
    // addScaledVector 两个参数相乘后与方法对象相加, 设置速度
    sphere.velocity.addScaledVector( playerVelocity, 2 );

    // idex 从0-100循环生成
    sphereIdx = ( sphereIdx + 1 ) % spheres.length;

}

4. updatePlayer()

  • 这个方法主要是更新玩家的位置和速度
  • 上一步control()方法里,通过监听键盘操作实时改变了 playerVelocity 的值,这里玩家和相机位置也是依据 playerVelocity 进行改变。
  • 使用addScaledVector给 playerVelocity 设置阻尼减速,逼真模拟真实运动效果。playerCollider.translate( deltaPosition ) 修改胶玩家位置, camera.position.copy( playerCollider.end ) 修改相机位置。
js 复制代码
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 );
}

updatePlayer()方法中调用了playerCollisions(),进行碰撞检测。

  • worldOctree.capsuleIntersect( playerCollider ) 使用八叉树对玩家和场景是否碰撞进行检测,碰撞结果会存储在result中。如果产生了碰撞,则result会包含两个属性:depth 和 normal, 分别表示交叉重合深度和深度对应的方向。
  • 发生碰撞,检测碰撞方向是否位于地面上,如果不是则进行阻尼减速
  • 发生碰撞后改变玩家位置
js 复制代码
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 ) );
    }

}

5. updateSpheres()

5.1 总体流程

这个方法主要是用于更新所有球的位置和速度,并处理他们与场景中其他物体的碰撞。

  • 阻尼减速,更新球心的位置
  • 检测小球和场景之间是否发生碰撞,使用八叉树的sphereIntersect方法进行检测。
  • 如果发生碰撞,更新球的运动速度和球心位置; 否则更新球速度在y轴的位置(因为物体在空中运动,会受向下的重力作用)
  • 统一修改球的速度,同样也要进行阻尼减速(毕竟物体在运动过程中会受到一些阻力影响)
  • 调用 playerSphereCollision( sphere ) 方法,处理玩家球体(胶囊体)和另一个球体的碰撞,并相应的处理他们的速度和位置。
  • 使用 spheresCollisions( ) 处理球体之间的碰撞
  • 更新球体位置 sphere.mesh.position.copy( sphere.collider.center )
js 复制代码
function updateSpheres( deltaTime ) {
    spheres.forEach( sphere => {
        // 阻尼减速,更新球心的位置
        sphere.collider.center.addScaledVector( sphere.velocity, deltaTime );
        // 检测场景是否和小球球发生碰撞
        const result = worldOctree.sphereIntersect( sphere.collider );

        // 如果发生碰撞,result为true
        if ( result ) {
            // 若碰撞,更新球运动速度和球心的位置
            sphere.velocity.addScaledVector( result.normal, - result.normal.dot( sphere.velocity ) * 1.5 );
            sphere.collider.center.add( result.normal.multiplyScalar( result.depth ) );
        } else {
            // 改变球体速度的y轴位置,因为求受重力作用,高度一直降低
            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 );
    }

}
5.2 playerSphereCollision 函数: 处理胶囊体和另一个球的碰撞
  • 计算胶囊体玩家的中心点
  • 获取球体中心
  • 计算碰撞半径: 是胶囊体和球体碰撞半径之和,以及他们的平方
  • 碰撞检测: 这里将胶囊体玩家近似看为三个点(顶部中心点,底部中心点,两点的终点)进行碰撞检测。对于每个点,计算到球体中心的距离平方,并检查这个距离是否小于两个碰撞半径之和的平方。
  • 处理碰撞: d2 < r2 为true代表发生了碰撞,则计算法线(normal),然后根据法线和两个物体的速度计算碰撞后的速度,调整球体的位置。
js 复制代码
// 球碰撞,改变球心位置
function playerSphereCollision( sphere ) {
    // 胶囊体中心点
    const center = vector1.addVectors( playerCollider.start, playerCollider.end ).multiplyScalar( 0.5 );
    // 球心
    const sphere_center = sphere.collider.center;
    // 计算碰撞半径 r
    const r = playerCollider.radius + sphere.collider.radius;
    const r2 = r * r;

    // approximation: player = 3 spheres

    for ( const point of [ playerCollider.start, playerCollider.end, center ] ) {
        // distanceToSquared: 两个向量的平方距离
        // d2 为球心到胶囊体的距离平方值
        const d2 = point.distanceToSquared( sphere_center );

        if ( d2 < r2 ) {
            // 胶囊体到球心的归一化向量,主要是表示方向
            const normal = vector1.subVectors( point, sphere_center ).normalize();
            // multiplyScalar 沿着视线方向移动
            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 );

        }

    }

}
5.3 spheresCollisions 函数: 这个函数主要是处理球体之间碰撞
  • 双层for循环,遍历球体
  • 计算球体之间的距离平方: distanceToSquared
  • 检测碰撞: 如果两个球体中心之间的距离他小于他们半径之和,则发生碰撞
  • 处理碰撞: 根据碰撞法线和两个球的速度来计算新的速度,并调整球体的位置
js 复制代码
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 );

            }
        }
    }
}

6. teleportPlayerIfOob()

如果相机位置(代表玩家)的位置在y轴的值小于等于-25,则将玩家的碰撞体(playerCollider)的位置和大小重置,并将相机位置更新为新得碰撞体位置,同时将相机得旋转重置。这实际上是将玩家"传送"回世界中安全位置。

js 复制代码
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 );
    }
}

十一、完整代码

js 复制代码
<!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);
			// fog: 这个类中的参数定义了线性雾。也就是说,雾的密度是随着距离线性增大的。
			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";

			// HemisphereLight: 半球光,光源直接放置于场景之上,光照颜色从天空光线颜色渐变到地面光线颜色。半球光不能产生阴影
			const fillLight1 = new THREE.HemisphereLight(0x8dc1de, 0x00668d, 1.5);
			fillLight1.position.set(2, 1, 1);
			scene.add(fillLight1);

			// DirectionalLight: 平行光
			const directionalLight = new THREE.DirectionalLight(0xffffff, 2.5);
			directionalLight.position.set(-5, 25, -1);
			// castShadow: 设置为true灯光将投射为阴影
			directionalLight.castShadow = true;
			// shadow.camera 设置阴影渲染范围
			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;
			// .shadow.mapSize阴影贴图尺寸属性
			directionalLight.shadow.mapSize.width = 1024;
			directionalLight.shadow.mapSize.height = 1024;
			// shadow.radius 阴影半径
			directionalLight.shadow.radius = 4;
			directionalLight.shadow.bias = -0.00006;
			scene.add(directionalLight);

			const container = document.getElementById("container");

			// antialias: 是否执行抗锯齿
			const renderer = new THREE.WebGLRenderer({ antialias: true });
			// setPixelRatio: 设备像素比
			// window.devicePixelRatio: 返回当前显示设备的物理像素分辨率和css像素分辨率之比
			renderer.setPixelRatio(window.devicePixelRatio);
			renderer.setSize(window.innerWidth, window.innerHeight);
			// shadowMap: 如果使用,它包含阴影贴图的引用
			renderer.shadowMap.enabled = true;
			// THREE.VSMShadowMap: 使用Variance Shadow Map (VSM) 算法来过滤阴影映射。当使用 VSMShadowMap时, 所有阴影接收者也将会投射阴影。
			// 注意几种阴影类型的区别
			renderer.shadowMap.type = THREE.VSMShadowMap;
			// toneMapping: 这个属性用于在普通计算机显示器或者移动设备屏幕等低动态范围介质上,模拟、逼近高动态范围(HDR)效果
			// 示例效果查看: http://www.yanhuangxueyuan.com/threejs/examples/#webgl_tonemapping
			renderer.toneMapping = THREE.ACESFilmicToneMapping;
			container.appendChild(renderer.domElement);

			// Stats: 查看渲染帧率
			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;

			// IcosahedronGeometry: 二十面缓冲几何体
			const sphereGeometry = new THREE.IcosahedronGeometry(SPHERE_RADIUS, 5);
			// MeshLambertMaterial: 网格材质
			const sphereMaterial = new THREE.MeshLambertMaterial({ color: 0xdede8d });

			const spheres = [];
			let sphereIdx = 0;

			// 批量生成一百个带阴影得球球
			for (let i = 0; i < NUM_SPHERES; i++) {
				// 批量设置mesh都能产生阴影和接收阴影
				const sphere = new THREE.Mesh(sphereGeometry, sphereMaterial);
				sphere.castShadow = true; // 灯光投射阴影
				sphere.receiveShadow = true; // 接收阴影

				scene.add(sphere);

				spheres.push({
					mesh: sphere,
					// Sphere: 球,由球心和半径定义
					collider: new THREE.Sphere(
						new THREE.Vector3(0, -100, 0),
						SPHERE_RADIUS
					),
					velocity: new THREE.Vector3(), // 速度
				});
			}

			// Octree: 八叉树拓展库
			const worldOctree = new Octree();

			// Capsule: 表示胶囊形状的几何体,具体说就是上面一个半球、中间一个圆柱、下面一个半球拼接构成的胶囊形状几何体
			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", () => {
				// requestPointerLock: 允许异步得请求将鼠标指针锁定在指定元素上
				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];

				// getWorldDirection: 获取obj对象自身z轴正方向在世界坐标空间中的方向
				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));

				// multiplyScalar:表示向量x y z 三个分量和参数分别相乘
				sphere.velocity.copy(playerDirection).multiplyScalar(impulse);
				// addScaledVector 两个参数相乘后与方法对象相加, 设置速度
				sphere.velocity.addScaledVector(playerVelocity, 2);

				// idex 从0-100循环生成
				sphereIdx = (sphereIdx + 1) % spheres.length;
			}

			function playerCollisions() {
				// 碰撞检测: 几何体交叉计算
				// .depth: 交叉重合得深度
				// .normal: 深度对应的方向
				const result = worldOctree.capsuleIntersect(playerCollider);

				playerOnFloor = false;

				if (result) {
					playerOnFloor = result.normal.y > 0;

					if (!playerOnFloor) {
						console.log("前", playerVelocity);
						playerVelocity.addScaledVector(
							result.normal,
							-result.normal.dot(playerVelocity)
						);
						console.log("后", 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;
				// r: 胶囊体r + 球体 r
				const r = playerCollider.radius + sphere.collider.radius;
				const r2 = r * r;

				// approximation: player = 3 spheres

				for (const point of [
					playerCollider.start,
					playerCollider.end,
					center,
				]) {
					// distanceToSquared: 两个向量的平方距离
					// d2 为球心到胶囊体的距离平方值
					const d2 = point.distanceToSquared(sphere_center);

					if (d2 < r2) {
						// 胶囊体到球心的归一化向量,主要是表示方向
						const normal = vector1.subVectors(point, sphere_center).normalize();
						// multiplyScalar 沿着视线方向移动
						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);

					// 如果发生碰撞,result为true
					if (result) {
						// 若碰撞,更新球运动速度和球心的位置
						sphere.velocity.addScaledVector(
							result.normal,
							-result.normal.dot(sphere.velocity) * 1.5
						);
						sphere.collider.center.add(
							result.normal.multiplyScalar(result.depth)
						);
					} else {
						// 改变球体速度的y轴位置,因为求受重力作用,高度一直降低
						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();
				// cross: 叉乘
				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) {
							// 沿着轴通过具有最高纹素密度的像素所采集的样本数。默认情况下,此值为1。与基本mipmap相比,值越高,结果越不模糊,代价是使用了更多纹理采样。
							child.material.map.anisotropy = 4;
						}
					}
				});

				// OctreeHelper: 可视化八叉树
				const helper = new OctreeHelper(worldOctree);
				helper.visible = false;
				scene.add(helper);

				// GUI控制是否限制可视化八叉树
				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() {
				// clock.getDelta() 方法获得两帧得时间间隔
				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++) {
					// W S A D 四个方向键,控制发射方向
					controls(deltaTime);

					updatePlayer(deltaTime);

					updateSpheres(deltaTime);

					teleportPlayerIfOob();
				}

				renderer.render(scene, camera);

				stats.update();

				requestAnimationFrame(animate);
			}
		</script>
	</body>
</html>
相关推荐
高山我梦口香糖6 分钟前
[react]searchParams转普通对象
开发语言·前端·javascript
m0_748235249 分钟前
前端实现获取后端返回的文件流并下载
前端·状态模式
m0_748240251 小时前
前端如何检测用户登录状态是否过期
前端
black^sugar1 小时前
纯前端实现更新检测
开发语言·前端·javascript
寻找沙漠的人2 小时前
前端知识补充—CSS
前端·css
GISer_Jing2 小时前
2025前端面试热门题目——计算机网络篇
前端·计算机网络·面试
m0_748245522 小时前
吉利前端、AI面试
前端·面试·职场和发展
理想不理想v2 小时前
webpack最基础的配置
前端·webpack·node.js
pubuzhixing2 小时前
开源白板新方案:Plait 同时支持 Angular 和 React 啦!
前端·开源·github
2401_857600952 小时前
SSM 与 Vue 共筑电脑测评系统:精准洞察电脑世界
前端·javascript·vue.js