【可视化学习】58-开放世界-源码篇
发表于:2024-01-07 |

前言

用这篇文章作为2024年第一篇文章,再合适不过了,内容略长,3D基础不好和没兴趣的同学可能会看不下去,本篇文章我将和大家一起学习使用SketchBook来快速开发自己的开放世界,这里主要讲解源码是干什么的,后续等我把建模学一下,再根据这个源码来做一个自己的开放世界,这里我就不多说了,就简单介绍一下源码内容,我也只是囫囵吞枣,大佬的代码的确需要好好观摩学习。

资源介绍

这是一个大佬开源的库,大家可以去github支持一下,用这个库可以更快更高效的完成我们自己的开放世界项目,如果大家不想去下载的话我也把这个文件放到了百度网盘上
链接:https://pan.baidu.com/s/1BHt72QV_r_913ctDHe6tjg?pwd=3q52
提取码:3q52

温馨提示

使用这个库的时候建议node版本不要太高,我16版本是正常的,18版本的时候就有问题了

项目地址

这里给出的是我改完的代码
在线体验地址:https://gujyang.gitee.io/open-world/(已失效)
代码地址:https://gitee.com/guJyang/open-world

目录结构简单介绍

这里就简单的和大家过一下,这个项目由webpack进行打包,目录下的核心结构是blend文件夹,这个文件夹放着我们用到的所有blend文件,通过webpack打包之后会变成glb,这样我们的网页就能识别,css文件夹下是样式,lib下面有cannon文件夹,里面都是物理世界的一些操作,shaders里面是着色器,里面有俩个默认的着色器,天空和水,utils文件夹下是一些库,比如dat.gui,这个大家都知道是用来操作gui控制面板设置的,还有detector.js是用来检测浏览器的,还有一些更多的文件目录,大家可以拉代码自己看看,这里就不一一介绍了
目录结构

源码介绍

目录结构大家过一下之后,我们就开始真正的内容了,开始源码模块的介绍

初始化开放世界

ts/world/World.ts

首先我们来看一下ts文件夹下的world文件夹下的World.ts文件,这个文件是我们的开放世界的入口文件,我们来看一下里面的代码,代码太多就不贴了,大家自己找一下

设置构造器

我们从构造器里面开始讲解,首先是scope用来存储this指向

1
const scope = this;

检测webgl是否支持

下面的内容是检测浏览器是否支持webgl,如果不支持的话就会弹出一个提示框,这里用到sweetalert2弹窗库,大家可以自行去了解一下,我在这里就不多阐述了

1
2
3
4
5
6
7
8
9
10
11
12
// WebGL not supported
if (!Detector.webgl)
{
Swal.fire({
icon: 'warning',
title: 'WebGL compatibility',
text: 'This browser doesn\'t seem to have the required WebGL capabilities. The application may not work correctly.',
footer: '<a href="https://get.webgl.org/" target="_blank">Click here for more information</a>',
showConfirmButton: false,
buttonsStyling: false
});
}

初始化渲染器

如果我们的浏览器是支持webgl的话,那么我们就会初始化渲染器,这部分代码就不多介绍了,大家都看到这里了,应该都很熟悉

1
2
3
4
5
6
7
this.renderer = new THREE.WebGLRenderer();
this.renderer.setPixelRatio(window.devicePixelRatio);
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
this.renderer.toneMappingExposure = 1.0;
this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;

初始化页面

然后接下来就是初始化我们的页面

1
this.generateHTML();

我们进入到generateHTML方法里,这里一开始引入了一些文字的字体样式,

1
2
3
4
// Fonts
$('head').append('<link href="https://fonts.googleapis.com/css2?family=Alfa+Slab+One&display=swap" rel="stylesheet">');
$('head').append('<link href="https://fonts.googleapis.com/css2?family=Solway:wght@400;500;700&display=swap" rel="stylesheet">');
$('head').append('<link href="https://fonts.googleapis.com/css2?family=Cutive+Mono&display=swap" rel="stylesheet">');

然后是我们的loading效果

1
2
3
4
5
6
7
8
9
10
11
12
13
// Loader
$(` <div id="loading-screen">
<div id="loading-screen-background"></div>
<h1 id="main-title" class="sb-font">Sketchbook 0.4</h1>
<div class="cubeWrap">
<div class="cube">
<div class="faces1"></div>
<div class="faces2"></div>
</div>
</div>
<div id="loading-text">Loading...</div>
</div>
`).appendTo('body');

我们先来看一下他的默认loading效果

我们将代码改成这样

1
2
3
4
5
6
7
8
9
10
11
12
$(`	<div id="loading-screen">
<div id="loading-screen-background"></div>
<h1 id="main-title" class="sb-font">我的世界</h1>
<div class="cubeWrap">
<div class="cube">
<div class="faces1"></div>
<div class="faces2"></div>
</div>
</div>
<div id="loading-text">加载中</div>
</div>
`).appendTo('body');

然后在css/modules/loadingScreen.css中我们可以自己加个背景图

1
2
3
4
#loading-screen-background{
background: url(/build/assets/imgs/bg.png) !important;
background-size: cover;
}

此时我们的loading就变成了这样

然后接下来那一段代码是页面左上角的github地址

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
(`	<div id="ui-container" style="display: none;">
<div class="github-corner">
<a href="https://github.com/swift502/Sketchbook" target="_blank" title="Fork me on GitHub">
<svg viewbox="0 0 100 100" fill="currentColor">
<title>Fork me on GitHub</title>
<path d="M0 0v100h100V0H0zm60 70.2h.2c1 2.7.3 4.7 0 5.2 1.4 1.4 2 3 2 5.2 0 7.4-4.4 9-8.7 9.5.7.7 1.3 2
1.3 3.7V99c0 .5 1.4 1 1.4 1H44s1.2-.5 1.2-1v-3.8c-3.5 1.4-5.2-.8-5.2-.8-1.5-2-3-2-3-2-2-.5-.2-1-.2-1
2-.7 3.5.8 3.5.8 2 1.7 4 1 5 .3.2-1.2.7-2 1.2-2.4-4.3-.4-8.8-2-8.8-9.4 0-2 .7-4 2-5.2-.2-.5-1-2.5.2-5
0 0 1.5-.6 5.2 1.8 1.5-.4 3.2-.6 4.8-.6 1.6 0 3.3.2 4.8.7 2.8-2 4.4-2 5-2z"></path>
</svg>
</a>
</div>
<div class="left-panel">
<div id="controls" class="panel-segment flex-bottom"></div>
</div>
</div>
`).appendTo('body');

