优点:准确、快速、开销小
缺点:不能作用与三根及以上的骨骼

基于位置的数值算法,从尾到头,再从头到尾进行位置迭代至目标距离阈值内,或达到迭代次数上限时结束计算

优点:不限制作用的骨骼数量,可以作用于一整条骨骼链
缺点:目标越轴时关节角度会发生跳变情况
跳变问题:

优点:解决了 FARBIK 的跳变问题、可以限制骨骼的最大旋转角度
缺点:当两个 CCDIK 同时作用了相同的骨骼时,CCDIK 后计算的骨骼会影响前计算的骨骼
优点:可以解决跳变与共链问题,具有角度限制,可以作用于全身骨骼
缺点:性能消耗大
]]>学习参考:bilibili.com/video/BV1Sz4y1d7bN?
创建一个 GameObject 并添加 ConvertToClientSeverEntity 组件,用来负责在客户端与服务端之间同步 SharedData
首先,先来创建一个我们想要同步的 Prefab,本文使用子弹(bullet)来举例
给 Prefab 添加 Ghost Authoring Component 组件,并在 Name 参数栏写上对应的名字
Default Ghost Mode 选择 Owner Predicted,并添加 GhostOwnerComponent 组件
打开 PrefabAssetRegistry 场景,选择 Prefab Asset Registry Authoring 组件的 Gather Prefabs
打开 GhostCollection 场景,选择 Ghost Collection Authoring 组件的 Update ghost list ,将创建好的 Prefab 加入 Ghost 列表中
之后我们来为服务端写一个 GhostSystem
GameModeUtil.GetGhostPrefab 获取我们配置的 Ghost Prefab,参数要与 Ghost Authoring Component 的 Name 一致
[DisableAutoCreation]
public class GhostSystem : SystemBase {
public Entity bullet;
protected override void OnUpdate() {
if (bullet == Entity.Null) {
bullet = GameModeUtil.GetGhostPrefab(EntityManager, "TopDownGhostBullet");
}
}
添加到 SeverGameWorld,此时使用 EntityManager.Instantiate 方法就可以在服务端生成 Prefab ,同时多播给所有客户端了,但子弹不会凭空出现,我们需要一个判断条件来判断玩家是否为开火状态,再去生成 bullet
注意:模式为 Owner predicted 需要设置 GhostOwnerComponent 的 NetworkId
protected override void OnUpdate() {
var tick = World.GetExistingSystem<GhostPredictionSystemGroup>().PredictingTick;
Entities
.WithoutBurst()
.WithStructuralChanges()
.ForEach((ref DynamicBuffer<UserCommand> user, ref Player.State state, ref GhostComponent ghost) => {
user.GetDataAtTick<UserCommand>(tick, out UserCommand Input);
}).Run();
此时从 DynamicBuffer 中根据 tick 获取到了 UserCommand 的数据
便可以用来判断玩家目前是否为开火状态,再去初始化生成子弹,并对子弹的位置信息,方向等进行操作
虽然子弹现在已经从服务端同步生成到了客户端,但由于服务端是不需要进行模型渲染的
在客户端的 System 需要对生成的 Entity 添加模型来进行渲染
所以需要额外生成一个 GameObject 来对生成的 Entity 进行绑定和同步
用 GameObject 来生成渲染模型的原因有两个
因为 Freamwork 使用了 XRP,会导致 Entity 的 RenderBounds 组件失效
GhostPrefab 没有 render ,方便服务端分离渲染层,减小服务端渲染压力
在做这个 System 之前,需要先设计一个 StateComponent,继承 ISystemStateComponentData,包含一个 Entity 字段
因为当服务端删除子弹时,客户端的子弹也会被删除,没有机会去删除 GameObject 的子弹
当给 Entity 添加了 StateComponent 后,Entity 会保留 StateComponent 中存储的信息,等待我们进行删除的后续操作
所以我们要在这个 State 包含的 Entity 中去添加我们的 ComponentObject 才能保证我们去找到并删除 GameObject
示例:
// Spawn Model
Entities
.WithoutBurst()
.WithAll<TopDownGhostBulletInfo>()
.WithNone<bulletStateTag>()
.WithStructuralChanges()
.ForEach((Entity entity, in Translation translation) =>{
Transform transform = Object.Instantiate(bulletModel).GetComponent<Transform>();
var tranformEntity = EntityManager.CreateEntity();
EntityManager.AddComponentObject(tranformEntity, transform);
EntityManager.AddComponentData(entity, new bulletStateTag { entity = tranformEntity });
}).Run();
// Update
Entities
.WithoutBurst()
.WithAll<TopDownGhostBulletInfo>()
.WithAll<bulletStateTag>()
.ForEach((Entity entity,ref bulletStateTag tag, in Translation translation) =>{
Transform transform = EntityManager.GetComponentObject<Transform>(tag.entity);
transform.position = translation.Value;
}).Run();
// Destroy
Entities
.WithoutBurst()
.WithNone<TopDownGhostBulletInfo>()
.WithAll<bulletStateTag>()
.WithStructuralChanges()
.ForEach((Entity entity, in bulletStateTag tag) =>{
Object.Destroy(EntityManager.GetComponentObject<Transform>(tag.entity).gameObject);
EntityManager.DestroyEntity(tag.entity);
EntityManager.RemoveComponent<bulletStateTag>(entity);
}).Run();

Supported Ghost Modes - Ghost 支持模式
Default Ghost Mode - Ghost 默认模式
Owner predicted - Ghost 为拥有它的客户端预测,并为所有其他客户端进行插值
Optimization Mode - 优化模式
Dynamic - 动态优化
Static - 静态优化

但当存在 State 组件时,实体不会被删除,只会删除除了 State 组件以外的组件
当配合使用 StateComponentData 与 ComponentData 时可以实现观察实体当前的状态
| Tag(ComponentData) | State(StateComponentData) |
|---|---|
| Tag 存在,State 缺失 | 实体刚被创建,未被初始化 |
| Tag 存在,State 存在 | 实体已被初始化,可以使用 |
| Tag 存在,State 存在 | 实体处于删除状态 |
例如:
Entities
.WithAll<Tag>()
.WithNone<State>()
.WithStructuralChanges()
.ForEach((Entity entity) =>{
// Spawn
}).Run();
Entities
.WithAll<Tag>()
.WithAll<State>()
.ForEach((Entity entity) =>{
// Update
}).Run();
Entities
.WithNone<Tag>()
.WithAll<State>()
.ForEach((Entity entity) =>{
// Destroy
}).Run();
]]>ECB 主要解决了两个问题
结构化改变包括
同步点
当我们在使用 Job 时,为了防止多线程运行时对同一个实体进行了冲突的操作时,同步点的存在是必要的
例如:当线程 A 对实体 a1 的组件进行了删除操作,线程 B 又去获取实体 a1 被删除的组件时,就会发生错误
所以当我们使用了 Job 的多线程时,需要使用 ECB 来延迟结构化改变操作,统一到同步点内再进行操作
只使用射线检测的好处是简单易用,但仅适用于 2D 或者对高度没有要求的俯视角游戏,很多场景并不适用
判断看向目标的向量与自身正前方向量的角度,若小于设定的角度,再向目标打出射线,若命中,则说明目标与自身之间没有阻碍,且角度,距离都正确

