前言

在 3D 游戏中,都会有一个主人公。我们可以通过点击游戏中的其他位置,使游戏主人公向点击处移动。

那当我们想要实现一个“点击地面,人物移动到点击处”的功能,需要什么前置条件,并且具体怎么实现呢?本文带大家一步步实现人物行走移动,同时进行状态改变的功能。

一、骨骼动画

骨骼动画(Skeleton animation 又称骨架动画,是一种计算机动画技术,它将三维模型分为两部分:用于绘制模型的蒙皮(Skin),以及用于控制动作的骨架。

一般在 3D 游戏中的主人公,它的跑步、走路、站立的动作,都是模型文件的自带骨骼动画。

骨骼动画权重

改变骨骼动画的权重,可以使得动画间的过渡更为自然。比如体测时,当你到达终点后,会逐渐减慢速度,跑步动作的幅度越来越小,然后变成走路,最后停止。

让我们看看一个俩动作权重渐变的例子:

这个例子中,从休闲变到走路,休闲动画的权重从1到0递减,同时走路动画的权重从0到1递增。可以的点击 这个网站中 > Crossfading > from idle to walk 体验一下。

在本次 3D 沙盒游戏中,人物状态改变,主要是鼠标点击地面后,人物从休闲状态转为跑步状态,当人物到达目的地后,又变为休闲状态。我们先来看看这些状态改变是如何实现的。

首先,我们需要设计师提供一个拥有骨骼动画的模型,它有两个骨骼动画,一个为休闲(idle)状态,一个为跑步(run)状态。

1.1 思路

1.2 动画初始化

先让我们将骨骼动画、动画名称、权重放到一个对象中存储起来,

idleAnimConfig = {
name: string;
anim: AnimationGroup;
weight: number;
}

那么如何判断是否正在行走呢?就需要一个当前动画的 flag,初始化时将 idle 设为当前动画

currentAnimConfig = idleAnimConfig

1.3 动画权重改变

如图,我们在人物状态改变时,需要将当前状态的动画权重递增,另一状态的动画权重递减(注意,权重值需要限制在[0, 1])。让我们看下伪代码,假设 deltaWeight 为正数

changeAnimWeight() {
// 当前动画 -> 递增
if (currentAnimConfig) {
setAnimationWeight(currentAnimConfig, deltaWeight)
}
// 其他动画 -> 递减,如站立动作切换到走路
if (currentAnimConfig !== idleAnimConfig) {
setAnimationWeight(idleAnimConfig, -deltaWeight)
}
// 其他动画 -> 递减,如走路动作切换到站立
if (currentAnimConfig !== runAnimConfig) {
setAnimationWeight(runAnimConfig, -deltaWeight)
}
}

然后在 render 的时候,进行状态切换

onRender() {
if (准备到达目的地) {
setCurAnimation(runAnimConfig)
} else {
setCurAnimation(idleAnimConfig)
}
changeAnimWeight()
}

1.4 缺少动画

如果 animationGroup 里只有一个 run 动画怎么办呢?

答案还是一样的,只要将 idle 动画的骨骼动画设为 null 即可,像这样:

idleAnimConfig = {
name: string;
anim: null;
weight: number;
}

这么做即使后来更换了具有两个动画的人物模型,也能复用。

动画状态切换实现效果

二、行走移动

当我们平常写动画时,会用到 rAF 并递归调用渲染函数,实现一个逐帧渲染动画。当人物行走在平地上时,也可以利用逐帧移动,来实现一个位移的动画。例如 Babylon 已经封装好了render 的事件 API,只要我们将渲染动画绑定 render 事件,就可以使用了。

让我们看看具体思路:

2.1 移动

由上面的思路可以看出,我们移动的时候需要用到几个变量:

  • 距终点的距离(distance)
  • 移动的方向(direction)

那么就需要在点击的时候,获取到这些变量。distance 可以利用矩阵对应坐标相加减计算,direction 就是目标位置减初始位置的法向量

directToPath() {
// 将人物的位置设为初始位置
initVec = this.player.position
// 计算初始位置与终点的距离
distance = Distance(targetVec, initVec)
// 将终点位置与初始位置相减
targetVec = targetVec.subtract(initVec)
// 使用法向量计算出与终点的朝向
direction = Normalize(targetVec)
player.lookAt(targetVec)
}
onClick() {
// ...
directToPath()
}

在 render 的时候进行位移

onRender() {
if (distance > READY_ARRIVE) {
distance -= SPEED
// 人物朝 direction 方向移动 SPEED 距离
player.translate(direction, SPEED, Space.WORLD)
}
}

位移实现效果

2.2 结合动画

当我们的移动结合模型的骨骼动画

让我们看看伪代码:

onRender() {
if (distance > READY_ARRIVE) {
distance -= SPEED
// 人物朝 direction 方向移动 SPEED 距离
player.translate(direction, SPEED, Space.WORLD)
setCurAnimation(runAnimConfig)
} else {
setCurAnimation(idleAnimConfig)
}
changeAnimWeight()
}

位移及状态变化实现效果

三、人物避障

3.1 思路

人物行走避障,实际上就是从起点到终点,在这之中添加了中间点。如图

所以我们只要记录下当前起点到终点这个路径数组,每次都朝数组的第N个点行走,就能做到转向。下面我们来根据思路及伪代码进行步骤细化。

(1) 记录路径和初始化当前的路径索引

path = getPath(targetVec)
prePathIdx = 0

(2) 当到达当前中间点时,切换到下一个中间点。当走到最后一个,则停止

onRender() {
if (distance > READY_ARRIVE) {
// ...移动及动画权重切换...
} else {
switchPath()
// ...
}
// ...
}
switchPath() {
prePathIdx += 1
directToPath()
}
directToPath() {
const curPath = path[prePathIdx]
if (!curPath) return
// ...人物移动及转向...
}

3.2 接入实际避障算法

3.1 得知,人物的行走移动要接入避障算法,需要利用到该算法提供的路径规划数组。实际应用中,我们只需要把,伪代码里的getPath()方法,换成算法计算道路的方法即可。

3.2.1 RecastJSPlugin

下面我们使用 Babylon 自带的 Recast 插件 ,来具体说明一下如何接入避障算法。

方法 1

在 recast 中,可以通过 computePath 获取路径:

const closestPoint = this.navigationPlugin.getClosestPoint(pickedPoint)
const path = this.navigationPlugin.computePath(
this._crowd.getAgentPosition(0),
closestPoint
)

然后利用 3.1 的思路,通过路径索引切换进行移动。

方法 2

recast 首先会创建一个导航网格,然后通过添加 agent 让它们约束在这个导航网格中,而这些 agent 的集合,称为 crowd

并且 recast 自带了移动的 API —— agentGoto,此时可以不需要再去计算距离和方向,并且也不需要手动切换移动路径,让我们看看具体是怎么做的。

(1) 初始化插件,并设置 Web Worker 来获取网格数据以优化性能

initNav() {
navigationPlugin = new RecastJSPlugin()
// 设置Web Worker,在里面获取网格数据
navigationPlugin.setWorkerURL(WORKER_URL)
// 创建导航mesh
navigationPlugin.createNavMesh([
ground,
...obstacleList, // 障碍物列表mesh
], NAV_MESH_CONFIG, (navMeshData) => {
navigationPlugin.buildFromNavmeshData(navMeshData)
}
this.navigationPlugin = navigationPlugin
}

(2) 初始化 crowd(crowd:约束在导航网格中 agent 的集合)

initCrowd() {
this.crowd = this.navigationPlugin.createCrowd(1, MAX_AGENT_RADIUS, this.scene)
const transform = new TransformNode('playerTrans')
this.crowd.addAgent(this.player.position, AGENTS_CONFIG, transform)
}

(3) 点击时利用 agentGoto API进行移动,pickedPoint 为点击点的三维坐标,由于 crowd 里只有一个对象,所以索引是 0

const closestPoint = this.navigationPlugin.getClosestPoint(pickedPoint)
this.crowd.agentGoto(0, closestPoint)

(4) 判断是否停止,未停止则改变人物朝向

那么如何改变人物的朝向呢,我们需要下一个中间点的位置,让人物看向它即可。

所以回到之前初始化的地方,创建一个 navigator

initCrowd() {
// ...
const navigator = MeshBuilder.CreateBox('navBall', {
size: 0.1,
height: 0.1,
}, this.scene)
navigator.isVisible = false
this.navigator = navigator
// ...
}

在 render 的时候,人物是否停止,可以通过当前 agent 的移动速度来进行判断。而改变方向,则是通过将 navigator 移动到下一个 path 的中间点,让人物看向它。

onRender () {
// 第一个agent对象的移动速度
const velocity = this.crowd.getAgentVelocity(0)
// 移动人物到agent的位置
this.player.position = this.crowd.getAgentPosition(0)
// 将navigator的位置移到下一个点
this.crowd.getAgentNextTargetPathToRef(0, this.navigator.position)
if (velocity.length() > 0) {
this.player.lookAt(this.navigator.position)
// ...
} else {
// ...
}
// ...
}

4. 避障实现效果

让我们看看最后的效果

5. 遇到的问题

整个开发过程中,其实也不是非常顺利,总结了一些遇到的问题,可以给大家参考一下。

(1) 年兽移动时,有时会“无法刹车”,导致在终点时反复来回停不下来;

这是因为在这一帧里,由于年兽的加速度较小,无法使得短时间内将速度降为0。所以只能“走过头”再“走回来”直到速度降为0之后,停止在终点。

此时,只需要 hack 一下,将 agent 的 maxAcceleration 设为极大,让其有种匀速行走并立马停下的感觉。

export const AGENTS_CONFIG: IAgentParameters = {
maxAcceleration: 1000
// ...
}

(2) 障碍物的动态添加与移除

如果障碍物在该场景初始化后,位置发生了改变,此时再去销毁创建一次 navMesh 是很消耗性能的。

于是我们通过查找文档,看到还有动态添加障碍物的 API。再立马调了下文档中的Playground,发现是可以用的。但是当我们把障碍物放大了之后,穿模了 看看这里

于是在 Babylon 的论坛上提了这个问题,20分钟后就得到了 reply,这个速度。

原来是需要调整 NavMeshParametersch / cs / tileSize 参数,对项目做适配。

那如果想要自己实现避障,创建更快的navMesh,我们应该怎么做呢?可以看看这篇文章:3D 沙盒游戏之避障踩坑和实现之旅

总结

这篇文章,我们从骨骼动画的介绍及使用、模型的移动及状态改变、路径规划的适配三个方面,讲解了3D沙盒游戏中实现人物行走移动并进行状态改变的思路及步骤,希望新人阅读结束之后,能更快上手这个功能。

当然,本篇文章介绍的实现方式还仍有不足之处,比如移动可以加上加速度,让动作与移动速度匹配得更自然等。

如果还有什么合适的建议,也欢迎大家积极留言交流。

参考资料

  1. 骨骼动画 - 维基百科,自由的百科全书
  2. Grouping Animations | Babylon.js Documentation
  3. Advanced Animation Methods | Babylon.js Documentation
  4. Vector3 | Babylon.js Documentation
  5. Crowd Navigation System | Babylon.js Documentation
  6. Web Workers API
  7. Make crowd agent move at constant speed - Questions - Babylon.js

欢迎关注凹凸实验室博客:aotu.io

或者关注凹凸实验室公众号(AOTULabs),不定时推送文章:

3D 沙盒游戏之人物的点击行走移动的更多相关文章

  1. git 沙河游戏节点图, 自由沙盒模拟git, 各类交互git命令

    git学习练习总资源链接: https://try.github.io/ (练习已通,有document) 本沙盒游戏教学:https://learngitbranching.js.org/?demo ...

  2. 游戏开发设计模式之子类沙盒模式(unity3d 示例实现)

    积累提供所有操作(的实现)来定义子类的行为用一个最简单的例子来讲解这个模式玩家操纵的英雄也就是这个游戏的主角会有许多技能,我们想定义许多不同的技能,来让玩家使用.首 先我们定义一个skillBase类 ...

  3. <转>iOS应用程序内使用IAP/StoreKit付费、沙盒(SandBox)测试、创建测试账号流程!

    原文地址:http://blog.csdn.net/xiaominghimi/article/details/6937097 //——2012-12-11日更新   获取"产品付费数量等于0 ...

  4. NSFileManager(沙盒文件管理)数据持久化 <序列化与反序列化>

    iOS应用程序只能在为该改程序创建的文件中读取文件,不可以去其它地方访问,此区域被成为沙盒,所以所有的非代码文件都要保存在此,例如图像,图标,声音,映像,属性列表,文本文件等.       默认情况下 ...

  5. iOS开发——多线程篇——快速生成沙盒目录的路径,多图片下载的原理、SDWebImage框架的简单介绍

    一.快速生成沙盒目录的路径 沙盒目录的各个文件夹功能 - Documents - 需要保存由"应用程序本身"产生的文件或者数据,例如:游戏进度.涂鸦软件的绘图 - 目录中的文件会被 ...

  6. iOS开发——UI进阶篇(十一)应用沙盒,归档,解档,偏好设置,plist存储,NSData,自定义对象归档解档

    1.iOS应用数据存储的常用方式XML属性列表(plist)归档Preference(偏好设置)NSKeyedArchiver归档(NSCoding)SQLite3 Core Data 2.应用沙盒每 ...

  7. iOS学习之沙盒

    1.iOS沙盒 iOS应用程序只能在为该改程序创建的文件系统中读取文件,不可以去其它地方访问,此区域被成为沙盒,所以所有的非代码文件都要保存在此,例如图像,图标,声音,映像,属性列表,文本文件等. 1 ...

  8. IOS 沙盒机制 浅析

    IOS中的沙盒机制(SandBox)是一种安全体系,它规定了应用程序只能在为该应用创建的文件夹内读取文件,不可以访问其他地方的内容.所有的非代码文件都保存在这个地方,比如图片.声音.属性列表和文本文件 ...

  9. 【转】详解iOS应用程序内使用IAP/StoreKit付费、沙盒(SandBox)测试、创建测试账号流程

    http://blog.csdn.net/xiaominghimi/article/details/6937097 //——2012-12-11日更新   获取"产品付费数量等于0这个问题& ...

随机推荐

  1. Windows 8下完美使用Virtual PC 2007(virtual pc 2007 64 win8 兼容性)

    Windows 8下完美使用Virtual PC 2007(virtual pc 2007 64 win8 兼容性) 一.从微软的官方网站下载Virtual PC 2007 SP1英文版,文件名为se ...

  2. Dubbo Monitor 实现原理?

    Consumer 端在发起调用之前会先走 filter 链:provider 端在接收到请求时也是 先走 filter 链,然后才进行真正的业务逻辑处理. 默认情况下,在 consumer 和 pro ...

  3. XMLBeanFactory?

    最常用的就是 org.springframework.beans.factory.xml.XmlBeanFactory ,它根据XML文件中的定义加载beans.该容器从XML 文件读取配置元数据并用 ...

  4. java中的正则表达式And Pattern And Macher

    在哪里?? java.util.regex包下有两个用于正则表达式的类, 一个是Matcher类, 另一个Pattern 简单例子 public class RegexLeaning { public ...

  5. 为什么使用 Executor 框架?

    每次执行任务创建线程 new Thread()比较消耗性能,创建一个线程是比较耗时. 耗资源的. 调用 new Thread()创建的线程缺乏管理,被称为野线程,而且可以无限制的创建, 线程之间的相互 ...

  6. Redis 的内存用完了会发生什么?

    如果达到设置的上限,Redis 的写命令会返回错误信息(但是读命令还可以正 常返回.)或者你可以将 Redis 当缓存来使用配置淘汰机制,当 Redis 达到内存 上限时会冲刷掉旧的内容.

  7. JVM监控工具介绍jstack, jconsole, jinfo, jmap, jdb, jsta

    JVM监控工具介绍 jstack - 如果java程序崩溃生成core文件,jstack工具可以用来获得core文件的java stack和native stack的信息,从而可以轻松地知道java程 ...

  8. Python turtle 模块可以编写游戏,是真的吗?

    1. 前言 turtle (小海龟) 是 Python 内置的一个绘图模块,其实它不仅可以用来绘图,还可以制作简单的小游戏,甚至可以当成简易的 GUI 模块,编写简单的 GUI 程序. 本文使用 tu ...

  9. 插值方法 - Newton向前向后等距插值

    通常我们在求插值节点的开头部分插值点附近函数值时,使用Newton前插公式:求插值节点的末尾部分插值点附近函数值时,使用Newton后插公式. 代码: 1 # -*- coding: utf-8 -* ...

  10. Numpy中重要的广播概念

    Numpy中重要的广播概念 广播:简单理解为用于不同大小数组的二元通用函数(加.减.乘等)的一组规则 广播的规则: 如果两个数组的维度数dim不相同,那么小维度数组的形状将会在左边补1 如果shape ...