我们做自己的东西,那就不需要了,当然你也可以加上,改成自己的github地址什么的,我这里就去掉了类名为github-corner的部分,类名为left-panel的部分是我们左下方的操作栏提示,暂时先不去掉了,去了有其他文件影响。
观察左边控制提示和logo

接下来一段就是把canvas添加进去了

1
2
document.body.appendChild(this.renderer.domElement);
this.renderer.domElement.id = 'canvas';

ok,这个方法看完了之后,让我们把视野重新返回上去

屏幕适配

下面这段代码做的就是屏幕适配,fxaaPass是抗锯齿,那一行代码是设置我们的宽高比例,scope.composer是合成渲染器的像素比例,其他的大家都认识,我就不多阐述了。

1
2
3
4
5
6
7
8
9
10
// Auto window resize
function onWindowResize(): void
{
scope.camera.aspect = window.innerWidth / window.innerHeight;
scope.camera.updateProjectionMatrix();
scope.renderer.setSize(window.innerWidth, window.innerHeight);
fxaaPass.uniforms['resolution'].value.set(1 / (window.innerWidth * pixelRatio), 1 / (window.innerHeight * pixelRatio));
scope.composer.setSize(window.innerWidth * pixelRatio, window.innerHeight * pixelRatio);
}
window.addEventListener('resize', onWindowResize, false);

创建场景和相机

下面这段代码大家也认识

1
2
3
// Three.js scene
this.graphicsWorld = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera(80, window.innerWidth / window.innerHeight, 0.1, 1010);

渲染通道和着色器通道

这里使用了new RenderPass生成渲染通道,这个是用来操作我们后期效果的,new ShaderPass生成着色器通道,这个着色器通道是用来做抗锯齿操作的

1
2
3
4
5
6
7
8
9
10
11
12
13
// Passes
let renderPass = new RenderPass( this.graphicsWorld, this.camera );
let fxaaPass = new ShaderPass( FXAAShader );

// FXAA
let pixelRatio = this.renderer.getPixelRatio();
fxaaPass.material['uniforms'].resolution.value.x = 1 / ( window.innerWidth * pixelRatio );
fxaaPass.material['uniforms'].resolution.value.y = 1 / ( window.innerHeight * pixelRatio );

// Composer
this.composer = new EffectComposer( this.renderer );
this.composer.addPass( renderPass );
this.composer.addPass( fxaaPass );

物理世界初始化

下面这段代码初始化了我们的物理世界

1
2
3
4
5
6
// Physics
this.physicsWorld = new CANNON.World();
this.physicsWorld.gravity.set(0, -9.81, 0);
this.physicsWorld.broadphase = new CANNON.SAPBroadphase(this.physicsWorld);
this.physicsWorld.solver.iterations = 10;
this.physicsWorld.allowSleep = true;

下面这段代码定义了我们物理世界的帧

1
2
3
4
5
6
7
this.parallelPairs = [];
// 每秒60帧
this.physicsFrameRate = 60;
// 每帧的时间
this.physicsFrameTime = 1 / this.physicsFrameRate;
// 物理时间预测
this.physicsMaxPrediction = this.physicsFrameRate;

时间操作

这一段代码我们用到了我们再说

1
2
3
4
5
6
// RenderLoop
this.clock = new THREE.Clock();
this.renderDelta = 0;
this.logicDelta = 0;
this.sinceLastFrame = 0;
this.justRendered = false;

FPS检测

这个就是检测我们的FPS,占用GPU大小什么的

1
2
// Stats (FPS, Frame time, Memory)
this.stats = Stats();

FPS检测

创建右侧GUI

具体的挖个坑,后面再说

1
2
// Create right panel GUI
this.createParamsGUI(scope);

初始化输入管理器,相机操作,天空

这里也先描述下,后面具体再说

1
2
3
4
// Initialization
this.inputManager = new InputManager(this, this.renderer.domElement);
this.cameraOperator = new CameraOperator(this, this.camera, this.params.Mouse_Sensitivity);
this.sky = new Sky(this);

加载完毕

这里就是加载完毕的情况,如果你没有场景就执行else那块的代码,创建个弹窗提示你这是个空的世界,我们这里可以把加载完毕之后的弹窗自己优化一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// Load scene if path is supplied
if (worldScenePath !== undefined)
{
let loadingManager = new LoadingManager(this);
loadingManager.onFinishedCallback = () =>
{
this.update(1, 1);
this.setTimeScale(1);

Swal.fire({
title: 'Welcome to Sketchbook!',
text: 'Feel free to explore the world and interact with available vehicles. There are also various scenarios ready to launch from the right panel.',
footer: '<a href="https://github.com/swift502/Sketchbook" target="_blank">GitHub page</a><a href="https://discord.gg/fGuEqCe" target="_blank">Discord server</a>',
confirmButtonText: 'Okay',
buttonsStyling: false,
onClose: () => {
UIManager.setUserInterfaceVisible(true);
}
});
};
loadingManager.loadGLTF(worldScenePath, (gltf) =>
{
this.loadScene(loadingManager, gltf);
}
);
}
else
{
UIManager.setUserInterfaceVisible(true);
UIManager.setLoadingScreenVisible(false);
Swal.fire({
icon: 'success',
title: 'Hello world!',
text: 'Empty Sketchbook world was succesfully initialized. Enjoy the blueness of the sky.',
buttonsStyling: false
});
}

我这里就改成了

1
2
3
4
5
6
7
8
9
Swal.fire({
title: '欢迎来到Codesigner的世界!',
text: '这里能让你免费体验全新的3D世界,你可以在这里自由探索,也可以在这里创造属于你的世界!',
confirmButtonText: '确认',
buttonsStyling: false,
onClose: () => {
UIManager.setUserInterfaceVisible(true);
}
});

切换确认框

好了,到这里初始化我们就差不多完成了

物理世界初始化和输入管理模块

物理世界初始化内容介绍

其中的含义我都打备注了,大家自己看

1
2
3
4
5
6
7
8
9
10
// Physics
this.physicsWorld = new CANNON.World();
// 设置重量加速度
this.physicsWorld.gravity.set(0, -9.81, 0);
// 提升碰撞性能,应用了扫描和剪枝的算法
this.physicsWorld.broadphase = new CANNON.SAPBroadphase(this.physicsWorld);
// 解算器的迭代次数,越多越精确,但是性能也会下降
this.physicsWorld.solver.iterations = 10;
// 当刚体移动非常缓慢时,可以将其休眠,以提高性能
this.physicsWorld.allowSleep = true;

