在Unity中为即时战略游戏实现战争迷雾(下)

2024-05-30 09:48

本文主要是介绍在Unity中为即时战略游戏实现战争迷雾(下),希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

本文将在Unity中为即时战略游戏实现战争迷雾的一种新方法。

在上一篇文章中,游戏开发工程师Ariel Coppes分享了《钢铁战队》中战争迷雾效果的实现方法,本文他将介绍新的一种实现方法。

新的战争迷雾及视野系统目标是实现下列功能:

  • 能够随时渲染每个玩家的战争迷雾,用于进行回放和调试。

  • 能够结合多个玩家的视野,用于提供友方视野、实现观众模式和观看回放时使用。

  • 使用不同地形高度和其它元素来阻挡视野。

  • 优化开发,使视野在移动设备上支持同时显示50多个单位,并在60fps的状态下运行。

  • 该效果应该类似《星际争霸2》和《英雄联盟》等游戏。

下面是《星际争霸2》的战争迷雾,是我希望实现的效果。

 

请注意:为了让本文的内容更简洁,在提到“单位”时,所指的是角色、建筑等影响游戏内战争迷雾的结构。

 

逻辑

首先,我们有一个概念叫UnitVision,即单位视野,用于表示可揭开迷雾的任何对象。UnitVision在代码中是一种数据结构。

 