可以适用于 3D 场景,但问题在于必须指定目标,并且单一射线目标不适于所有场景
当有碰撞体进入时进行角度判断,再进行射线检测,相较于2方法,无需指定目标
使用 TestPlanesAABB(Plane[] planes, Bounds bounds) 方法
使用 GeometryUtility.CalculateFrustumPlanes 方法从 Camera 组件上获取到视锥体的六个 Plane
这个方法有两个弊端,一是必须挂载 Camera 组件来使用,二是必须指定两个 AABB 盒才能进行判断,不适合使用
根据 Fov、near、far 等信息计算视锥顶点与三角面信息,手动绘制 Mesh,传入 MeshFilter,再由 MeshCollider 转化为碰撞体
顶点计算逻辑:Mathf.Tan(fov / 2 * Mathf.Deg2Rad);
使用起来与普通的碰撞体一样
向指定角度打出若干射线,根据射线检测是否碰撞,若碰撞则返回碰撞点位置,没有碰撞则返回正常位置,根据位置信息绘制平面,实现剔除效果
缺点:不必要的 RayCast 太多,造成性能浪费
只向更近的物体顶点打出射线

早期沿用了神海的简单的锥体和射线来检测阻挡,但这样会导致贴AI的玩家不会被AI发现,而站在远处却会被敌人发现。