输入管理

我们将视线移动到目录ts/core/InputManager.ts中,带大家简单过一下

参数解释

这里的world是从我们初始化传入的this,这里面有我们的物理世界和三维显示世界
domElement就是我们的画布canvas
pointerLock就是我们指针
isLocked就是是否锁定我们的鼠标
inputReceiver就是我们接受一些键盘输入的事件,比如w前进什么的

1
2
3
4
5
public world: World;
public domElement: any;
public pointerLock: any;
public isLocked: boolean;
public inputReceiver: IInputReceiver;

然后后面的那些都是一些事件的注册,通过监听来改变,然后通过下面这行代码,把这些事件绑定到注册表上,然后世界更新的时候把这些事件也跟着更新

1
world.registerUpdatable(this);

天空模块和物理世界

相机模块

接下来,我们先来看一下相机事件,这里我们传入了三个参数,世界,相机和鼠标灵敏度

1
this.cameraOperator = new CameraOperator(this, this.camera, this.params.Mouse_Sensitivity);

我们可以在页面上调节这个来改变鼠标灵敏度,这样我们切换视角的时候的速度也会随之变化
鼠标灵敏度
然后我们将视野移动到目录ts/core/CameraOperator.ts中

我们可以看到这个,这俩个参数是因为我们相机旋转是一个经纬度的球,我们可以调节这两个参数来控制相机的旋转
代码

1
2
3
this.onMouseDownPosition = new THREE.Vector2();
this.onMouseDownTheta = this.theta;
this.onMouseDownPhi = this.phi;

这三行代码就是我们鼠标的位置和鼠标水平旋转角度和鼠标垂直旋转角度,其他的部分我们就后面再说,我们将视线移动到world.ts中

天空模块

1
this.sky = new Sky(this);

我们看看这个Sky.ts文件

1
public sunPosition: THREE.Vector3 = new THREE.Vector3();

上面这个代码定义了太阳的位置,太阳的升起和降落和我们的相机是一样的,都是通过经纬度来控制的,我们同样有这个俩个参数thetaphi来进行位置的控制。
代码中的这个他创建了一个球作为天空

1
2
3
4
5
// Mesh
this.skyMesh = new THREE.Mesh(
new THREE.SphereBufferGeometry(1000, 24, 12),
this.skyMaterial
);

通过这些代码设置了环境的半球光

1
2
3
4
5
6
7
// Ambient light
this.hemiLight = new THREE.HemisphereLight( 0xffffff, 0xffffff, 1.0 );
this.refreshHemiIntensity();
this.hemiLight.color.setHSL( 0.59, 0.4, 0.6 );
this.hemiLight.groundColor.setHSL( 0.095, 0.2, 0.75 );
this.hemiLight.position.set( 0, 50, 0 );
this.world.graphicsWorld.add( this.hemiLight );

下面这段代码使用了级联阴影,因为我们的场景非常的大,近距离的阴影,比如我们的小人就应该比较清晰,而那种远一些的建筑物的阴影就没必要那么清晰了,splitsCallback就是我们自定义阴影级联的函数。

1
2
3
4
5
6
7
8
9
10
11
12
this.csm = new CSM({
fov: 80,
far: 250, // maxFar
lightIntensity: 2.5,
cascades: 3,
shadowMapSize: 2048,
camera: world.camera,
parent: world.graphicsWorld,
mode: 'custom',
customSplitsCallback: splitsCallback
});
this.csm.fade = true;

加载管理和UI界面管理

UI界面设置

我们来看一下ts/core/LoadingManager.ts文件

1
2
3
this.world.setTimeScale(0);
UIManager.setUserInterfaceVisible(false);
UIManager.setLoadingScreenVisible(true);

上面这段代码就是通过UIManager来控制我们UI界面内容的显示的ts/core/UIManager.ts文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
export class UIManager
{
public static setUserInterfaceVisible(value: boolean): void
{
document.getElementById('ui-container').style.display = value ? 'block' : 'none';
}

public static setLoadingScreenVisible(value: boolean): void
{
document.getElementById('loading-screen').style.display = value ? 'flex' : 'none';
}

public static setFPSVisible(value: boolean): void
{
document.getElementById('statsBox').style.display = value ? 'block' : 'none';
document.getElementById('dat-gui-container').style.top = value ? '48px' : '0px';
}
}

加载实现

我们可以看到World.ts中的这段代码,这里就是调用glft加载,等加载完成之后就会调用我们的回调函数
代码
我们进入loadingManager.ts文件来仔细看看
代码
我们可以明确看到他这里的实现是通过GLTFLoader来加载的,let trackerEntry = this.addLoadingEntry(path);这段代码添加了一个进度跟踪,等待gltf加载完调用回调函数,然后我们的世界World.ts就会执行后续函数。
我们可以将这个UI给关掉,此时我们就可以看到我们右侧的GUI和左下角的control提示就没了。
代码
代码

给场景加载模型和各类型碰撞体

了解模型加载原理代码

代码
blend
blend
通过这三张截图我们可以知道我们的模型是分为俩种类型的,一个是我们看的见的模型,里面有材质各种东西,一种是我们看不见的模型,是白膜,他用做碰撞检测。他的自定义属性刚好在代码中被用来生成点位做成碰撞检测的物体。

添加列车

ok,我们给我们的模型添加个列车的模型试一下效果
我将模型导出到了我们项目的目录下,把原来的目录命名为了world1,把我们新增列车的模型作为主世界模型导入
blend
文件

通过上面的视频我们可以清楚看到列车是加载进来了,但是我们是会穿模的,这是因为我们的列车并没有碰撞体

添加碰撞体

接下来我们来创建一下这个列车的碰撞体

  1. 首先我们先复制一份模型
    blend
  2. 移除碰撞体纹理
    我们要在物体模式下,点击右边菜单栏材质的小圆球,里面将材质一个个点击➖号移除,直到没有材质
    blend

删除成这样就大功告成了
blend

  1. 添加自定义属性
    我们在这个模型的自定义属性中新增一个,然后点击设置,类型修改成字符串类型,属性名是data,然后修改属性值为physics,同理添加一个type属性,值为trimesh
    blend
    blend
    blend

  2. 导出查看效果
    此时我们将我们的模型导出,然后查看效果

    这时候我们就会发现我们的列车就不会穿模了。
    注意:当然这样的做法一般在大型项目中是不太合格的,一般这种边界比较简单的模型,我们完全可以自己新建一个立方体,通过挤出等方式包裹住我们的物体来减少顶点的检测,从而提升我们的性能,这个我就不多阐述怎么做了,大家可以自己去尝试一下。