struct UnitVision { // 表示视野系统中玩家分组的位掩码。 int players; // 使用世界坐标的视野范围。 float range; // 使用世界坐标的位置。 vector2 position; // 用于阻挡视线。 short terrainHeight; }

通常在游戏中,每个单位都有一个单位视野,有的单位会有更多视野,例如:大型单位,而有的单位甚至没有视野。

位掩码用于指定玩家分组,例如:如果玩家0是0001,而玩家1是0010,那么0011表示由玩家0和玩家1组成的分组。由于这是一个int类型数值,因此它最多支持sizeof(int)大小的玩家数量。

大多数时候,该分组仅包含一个玩家,但在部分情况下,例如:在通用特效或影片效果中,该分组需要被所有玩家看到,其中一种实现方法是使用带有多个玩家的unitVision。

字段terrainHeight存储当前单位的高度,我们会使用该字段来检测单位是否会阻挡视野。如果该单位是地面单位,该值通常是该位置世界地形的高度,但也有一些特别情况。

例如:飞行单位或可改变单位高度的特殊技能,在计算被阻挡的视野时,要考虑到这些因素。游戏要对该字段进行相应的更新。

我们还有一个概念名叫VisionGrid,即视野网格,表示所有玩家的视野。下面是VisionGrid的数据结构。

 

struct VisionGrid { // 下列变量表示网格的宽度和高度,它们需要访问数组。 int width, height; // 存储宽度乘高度得到的大小结果,每个部分都有int类型数值和位码,表示哪个玩家的视野中有该部分。 int[] values; // 和values数组类似,但它只存储玩家是否在某段时间访问该部分。 int[] visited; void SetVisible(i, j, players) { values[i + j * width] |= players; visited[i + j * width] |= players; } void Clear() { values.clear(0); } bool IsVisible(i, j, players) { return (values[i + j * width] & players) > 0; } bool WasVisible(i, j, players) { return (visited[i + j * width] & players) > 0; } }

请注意:数组的大小为宽度乘以高度的结果。

网格越大,计算视野的速度越慢,但它会有更多信息用于实现单位的行为或渲染更好的迷雾效果。网格越小,效果则会相反。我们必须在一开始就确定好二者的平衡,由此来构建游戏。

下面是游戏世界中网格的示例。

58618dba-85eb-467c-9c66-a40dc6400c33_01.pnguploading.4e448015.gif转存失败重新上传取消

在获得世界位置的网格部分后,该结构会在values数组存储int类数值,提供哪个玩家视野内有该位置的数据信息。例如:如果该部分存储了0001,那么它表示只有玩家0能够看到该部分,如果该部分存储了0011,那么玩家0和玩家1都会看到该部分。

该结构也会把玩家之前探索迷雾部分的时间存储到visited数组中,主要用于渲染功能,即渲染灰色迷雾,但该数据也可以用到游戏逻辑中,例如:用于检查玩家是否了解相关信息。

如果位掩码中的玩家能够看到该位置,IsVisible(i, j, players)方法会返回True。

WasVisible(i, j, players)方法有类似的功能,但它会检查visited数组。

例如:如果玩家1和玩家2,即位码中的0010和0100属于同一阵营,如果玩家2希望知道敌人是否可见,以便展开进攻,则可以使用两个玩家的位掩码0110来调用isVisible方法。

 

计算视野

每次更新视野网格时,values数组都会清空,然后重新计算。

下面是该算法的伪代码。

 

void CalculateVision() { visionGrid.Clear() for each unitVision in world { for each gridEntry inside unitVision.range { if (not IsBlocked(gridEntry)) { // 设为可见时,会更新values数组和visited数组。 grid.SetVisible(gridEntry.i, gridEntry.j, unitVision.players) } } } }

为了在范围内迭代网格部分,该脚本首先会使用网格坐标来计算视野的位置和范围,相应的变量分别是gridPosition和gridRange,然后脚本会围绕gridPosition并以gridRange为半径来绘制实心圆形。

44bf5f7e-8d26-4bbd-9db9-d64ed8221b7b_02.pnguploading.4e448015.gif转存失败重新上传取消

 

被阻挡的视野

为了检测被阻挡的视野,我们有相同大小的另一个网格,它带有地形的高度信息。

下面是该信息的数据结构。

 

struct Terrain { // 下列变量表示网格的宽度和高度,它们需要访问数组。 int width, height; // 存储宽度乘高度得到的大小结果,该数组拥有网格部分的地形层级。 short[] height; int GetHeight(i, j) { return height[i + j * width]; }

下面是网格在游戏中的示例效果。

a315b5fd-6212-455a-a371-70cd43c172d0_03.pnguploading.4e448015.gif转存失败重新上传取消

在迭代unitVision范围中视野的网格部分时,为了检测该部分是否可见,我们的系统会检查视野中心是否有障碍物。为此,它会从该部分的位置绘制一条直线,连接到中心的位置。

如果线条上的所有网格部分都在相同高度或较低高度,那么该部分是可见的。下面是相应的示例,蓝点表示进行计算的网格部分,白点表示连接中心的线条。

baf881e3-9c4d-4cb6-a44c-4d6ad66f480a_04.pnguploading.4e448015.gif转存失败重新上传取消

如果线条上至少有一个部分的高度较大,那么视线会被阻挡。

在下面的例子中,蓝点表示我们希望了解是否可见的部分,白点表示线条上相同高度的部分,红点表示地形较高的部分。

5c8515ae-fd43-4982-8d8b-def39a26ff8a_05.pnguploading.4e448015.gif转存失败重新上传取消

在脚本检测到某个部分高于视野后,它不必继续绘制连接视野中心的线条。下面是相应的伪代码。

 

bool IsBlocked() { for each entry in line to unitVision.position { height = terrain.GetHeight(entry.position) if (height > unitVision.height) { return true; } } return false; }

 

优化

  • 如果某个部分已经在迭代所有单位视野时标记为可见,则不必重新计算该部分。

  • 减小网格的大小。

  • 减少更新迷雾的频率,我最近在玩《星际争霸1》时发现,更新迷雾会有约1秒的延迟。

 

渲染

渲染战争迷雾时,我使用和网格相同大小的小型纹理FogTexture,通过使用Texture2D.SetPixels()方法,在该纹理上写入相同大小的Color数组。

在每一帧中,我会在每个VisionGrid部分进行迭代,通过使用values数组和visited数组,对数组设置对应的颜色。

下面是这部分的伪代码。

 

void Update() { for i, j in grid { colors[i + j * width] = black if (visionGrid.IsVisible(i, j, activePlayers)) colors[pixel] = white else if (visionGrid.WasVisible(i, j, activePlayers)) colors[pixel] = grey // 这里用于处理之前的视野。 } texture.SetPixels(colors) }

字段activePlayers包含玩家的位掩码,用于渲染玩家的当前迷雾。通常,它只包含游戏中主要玩家的迷雾,但是在部分情况下,例如:回放模式中,该字段可以随时改变,渲染不同玩家的视野。

如果有两个玩家处于同一阵营,两个玩家的位掩码可用于渲染二者的共享视野。

 

填补FogTexture纹理后,该纹理会使用带有后期处理滤镜的摄像机,在RenderTexture渲染纹理中进行渲染,滤镜会给纹理应用模糊效果,让迷雾的外观效果更好。

为了实现更好的结果,在应用后期处理特效时,这里使用的RenderTexture渲染纹理比FogTexture纹理大四倍。

21330be8-6d4e-4c7f-98a2-a655a2bfb2ac_07.pnguploading.4e448015.gif正在上传…重新上传取消

获得RenderTexture渲染纹理后,我会使用自定义着色器在游戏中渲染它,该着色器会把图像看作Alpha遮罩,白色表示透明部分,黑色表示不透明部分,由于我在此不需要其它颜色通道,因此使用红色作为补充,这部分处理类似《钢铁战队》的相应过程。

画面效果如下图所示。

a14d47be-82bd-4287-8bc4-afea12e6b990_08.pnguploading.4e448015.gif转存失败重新上传取消

下图是该方法在Unity场景视图中的效果。

f089633b-b5a0-46dd-b1b4-9c4e903359a2_09.pnguploading.4e448015.gif转存失败重新上传取消

渲染流程如下图所示。

 

平缓过渡

在部分情况下,迷雾纹理会在不同帧数间大幅变化,例如:在新单位出现时,或是某个单位移动到高地时。

对于这类情况,我会给colors数组添加平缓过渡过程,这样数组中每个部分会及时从原有状态过渡到新状态,从而最小化变化幅度。

该过程非常简单,虽然该过程在处理纹理像素时会增加少量性能开销,但最终效果比我想象的更好,而且该过程也可以随时禁用。

 

最初我不确定是否要直接把像素写入纹理,因为我担心这项操作的速度很慢,但在移动设备上测试后,我发现速度很快,因此这并不是一个问题。

 

单位可见性

为了了解某个单位是否可见,该系统要检查包含单位的所有部分,大型单位会占用多个部分,如果至少有一个部分是可见的,那么该单位就是可见的。这个检查能够帮助我们了解某个单位是否可以被攻击。

下面是相应的伪代码。

 

bool IsVisible(players, unit) { // 这是某个玩家的单位。 if ((unit.players & players) > 0) return true; // 返回包含该单位的所有部分 entries = visionGrid.GetEntries(unit.position, unit.size) for (entry in entries) { if (visionGrid.IsVisible(entry, players)) return true; } return false; }

哪些单位可见和渲染的迷雾有关,因此我们使用了相同的activePlayers字段来检查是否显示单位。

为了避免渲染单位,我使用了类似《钢铁战队》的方法,也就是使用了游戏对象的图层。

如果单位是可见的,那么我们会给该对象设置默认图层,如果单位不可见,那么我们会给它设置从游戏摄像机剔除的图层。

 

void UpdateVisibles() { for (unit in units) { unit.gameObject.layer = IsVisible(activePlayers, unit) : default ? hidden; } }

 

最终效果

下面是所有内容结合起来的效果。

 

 

小结

将整个游戏世界简化为网格,并开始对纹理进行思考后,我们可以轻松应用不同的图像算法,例如:绘制填充圆圈或线条,它们在进行优化时非常实用。此外,还有更多图像操作可以用于游戏逻辑和渲染。

《星际争霸2》有很多纹理方面的信息,不只是玩家的视野,还提供了API来访问纹理,该API也被用到了机器学习的实验中。我仍在开发更多相关功能,同时还计划尝试一些优化功能,例如:C# Job System。

给迷雾纹理使用模糊效果也存在缺点,例如:这会在不合适的时候展示一部分高地。我仍然会研究其它图像效果,寻找合适的方法。

更多Unity精彩内容,请搜索“Unity官方平台”关注Unity官方微信公众号。

开发过程中遇到问题?请在App”群聊“ - “技术交流”中提问。

文章来源:gemserk.com

这篇关于在Unity中为即时战略游戏实现战争迷雾(下)的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/1016095

相关文章

Java实现检查多个时间段是否有重合

《Java实现检查多个时间段是否有重合》这篇文章主要为大家详细介绍了如何使用Java实现检查多个时间段是否有重合,文中的示例代码讲解详细,感兴趣的小伙伴可以跟随小编一起学习一下... 目录流程概述步骤详解China编程步骤1:定义时间段类步骤2:添加时间段步骤3:检查时间段是否有重合步骤4:输出结果示例代码结语作

使用C++实现链表元素的反转

《使用C++实现链表元素的反转》反转链表是链表操作中一个经典的问题,也是面试中常见的考题,本文将从思路到实现一步步地讲解如何实现链表的反转,帮助初学者理解这一操作,我们将使用C++代码演示具体实现,同... 目录问题定义思路分析代码实现带头节点的链表代码讲解其他实现方式时间和空间复杂度分析总结问题定义给定

Java覆盖第三方jar包中的某一个类的实现方法

《Java覆盖第三方jar包中的某一个类的实现方法》在我们日常的开发中,经常需要使用第三方的jar包,有时候我们会发现第三方的jar包中的某一个类有问题,或者我们需要定制化修改其中的逻辑,那么应该如何... 目录一、需求描述二、示例描述三、操作步骤四、验证结果五、实现原理一、需求描述需求描述如下:需要在

如何使用Java实现请求deepseek

《如何使用Java实现请求deepseek》这篇文章主要为大家详细介绍了如何使用Java实现请求deepseek功能,文中的示例代码讲解详细,感兴趣的小伙伴可以跟随小编一起学习一下... 目录1.deepseek的api创建2.Java实现请求deepseek2.1 pom文件2.2 json转化文件2.2

python使用fastapi实现多语言国际化的操作指南

《python使用fastapi实现多语言国际化的操作指南》本文介绍了使用Python和FastAPI实现多语言国际化的操作指南,包括多语言架构技术栈、翻译管理、前端本地化、语言切换机制以及常见陷阱和... 目录多语言国际化实现指南项目多语言架构技术栈目录结构翻译工作流1. 翻译数据存储2. 翻译生成脚本

如何通过Python实现一个消息队列

《如何通过Python实现一个消息队列》这篇文章主要为大家详细介绍了如何通过Python实现一个简单的消息队列,文中的示例代码讲解详细,感兴趣的小伙伴可以跟随小编一起学习一下... 目录如何通过 python 实现消息队列如何把 http 请求放在队列中执行1. 使用 queue.Queue 和 reque

Python如何实现PDF隐私信息检测

《Python如何实现PDF隐私信息检测》随着越来越多的个人信息以电子形式存储和传输,确保这些信息的安全至关重要,本文将介绍如何使用Python检测PDF文件中的隐私信息,需要的可以参考下... 目录项目背景技术栈代码解析功能说明运行结php果在当今,数据隐私保护变得尤为重要。随着越来越多的个人信息以电子形

使用 sql-research-assistant进行 SQL 数据库研究的实战指南(代码实现演示)

《使用sql-research-assistant进行SQL数据库研究的实战指南(代码实现演示)》本文介绍了sql-research-assistant工具,该工具基于LangChain框架,集... 目录技术背景介绍核心原理解析代码实现演示安装和配置项目集成LangSmith 配置(可选)启动服务应用场景

使用Python快速实现链接转word文档

《使用Python快速实现链接转word文档》这篇文章主要为大家详细介绍了如何使用Python快速实现链接转word文档功能,文中的示例代码讲解详细,感兴趣的小伙伴可以跟随小编一起学习一下... 演示代码展示from newspaper import Articlefrom docx import

前端原生js实现拖拽排课效果实例

《前端原生js实现拖拽排课效果实例》:本文主要介绍如何实现一个简单的课程表拖拽功能,通过HTML、CSS和JavaScript的配合,我们实现了课程项的拖拽、放置和显示功能,文中通过实例代码介绍的... 目录1. 效果展示2. 效果分析2.1 关键点2.2 实现方法3. 代码实现3.1 html部分3.2