修改了视锥,如图,使近距离的可视范围较大,并且让可视角度大小随距离延长逐渐变小。


AI会对视觉范围内的玩家做射线检测(阻挡检测),起初检测乔尔身体的每个关节,每个关节都有各自的权重,当检测到的权重之和达到阈值,就会认为乔尔被看到了。
但是由于计算太复杂,玩家无法预测哪些掩体是安全的。
在经过一些尝试之后,顽皮狗最终决定只用一个点来做检测,这个点会根据敌人的状态而变化。
当玩家没被发现,也就是处在潜行状态的时候,AI只检测乔尔胸部中心点的位置,而当玩家和AI处于战斗状态的时候,AI就检测玩家的头顶。(如下图所示)

其中有三个颜色的区域用作视觉刺激
黄:该区域的玩家将会被缓慢发现
橙:该区域的玩家将会被迅速发现
为了防止玩家跑到 AI 身后,设置了一圈红色区域
红:该区域的玩家会被立刻发现
]]>有一个更有趣的设计,来自《Moirai》这款免费的像素游戏
简略的讲一下它的设计,在游戏里你会在洞穴里遇到一个满身的血,拿着刀的NPC
你上前对话,会有四个选项
你得到的回答可能完全不合逻辑,NPC看起来像在胡言乱语
你会感到疑惑,但当你玩到游戏结尾,当你满身是血,手里拿着刀,又遇到了一个NPC时
系统让你回答刚才那几个问题的时候你才明白,这是一个轮回
你的回答会变成别人游玩时,他们游戏中NPC的回答
同样,如果你注意到了第四个选项,你就会明白别人可以决定在他们游戏中,“你”的生死
你的操作会影响别人的游戏体验,这很有趣
如果这一类似机制被放在 MMORPG 中,不知道会产生怎样的效果
]]>挂载后,运行时将会自动转化为 Entity ,但并非所有组件都能被转化为 Entity 的 Component
可以通过更改 Conversion Mode 来更改转化的模式
Convert And Destroy 模式会将该 GameObject 的子层级一并转化,且保持父子级关系,但如遇到无法转化的组件时会直接删除
Convert And Inject Game Object 模式在转化时会保留原有组件,但会导致 Mesh Renderer 与 Hybrid Renderer 同时渲染,出现两个相同物体的情况,同时该模式无法转化子层级对象
父物体与子物体同时挂载 Convert To Entity 组件
父物体为 Destroy 模式,子物体为 Inject 模式 (经测试,父物体为 Inject,子物体为 Destroy 时,子物体将不会进行转化)
此时将会同时获得两种模式的好处,在保持父子级关系的同时,子物体的组件会被保留
该方法通常用于编写 Authoring ,在 Convert 可以加入我们想要在转化时同时加入的 Component 信息
这样我们可以还在 Inspector 窗口更改参数,方便调试
注意:GameObject 依然需要挂载 Convert To Entity 组件
示例:
public struct CameraAimInfo : IComponentData {
public float distance;
public float theta;
}
public class CameraAuthoring : MonoBehaviour, IConvertGameObjectToEntity {
public float distance;
public float theta;
public void Convert(Entity entity, EntityManager dstManager, GameObjectConversionSystem conversionSystem) {
dstManager.AddComponentData(entity, new CameraAimInfo() {
distance = distance,
theta = theta
});
}
}
它会创建一个新的 GameObject 来"伴随"(companion)被转换的数据,并用它来选择你想保留的组件
当我们的 GameObject 中有几个不想被转化或无法转化的组件时,可以使用 AddHybridComponent 这个方法
示例:
public class Test : MonoBehaviour, IConvertGameObjectToEntity {
public Button button;
public Image image;
public Text text;
public ParticleSystem particleCompanion;
public ParticleSystemRenderer rendererCompanion;
public void Convert(Entity entity, EntityManager dstManager, GameObjectConversionSystem conversionSystem) {
conversionSystem.AddHybridComponent(button);
conversionSystem.AddHybridComponent(text);
conversionSystem.AddHybridComponent(image);
conversionSystem.AddHybridComponent(particleCompanion);
conversionSystem.AddHybridComponent(rendererCompanion);
}
}
这个并不算转化方法,只是能够在转化的时候更方便的加上 Component
可以通过添加 [GenerateAuthoringComponent] 属性标签让 ComponentData 可以被直接挂载
注意:字段的访问类型必须为 public,且依然需要挂载 Convert To Entity
示例:
[GenerateAuthoringComponent]
public struct SpeedComponent : IComponentData {
public float speed;
}
[GenerateAuthoringComponent]
public struct TagPlayer : IComponentData {}
通过 SubScene 转化的物体可以实现一边在 Hierarchy 中修改 GameObject,一边实时的同步到 LocalToWorld / Translation 中,并且不会出现两个物体的情况,仅由 Hybrid Renderer 负责渲染,尽管物体已经被转化为 Entity 并且 Destroy,但在 Hierarchy 中仍然可以选中物体并同步移动。
注意:在 Scene 窗口想要调试 Entity 场景需要勾选 SubScene 右侧的 Toggle
建议阅读缩放比例:150%
在多数射击游戏中,子弹的弹道通常不是固定的,而是在一个区域范围内变化的,有固定弹道变化,如 CS:GO,也有随机的弹道变化,本文主要讲解如何制作随机的弹道。
让我们先来个最简单的,在 Unity 中最常规的获取随机值的办法是使用 Random.Range 方法,我们可以用这种方法来同时获取我们需要的 x 和 y 的(0 - 1)随机值,但这样我们只能获取到一个正方形区域内的 Vector2 值,一般的射击散布中是不会使用正方形范围的随机弹道的。(也可能有游戏这么玩)
正方形的随机分布
Unity 提供了一个方法
Random.insideUnitCircle
它会返回半径为 1 的圆内的一个随机点(Vector2),所以我们直接使用就好啦,现在我们就获取了一个圆形区域内的随机二维值。
只有一个圆形内的随机值是远远不够的,它会使我们的弹道完全随机,像喝了假酒的亚瑟·摩根,准星如同摆设,玩家的射击体验会很糟糕,所以我们需要让准星的随机值大概率是落在准星近点,小概率落在准星远点。
这很简单,我们只需要使用 Random.Range 先随机一个值,然后准备两个圆形的随机圆,一个正常的随机圆形大小,一个缩小几倍的圆形,然后让 Random.Range 的随机值大概率落在小圆形中,小概率落在大圆形中就可以啦。
如:noise = 3 >= Random.Range(1, 11) ? Random.insideUnitCircle * 0.2f : Random.insideUnitCircle;
双圆随机是一个取巧的办法,效果只能说勉强过得去,接下来这个方法才是常规办法,要讲解这个方法,首先需要了解高斯分布,也叫正态分布。
简单来说,我们使用高斯分布,给定均值 μ 和方差 σ ,它便会随机计算出一个趋近于均值 μ 的值,其中方差 σ 越小,随机值越趋近于均值。说人话就是,高斯分布可以让我们更高概率获取偏移量小的值。
高斯函数的详解我推荐看这个视频,非常的有趣
但即便看完了视频,你可能也不知道如何在 Unity 中,用代码来实现高斯函数,你需要看这篇国外文章
了解高斯分布 Understanding the Gaussian distribution
如何生成高斯分布数 How togenerate Gaussian distributed numbers
如果你嫌麻烦,也可以直接食用下面的 Code
#region 生成高斯分布数
// mean:均值,variance:方差
// min和max用于去掉不需要的偏差值
public static float NextGaussian(float mean, float variance, float min, float max) {
float x;
do {
x = NextGaussian(mean, variance);
} while (x < min || x > max);
return x;
}
public static float NextGaussian(float mean, float standard_deviation) {
return mean + NextGaussian() * standard_deviation;
}
public static float NextGaussian() {
float v1, v2, s;
do {
v1 = 2.0f * Random.Range(0f, 1f) - 1.0f;
v2 = 2.0f * Random.Range(0f, 1f) - 1.0f;
s = v1 * v1 + v2 * v2;
} while (s >= 1.0f || s == 0f);
s = Mathf.Sqrt((-2.0f * Mathf.Log(s)) / s);
return v1 * s;
}
#endregion}
食用栗子 noise = new Vector2(NextGaussian(0, 0.2f, -1, 1), NextGaussian(0, 0.2f, -1, 1));
柏林噪声法是一个很神奇的函数,它可以随机出不那么随机的随机数(有点绕hhh),例如 Minecraft 的地形随机就是使用了这个方法,关于柏林噪声在子弹散布中的使用效果,我个人测试的效果不是很理想,可能是我的做法有误,所以我就不多讲了,可以参考下面这篇文章
原始柏林噪声
Unity 自带的柏林噪声函数 : Mathf.PerlinNoise
很多时候,我们更希望子弹最开始的几发是准确的,越往后的枪口抖动和后座力越大,例如 CS 中 AK47 的前三发是没有任何偏移的,要实现这个效果,我的做法是添加一个扩散值 diffusionValue 的参数,这个参数的初始值为 0 ,会根据我们射击的时间越来越大,同时扩散的范围也会越来越大。
想要实现固定开火次数内无扩散,只需要做一个计数器即可。
当然也可以参考CS:GO的做法,制作一个固定弹道
]]>Run 是直接在主线程上执行的,这个没什么需要解释的
Schedule 和 ScheduleParallel 都是放在多线程上并发执行的
我目前的理解是, Schedule 是在每一个 Foreach 后生成一个 Job
而 ScheduleParallel 划分 Job 是根据 Chunk 生成的,Chunk 是按 EntityArchetype 分配的
当一个 Chunk 的空间满了后,会再生成一个 Chunk,而这每一个 Chunk 都会成为一个 Job
所以从执行效率来看,ScheduleParallel 的大多数情况应该是优于 Schedule 的
而当我们只有一个 Chunk 的时候,Schedule 和 ScheduleParallel 的效果应该是一样的
这个可能是最简单方便的办法了,在场景中找到想要转换的 GameObject ,挂载 Convert To Entity 就可以进行转换了
需要提的一点是,这个组件有两种 Conversion Mode
| Convert And Destroy | 转换后 删除 原有 GameObject |
| Convert And Inject GameObject | 转换后 保留 原有 GameObject |
这个应该是最常用的办法了,生成的Entity会生成在和 EntityManager 相同的 World 中
| EntityManager.CreateEntity( ) | 可以什么参数都不填,生成一个空的 Entity |
| EntityManager.CreateEntity(params ComponentType[]) | 填入不定项个 ComponentType,可以直接生成带有这个 Component的Entity |
| EntityManager.CreateEntity(EntityArchetype) | 利用原型 Archetype 来生成 Entity |
| EntityManager.Instantiate(Entity) | 克隆一个现有的 Entity |
我们可以创建一个用来生成的类 Spawner ,继承 MonoBehaviour ,并实现 IDeclareReferencedPrefabs , IConvertGameObjectToEntity 接口
这个时候需要我们去实现接口中的两个方法,其中DeclareReferencedPrefabs的操作是referencedPrefabs.Add(Prefab)
这个Prefab是我们外部加载的GameObject,这个方法会在场景中生成一个Entity类型的一个Prefab
之后我们来实现Convert这个方法
首先我们来创建一个ComponentData,我们要确保这个里面包含Entity字段,并在Convert这个方法里使用conversionSystem.GetPrimaryEntity()将这个 Prefab 转化为 Entity 加载进去
最后我们使用dstManager.AddComponentData()方法,将这个ComponentData挂载到我们之前创建好的Entity当中
此时,我们就可以利用这个Entity来创建我们外部加载的Prefab了~
]]>