场景初始化与场景空对象解析

接下来我们来简单了解下World.ts中的这段代码的含义

1
2
3
4
if (child.userData.data === 'scenario')
{
this.scenarios.push(new Scenario(child, this));
}

这段代码的意思就是我们遇到data是scenario的时候,就会把这个场景添加进来
代码
我们仔细看看这个Scenario的类,里面的代码其实也很简单,就是初始化我们的场景类型,如果是default的就默认显示,如果是非隐藏的,即invisible就生成gui数据链接场景并在右边的gui那边显示,desc_title是有没有描述标题,desc_content是有没有描述内容,camera_angle则是相机角度,上面这些代码就相当于将我们模型中的自定义属性渲染到对应的场景对象中去
blend
代码
blend
上面这段代码就是将我们中的场景一次循环遍历进去,如果重生并且是car,airplane,heli这些交通工具就创建重生点,如果有这个type属性,就赋值进去,如果是驾驶员driver就赋值驾驶员,如果是ai操作的,就赋值ai节点
blend
blend
在blend中就是这样一个竞速赛的场景,里面前面的都是ai操作的,最后一辆车是我们玩家

1
2
3
4
5
else if (child.userData.type === 'player')
{
let sp = new CharacterSpawnPoint(child);
this.spawnPoints.push(sp);
}

这段代码的意思就是如果是我们玩家的话就将这个角色创建出来,然后将这个角色添加到我们的场景中去

然后就是这段代码

1
2
3
4
5
6
7
8
9
if (!this.invisible) this.createLaunchLink();
public createLaunchLink(): void
{
this.world.params[this.name] = () =>
{
this.world.launchScenario(this.id);
};
this.world.scenarioGUIFolder.add(this.world.params, this.name);
}

当invisible为false的时候就会创建一个场景的链接,这个链接就是我们右边的GUI,我们可以通过这个GUI来控制我们的场景
这样我们右边的GUI就会多出一个场景的链接,当我们点击的时候就会触发场景了

加载默认场景和重生交通工具

加载默认场景

代码
代码
上面一段代码就是加载默认场景的代码,通过launchScenario方法加载,这里面先调用this.clearEntities()清除了实体,
clearEntities方法中,我们通过remove方法移除了所有的characters角色,vehicles车辆。而这个remove方法中的代码是这样的

1
2
worldEntity.removeFromWorld(this);
this.unregisterUpdatable(worldEntity);

这俩行代码就是通过注册表来移除我们的实体
代码
我们仔细看这个removeFromWorld方法,这里通过了loadash的_判断了一下是不是有word.vehicles,如果没有就报错,有的话就清除,世界变成undefined,
接下来继续看launchScenario方法,移除完实体之后,
然后通过loadingManage加载管理器重新载入场景

重生交通工具

1
2
3
4
5
for (const scenario of this.scenarios) {
if (scenario.id === scenarioID || scenario.spawnAlways) {
scenario.launch(loadingManager, this);
}
}

然后这段代码,我们将当前场景和需要总是重生的交通工具进行加载
blend

我们的launch方法就会被调用,我们来看看这个方法
代码
这个方法里面将重生点添加,如果不是总是重生的话,比如我们的小人标语什么的,就只是一开始创建一次就好了,而不是每个场景都需要创建

场景渲染的执行流程

这里就是我们之前做的循环渲染函数
代码

不成比例时间步长

这里有个核心的逻辑:

1
2
3
4
5
6
7
// Getting timeStep
// 不成比例的时间步长=请求下一帧率时间+物理世界更新的代码运行时间+three代码渲染运行的时间
let unscaledTimeStep = (this.requestDelta + this.renderDelta + this.logicDelta) ;
// 可以通过调整参数来调整动画的快慢,时间步长=不成比例的时间步长*时间缩放
let timeStep = unscaledTimeStep * this.params.Time_Scale;
// 最小的时间步长为30帧率
timeStep = Math.min(timeStep, 1 / 30); // min 30 fps

一般我们渲染的逻辑就是一个两帧之间的时间差,在这种大场景中,需要考虑到代码执行的时间和物理世界变化的时间。

物理世界更新

1
2
3
4
5
6
// Logic
world.update(timeStep, unscaledTimeStep);

// Measuring logic time
// 物理更新的代码运行时间
this.logicDelta = this.clock.getDelta();

默认帧率设置

1
2
3
4
5
// Frame limiting
// 设置默认帧率
let interval = 1 / 60;
this.sinceLastFrame += this.requestDelta + this.renderDelta + this.logicDelta;
this.sinceLastFrame %= interval;

性能监控

1
2
3
4
// Stats end
// 是否开启性能监控
this.stats.end();
this.stats.begin();

后期合成

1
2
3
4
// Actual rendering with a FXAA ON/OFF switch
// 是否开启后期合成fxaa抗锯齿
if (this.params.FXAA) this.composer.render();
else this.renderer.render(this.graphicsWorld, this.camera);

渲染代码时间

1
2
3
// Measuring render time
// 渲染的代码运行时间
this.renderDelta = this.clock.getDelta();

更新世界的执行流程

ok,接下来我们看一下这段代码
代码
首先这个物理世界的更新结合上面的代码我们可以得到传入的两个参数分别为每一帧的步长和结合时间因子之后的步长

更新物理世界

1
2
// 更新物理世界
this.updatePhysics(timeStep);
updatePhysics方法

我们接下来详细看一下updatePhysics方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Step the physics world
// 设置物理世界的步长
this.physicsWorld.step(this.physicsFrameTime, timeStep);

// 角色循环判断
this.characters.forEach((char) => {
// 判断是否超出了边界,比如掉到海里面了
if (this.isOutOfBounds(char.characterCapsule.body.position))
{
// 超出边界就重生
this.outOfBoundsRespawn(char.characterCapsule.body);
}
});

// 车辆循环判断
this.vehicles.forEach((vehicle) => {
// 判断是否超出了边界,比如掉到海里面了
if (this.isOutOfBounds(vehicle.rayCastVehicle.chassisBody.position))
{
// 超出边界就重生
let worldPos = new THREE.Vector3();
vehicle.spawnPoint.getWorldPosition(worldPos);
worldPos.y += 1;
this.outOfBoundsRespawn(vehicle.rayCastVehicle.chassisBody, Utils.cannonVector(worldPos));
}
});
超出边界判断与重生

代码
isOutOfBounds方法就是判断是否超出边界
其中inside是判断是否在边界内,belowSeaLevel是判断是否在海平面下了
outOfBoundsRespawn方法就是重生
核心的逻辑将body的位置进行设置就行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let newPos = position || new CANNON.Vec3(0, 16, 0);
let newQuat = new CANNON.Quaternion(0, 0, 0, 1);
// 重置位置
body.position.copy(newPos);
// 重置插值位置
body.interpolatedPosition.copy(newPos);
// 重置旋转
body.quaternion.copy(newQuat);
// 重置插值旋转
body.interpolatedQuaternion.copy(newQuat);
// 重置速度
body.velocity.setZero();
// 重置角速度
body.angularVelocity.setZero();

效果差不多就是这样

更新物理注册表

1
2
3
4
// 更新注册表中的所有对象
this.updatables.forEach((entity) => {
entity.update(timeStep, unscaledTimeStep);
});

插值函数

这里通过这个函数让我们的时间因子虽然是一次性变化的,使得页面的动画是慢慢逼近目标值的,而不是一次性变化的

1
2
3
// Lerp time scale
// 插值函数,随着时间的比例变化,不断逼近目标值
this.params.Time_Scale = THREE.MathUtils.lerp(this.params.Time_Scale, this.timeScaleTarget, 0.2);

差不多效果就是如下

物理碰撞体的调试

1
2
3
// Physics debug
// 物理碰撞体的调试
if (this.params.Debug_Physics) this.cannonDebugRenderer.update();

开启之后我们可以看到物体的碰撞的线框
效果图

角色初始化

代码
在这个文件中,我们进行了角色的初始化,首先是一串的public,这些是属性的定义,我们不关注,直接从构造函数开始说起

重生点文件

而我们这个构造器调用的地方是在这个重生点的文件中创建的
代码
这里我们也能看出来,我们的人物模型就是从这里导入的,后续如果要换成自己的人物模型也可以改成咱们自己的

构造器代码

ok,我们接着讲构造器中的内容

读取角色数据
1
2
// 读取角色数据
this.readCharacterData(gltf);
根据模型动画数据设置动画
1
2
// 根据模型动画数据设置动画
this.setAnimations(gltf.animations);
创建一个组方便管理和控制角色
1
2
3
4
// 创建一个组方便管理和控制角色
// The visuals group is centered for easy character tilting
this.tiltContainer = new THREE.Group();
this.add(this.tiltContainer);
嵌套一层模型容器
1
2
3
4
5
6
// 嵌套一层模型容器,更好的控制动画和角色的行为
// Model container is used to reliably ground the character, as animation can alter the position of the model itself
this.modelContainer = new THREE.Group();
this.modelContainer.position.y = -0.57;
this.tiltContainer.add(this.modelContainer);
this.modelContainer.add(gltf.scene);
创建一个动画混合器
1
2
// 创建一个动画混合器
this.mixer = new THREE.AnimationMixer(gltf.scene);
模拟器
1
2
3
4
// 速度模拟器
this.velocitySimulator = new VectorSpringSimulator(60, this.defaultVelocitySimulatorMass, this.defaultVelocitySimulatorDamping);
// 旋转模拟器
this.rotationSimulator = new RelativeSpringSimulator(60, this.defaultRotationSimulatorMass, this.defaultRotationSimulatorDamping);
视图向量
1
2
// 视图向量
this.viewVector = new THREE.Vector3();
动作和值绑定
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Actions
this.actions = {
'up': new KeyBinding('KeyW'),
'down': new KeyBinding('KeyS'),
'left': new KeyBinding('KeyA'),
'right': new KeyBinding('KeyD'),
'run': new KeyBinding('ShiftLeft'),
'jump': new KeyBinding('Space'),
'use': new KeyBinding('KeyE'),
'enter': new KeyBinding('KeyF'),
'enter_passenger': new KeyBinding('KeyG'),
'seat_switch': new KeyBinding('KeyX'),
'primary': new KeyBinding('Mouse0'),
'secondary': new KeyBinding('Mouse1'),
};
创建物理碰撞的角色胶囊
1
2
3
4
5
6
7
8
9
10
11
// Physics
// Player Capsule
// 创建物理碰撞的角色胶囊
this.characterCapsule = new CapsuleCollider({
mass: 1,
position: new CANNON.Vec3(),
height: 0.5,
radius: 0.25,
segments: 8,
friction: 0.0
});

我们简单看一下这个创建角色胶囊的代码,其实也没啥好讲的,和我之前在cannon的介绍使用中都讲过这部分内容
代码
就是创建刚体,设置一些参数,比如friction摩擦,mass质量等参数

设置匹配能够碰撞的碰撞组
1
2
3
4
5
// 设置匹配能够碰撞的碰撞组
this.characterCapsule.body.shapes.forEach((shape) => {
// tslint:disable-next-line: no-bitwise
shape.collisionFilterMask = ~CollisionGroups.TrimeshColliders;
});
允许休眠
1
2
// 允许休眠
this.characterCapsule.body.allowSleep = false;
设置碰撞组
1
2
3
// 设置碰撞组
// Move character to different collision group for raycasting
this.characterCapsule.body.collisionFilterGroup = 2;
固定碰撞体旋转
1
2
3
4
5
// 是否固定角色旋转(这里为了避免碰撞体旋转)
// Disable character rotation
this.characterCapsule.body.fixedRotation = true;
// 更新
this.characterCapsule.body.updateMassProperties();
盒子检测碰撞
1
2
3
4
5
6
7
8
// Ray cast debug
// 生成盒子检测碰撞,就是我们打开碰撞检测线框的红色方框
const boxGeo = new THREE.BoxGeometry(0.1, 0.1, 0.1);
const boxMat = new THREE.MeshLambertMaterial({
color: 0xff0000
});
this.raycastBox = new THREE.Mesh(boxGeo, boxMat);
this.raycastBox.visible = false;
碰撞回调
1
2
3
4
// Physics pre/post step callback bindings
// 物理碰撞执行之前和执行之后的回调函数
this.characterCapsule.body.preStep = (body: CANNON.Body) => { this.physicsPreStep(body, this); };
this.characterCapsule.body.postStep = (body: CANNON.Body) => { this.physicsPostStep(body, this); };

接下来我们看一下物理碰撞执行之前和执行之后的回调函数
代码
其中这个方法他定义了我们物体body向下投射的一个向量,start是物体body的位置,end是一段距离之后的点,这俩个点连成线中间相交的第一个刚体就是我们那个小红立方体的位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public feetRaycast(): void
{
// Player ray casting
// Create ray
let body = this.characterCapsule.body;
const start = new CANNON.Vec3(body.position.x, body.position.y, body.position.z);
const end = new CANNON.Vec3(body.position.x, body.position.y - this.rayCastLength - this.raySafeOffset, body.position.z);
// Raycast options
const rayCastOptions = {
collisionFilterMask: CollisionGroups.Default,
skipBackfaces: true /* ignore back faces 忽视反面*/
};
// Cast the ray
// 在物理世界中找到与射线相交的第一个刚体
this.rayHasHit = this.world.physicsWorld.raycastClosest(start, end, rayCastOptions, this.rayResult);
}

而下面这段代码的意思是判断射线有没有击中,如果有的话就固定在地面上,如果没有就设置偏移那么多的位置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Raycast debug
if (character.rayHasHit)
{
if (character.raycastBox.visible) {
character.raycastBox.position.x = character.rayResult.hitPointWorld.x;
character.raycastBox.position.y = character.rayResult.hitPointWorld.y;
character.raycastBox.position.z = character.rayResult.hitPointWorld.z;
}
}
else
{
if (character.raycastBox.visible) {
character.raycastBox.position.set(body.position.x, body.position.y - character.rayCastLength - character.raySafeOffset, body.position.z);
}
}

碰撞结束的代码我就直接贴了,上面有一些注释,大家可以自己看一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
public physicsPostStep(body: CANNON.Body, character: Character): void
{
// Get velocities
// 获取速度
let simulatedVelocity = new THREE.Vector3(body.velocity.x, body.velocity.y, body.velocity.z);

// Take local velocity
// 获取局部速度
let arcadeVelocity = new THREE.Vector3().copy(character.velocity).multiplyScalar(character.moveSpeed);
// Turn local into global
// 将局部速度转化为全局速度
arcadeVelocity = Utils.appplyVectorMatrixXZ(character.orientation, arcadeVelocity);

let newVelocity = new THREE.Vector3();

// Additive velocity mode
// 附加的速度模式
if (character.arcadeVelocityIsAdditive)
{
newVelocity.copy(simulatedVelocity);

let globalVelocityTarget = Utils.appplyVectorMatrixXZ(character.orientation, character.velocityTarget);
let add = new THREE.Vector3().copy(arcadeVelocity).multiply(character.arcadeVelocityInfluence);

if (Math.abs(simulatedVelocity.x) < Math.abs(globalVelocityTarget.x * character.moveSpeed) || Utils.haveDifferentSigns(simulatedVelocity.x, arcadeVelocity.x)) { newVelocity.x += add.x; }
if (Math.abs(simulatedVelocity.y) < Math.abs(globalVelocityTarget.y * character.moveSpeed) || Utils.haveDifferentSigns(simulatedVelocity.y, arcadeVelocity.y)) { newVelocity.y += add.y; }
if (Math.abs(simulatedVelocity.z) < Math.abs(globalVelocityTarget.z * character.moveSpeed) || Utils.haveDifferentSigns(simulatedVelocity.z, arcadeVelocity.z)) { newVelocity.z += add.z; }
}
else
{
newVelocity = new THREE.Vector3(
THREE.MathUtils.lerp(simulatedVelocity.x, arcadeVelocity.x, character.arcadeVelocityInfluence.x),
THREE.MathUtils.lerp(simulatedVelocity.y, arcadeVelocity.y, character.arcadeVelocityInfluence.y),
THREE.MathUtils.lerp(simulatedVelocity.z, arcadeVelocity.z, character.arcadeVelocityInfluence.z),
);
}

// If we're hitting the ground, stick to ground
// 如果我们碰到地面,就贴着地面移动
if (character.rayHasHit)
{
// Flatten velocity
newVelocity.y = 0;

// Move on top of moving objects
// 在移动对象的顶部移动
if (character.rayResult.body.mass > 0)
{
let pointVelocity = new CANNON.Vec3();
character.rayResult.body.getVelocityAtWorldPoint(character.rayResult.hitPointWorld, pointVelocity);
newVelocity.add(Utils.threeVector(pointVelocity));
}

// Measure the normal vector offset from direct "up" vector
// and transform it into a matrix
//测试法线矢量与直接向上矢量的偏移量,将其转化为矩阵(比如我们在斜坡上,就会有向下的一个重力加速度和得到一个向前的方向)
let up = new THREE.Vector3(0, 1, 0);
let normal = new THREE.Vector3(character.rayResult.hitNormalWorld.x, character.rayResult.hitNormalWorld.y, character.rayResult.hitNormalWorld.z);
let q = new THREE.Quaternion().setFromUnitVectors(up, normal);
let m = new THREE.Matrix4().makeRotationFromQuaternion(q);

// Rotate the velocity vector
// 旋转速度矢量
newVelocity.applyMatrix4(m);

// Compensate for gravity
// 补偿重力
// newVelocity.y -= body.world.physicsWorld.gravity.y / body.character.world.physicsFrameRate;

// Apply velocity
// 应用速度
body.velocity.x = newVelocity.x;
body.velocity.y = newVelocity.y;
body.velocity.z = newVelocity.z;
// Ground character
body.position.y = character.rayResult.hitPointWorld.y + character.rayCastLength + (newVelocity.y / character.world.physicsFrameRate);
}
else
{
// If we're in air
// 如果在天上,那我们主要应用的就是重力加速度
body.velocity.x = newVelocity.x;
body.velocity.y = newVelocity.y;
body.velocity.z = newVelocity.z;

// Save last in-air information
character.groundImpactData.velocity.x = body.velocity.x;
character.groundImpactData.velocity.y = body.velocity.y;
character.groundImpactData.velocity.z = body.velocity.z;
}

// Jumping
// 按下空格跳跃的时候
if (character.wantsToJump)
{
// If initJumpSpeed is set
// 如果设置了初始的跳跃速度
if (character.initJumpSpeed > -1)
{
// Flatten velocity
body.velocity.y = 0;
let speed = Math.max(character.velocitySimulator.position.length() * 4, character.initJumpSpeed);
body.velocity = Utils.cannonVector(character.orientation.clone().multiplyScalar(speed));
}
else {
// Moving objects compensation
// 移动对象补偿
let add = new CANNON.Vec3();
character.rayResult.body.getVelocityAtWorldPoint(character.rayResult.hitPointWorld, add);
body.velocity.vsub(add, body.velocity);
}

// Add positive vertical velocity
// 添加正的垂直速度
body.velocity.y += 4;
// Move above ground by 2x safe offset value
// 在地面上移动2倍的安全偏移值
body.position.y += character.raySafeOffset * 2;
// Reset flag
// 重置想要跳跃的标志
character.wantsToJump = false;
}
}
设置默认动作
1
2
3
// 设置待机动作
// States
this.setState(new Idle(this));

角色状态更新

这里我就贴个代码截图了,具体内容大家看注释
代码

输入管理与角色动作状态与动画

在重生点文件中,我们有一句代码是

1
player.takeControl();

这里调用的tokeControl就是我们接管了控制

1
2
3
4
5
6
7
8
9
10
11
12
13
// 设置角色接管控制
public takeControl(): void
{
if (this.world !== undefined)
{
// 设置输入接收者
this.world.inputManager.setInputReceiver(this);
}
else
{
console.warn('Attempting to take control of a character that doesn\'t belong to a world.');
}
}

然后它调用了核心库中的输入管理
代码
这里面他又调用了角色中的输入管理初始化,这里判断是否有我们需要控制的对象(比如车辆),如果有,就进行初始化
代码

1
2
3
4
5
6
7
// 相机角度
this.world.cameraOperator.setRadius(1.6, true);
// 相机跟随设为false
this.world.cameraOperator.followMode = false;
// this.world.dirLight.target = this;
// 展示控制,我们左下角的操作提示
this.displayControls();

这里我们简单修改下展示控制的代码,我们将他的提示文字改成我们自己的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public displayControls(): void
{
this.world.updateControls([
{
keys: ['W', 'A', 'S', 'D'],
desc: '移动'
},
{
keys: ['Shift'],
desc: '加速'
},
{
keys: ['Space'],
desc: '跳跃'
},
{
keys: ['F', 'or', 'G'],
desc: '进入车辆'
},
{
keys: ['Shift', '+', 'R'],
desc: '重生'
},
{
keys: ['Shift', '+', 'C'],
desc: '自由相机'
},
]);
}

这里我们可以清楚看到我们的按键提示已经变成我们自己的了
效果图
然后我们输入管理绑定了之后,就将那些按键,鼠标操作都给绑定了上去
代码
大家可以注意下,this.inputReceiver指代的其实就是我们的Character,他下面的方法都在Character.ts中
就比如控制按键操作的这部分
代码
这里的this.triggerAction,简单看下我的注释就行
代码
接下来我们来看一下角色状态
我们这个角色状态是通过setState进行赋值的,如果小伙伴有印象的话,我前面有一段代码讲解就有这个设置默认的动作状态this.setState(new Idle(this));
代码
这里我随便拿一个状态给大家看一下,这里我拿的是Idle状态
代码
这里的onInputChange就是不同的状态进行不同的设置

角色动画切换

首先我们可以看到这个模型添加了很多的动画,如果懂一些建模的话,大家可以自己去搞骨骼,搞动画进行配置
blend
我们先换成自己的模型,将这里的boxman换成自己的就行
代码
此时载入的模型为这个样子
效果
模型太大了我们就去设置下大小
代码
效果就如下了
效果
当然这样是不满足我们的效果的,我们需要根据自己模型的动画依次填入已有的动画,如果不符合就自己去搞个动画配置,这里需要一定的blend操作知识
就比如我这个模型,动作名称是这样的
blend
就比如我把待机动画换成我的
代码
此时的效果就好看多了
效果
又比如,我们将跑步的动画换成我们自己的
代码
此时的跑步的效果也非常不错了

其他动作我就不一一加给大家看了,大家可以自己去加一下,同样的,大家可以根据我前面说过的流程给按键加上什么动作都是可以的。

添加关卡触发点

我们还可以自定义一些触发点,比如我创建一个点位,这里可以选中我们的Spawn.024这个重生点,然后按下Shift+D进行复制,然后移动这个点位到我们想要的位置,然后设置我们这个点位为自定义的触发点,比如trigger,类型为lucky
blend
然后我们在场景渲染的时候加上我们自定义的代码,这里的代码是根据我们在blender中设置的自定义属性来的
代码
接下来我们根据点位把这个触发点模型添加到场景中去,这里需要注意的就一个点,就是这个模型的位置信息,因为我们是放在default下面的,所以需要加一层parent
代码
然后我们就可以看到我们的触发点了
效果
然后我加一点我喜欢的纹理材质
代码
此时的效果就是这样了
效果
然后我想要让这个触发点亮一点
代码
此时的效果就是这样了
效果
当然我们可以加上一些呼吸灯的动画的感觉,稍微修改下材质偏移的值就可以了

1
2
3
4
5
6
gsap.to(texture.offset,{
duration:1,
x:1,
repeat:-1,
yoyo:true
})

这个具体的效果大家可以自己看着加,我这里就抛砖引玉一下

触发机制与注册世界更新表

我们如果需要检测碰撞,就需要注册我们的世界更新表和更新我们的位置,最终实现代码如下,我们导入了更新注册表的接口,用我们的类去实现了这个接口,这个接口中,需要一个update方法要我们实现,我们就写一下,然后在update方法中,我们判断了一下,如果我们的角色在这个触发点的范围内(这里拿了距离1来做判断),就触发了这个方法(调用了弹窗)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
import { LoadingManager } from '../core/LoadingManager';
import * as THREE from 'three';
import {World} from '../world/World';
import gsap from 'gsap';
import { IUpdatable } from '../interfaces/IUpdatable';
import Swal from 'sweetalert2';

export class LuckyTrigger extends THREE.Object3D implements IUpdatable{
public updateOrder: number;
public world:World;
public isInner:boolean;
constructor(gltf:THREE.Object3D,world:World){
super();
this.name = 'LuckyTrigger';
this.world = world;
let loadingManager= new LoadingManager(world);
let object=gltf;
let worldPos=new THREE.Vector3();
object.position.add(object.parent.position);
object.getWorldPosition(worldPos);
this.position.set(worldPos.x,worldPos.y+0.5,worldPos.z);
let texture = new THREE.TextureLoader().load( 'build/assets/imgs/trigger1.png' );
// 旋转纹理
texture.rotation = -Math.PI/2;
loadingManager.loadGLTF("build/assets/trigger.glb",(gltf)=>{
// 设置基础材质
let material=new THREE.MeshBasicMaterial({
map:texture,
alphaMap:texture,
transparent:true,
side:THREE.DoubleSide,
opacity:1,
depthWrite:false,
blending:THREE.AdditiveBlending
})
gltf.scene.children[0].material=material;
this.add(gltf.scene)
})
world.graphicsWorld.add(this);
console.log("LuckyTrigger",world)
// 注册表
world.registerUpdatable(this)
gsap.to(texture.offset,{
duration:1,
x:1,
repeat:-1,
yoyo:true
})
this.isInner = false;
}
update(timestep: number, unscaledTimeStep: number): void {
let character = this.world.characters[0]
if(character){
let length = character.position.distanceTo(this.position)
if(length<1&& this.isInner==false){
this.isInner= true;
this.enterHandler()
}
if(length>1&& this.isInner==true){
this.isInner= false;
}
}

}
enterHandler(){
Swal.fire({
title: '恭喜你中奖了',
text: '您获得一等奖IPHONE14PRO',
confirmButtonText: '确定',
buttonsStyling: false,
onClose: () => {

}
});
}
}

人物进入车辆并进入

这里简单讲解下人物进入车辆并进入的这个逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
// 发现交通工具并进入
public findVehicleToEnter(wantsToDrive: boolean): void
{
// reusable world position variable
let worldPos = new THREE.Vector3();

// Find best vehicle
// 找到最近的车辆对象
let vehicleFinder = new ClosestObjectFinder<Vehicle>(this.position, 10);
// 循环判断是否是最近的车辆
this.world.vehicles.forEach((vehicle) =>
{
vehicleFinder.consider(vehicle, vehicle.position);
});

// 当找到最近的车辆
if (vehicleFinder.closestObject !== undefined)
{
let vehicle = vehicleFinder.closestObject;
// 进入交通工具
let vehicleEntryInstance = new VehicleEntryInstance(this);
vehicleEntryInstance.wantsToDrive = wantsToDrive;

// Find best seat
let seatFinder = new ClosestObjectFinder<VehicleSeat>(this.position);
for (const seat of vehicle.seats)
{
if (wantsToDrive)
{
// Consider driver seats
// 考虑驾驶员作为
if (seat.type === SeatType.Driver)
{
seat.seatPointObject.getWorldPosition(worldPos);
seatFinder.consider(seat, worldPos);
}
// Consider passenger seats connected to driver seats
// 考虑与驾驶员座位相连的乘客座位
else if (seat.type === SeatType.Passenger)
{
for (const connSeat of seat.connectedSeats)
{
if (connSeat.type === SeatType.Driver)
{
seat.seatPointObject.getWorldPosition(worldPos);
seatFinder.consider(seat, worldPos);
break;
}
}
}
}
else
{
// Consider passenger seats
// 考虑乘客座位
if (seat.type === SeatType.Passenger)
{
seat.seatPointObject.getWorldPosition(worldPos);
seatFinder.consider(seat, worldPos);
}
}
}

// 寻找最近的座位
if (seatFinder.closestObject !== undefined)
{
let targetSeat = seatFinder.closestObject;
vehicleEntryInstance.targetSeat = targetSeat;

let entryPointFinder = new ClosestObjectFinder<Object3D>(this.position);

for (const point of targetSeat.entryPoints) {
point.getWorldPosition(worldPos);
entryPointFinder.consider(point, worldPos);
}

if (entryPointFinder.closestObject !== undefined)
{
vehicleEntryInstance.entryPoint = entryPointFinder.closestObject;
this.triggerAction('up', true);
this.vehicleEntryInstance = vehicleEntryInstance;
}
}
}
}

public enterVehicle(seat: VehicleSeat, entryPoint: THREE.Object3D): void
{
// 重置控制器
this.resetControls();
// 当门还没打开的时候
if (seat.door?.rotation < 0.5)
{
// 打开门
this.setState(new OpenVehicleDoor(this, seat, entryPoint));
}
else
{
// 进入车辆
this.setState(new EnteringVehicle(this, seat, entryPoint));
}
}

AI操控比赛竞速

这里的代码主要在这里
代码
这里的代码具体逻辑我也不是很懂,大概意思好像就是提前规划了一些点,然后根据速度和夹角进行速度变化。后续我自己搞的话我觉得我可以使用yuka试一下重写

封装移动端操作

World.ts中的修改

  1. 定义是否是移动端方法
    在world文件中,添加上判断是否是移动端的代码,目前是这样就好了,以后鸿蒙的适配不知道是不是要额外搞就不清楚了

    1
    2
    3
    public isMobile(): boolean{
    return /Mobi|Android|iPhone/i.test(navigator.userAgent);
    }
  2. 初始化赋值
    代码

  3. 根据是否是移动端显示UIManager
    代码

Character.ts中的修改

  1. 相机角度抬高
    代码
  2. 角色移动方法
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    inputReceiverMove(event,vector): void{
    this.targetPosition.copy(vector);
    this.targetDirection = this.targetPosition.sub(this.position);
    this.targetDirection.y = 0;
    this.targetDirection.normalize();

    // 设置往前
    this.triggerAction('up', true);
    this.setOrientation(this.targetDirection,false);
    }

HTML修改

  1. 支持移动端适配
    代码

LoadingManager.ts中的修改

  1. 初始化GUI去除
    代码

Scenario.ts中的修改

  1. 设置相机角度
    代码

IInputReceiver.ts中的修改

  1. 定义类型
    代码

InputManager.ts中的修改

  1. 添加touch事件,单击前进,双击停下来
    代码
  2. 与之对应的定义
    代码

Walk.ts中的修改

  1. 移动时视角切换修改
    代码

这样修改完之后,我们就可以在移动端进行操作了

模型优化

我这里将世界的建模美化了一些,但是碰撞体很多还没做,因为比较麻烦,后续有空了再继续搞

结语

这篇文章花了我很长时间才搞出来,大家如果觉得对自己有帮助的话麻烦去gitee上帮我点个star,谢谢!!!
后续3D的内容会更新比较慢,要过年了,公司业务比较繁忙,然后过年回家大概率也不会学习,再加上接下来一段时间,我将重心会放到我去年买的一个低代码的课程上面去。

上一篇:
图片复制与编辑
下一篇:
JSON.parse使用中我遇到的bug