七八月的 BOOOM 告一段落了,我也算是长出了一口气,因为不用再担心熬夜熬到人无了。
其实开发开始之前,我看着队伍里的人还是很有信心的,第一次这么多队友,一人一锤子,怎么也锤完一个作品了吧。于是做出了这次最失败,但并不后悔的决定——做个 3D 恐怖游戏。
“我当时很兴奋啊!3D 恐怖游戏,就完全是那个 3D 的!恐怖游戏!一个月鼓捣出来,不要什么 Jump Scare,就完全是心理恐怖,氛围!是不是很大胆!”不过我倒是没有放弃,因为根本轮不到放弃,我都没做完……实在是让人顿足捶胸、痛哭流涕。
书归正文,还是回到开发记录上来。因为我是第一次制作 3D 游戏,所以着重记录下怎么在 Godot 里实现一个 3D 第一人称游戏。
第一人称操作
第一人称是一种讨巧的办法,代入感好,甚至可以省略整个人物的模型。我这次需要的基本操作只有行走、跑步、旋转摄像头观察、以及查看手机——手机上有一些功能。
得益于以往对插件的关注和积累,我选中了一款 Godot 社区中的插件——CharacterController,在此基础上进行一番学习和改造。
纵观整个插件,它利用了 Godot 的节点组合思想,将 Player 分成了两个部分——摄像头操作和其他行为。行走、跑步、跳跃、下蹲、游泳、飞行等都是一些相对独立的行为,这些对 Player 移动行为上的操作被分成了一个个可选择挂载的 MovementAbility,当行为被挂载在 Player 上这些操作就生效了,它们分别控制 Player 主体的运动。
另一部分是摄像头操作。首先是视角旋转,将旋转分为了两部分:水平的旋转为人物本身的旋转,竖直的旋转为摄像头所在节点的旋转。这样做的好处是可以避免单个节点旋转的欧拉角锁问题,也方便理解和控制,因为并不涉及到歪头这种旋转方式。
同时,因为头的标记点是和身体分开的,在游戏中涉及到镜头运动的一些演出上,我就可以维持人物本身不动,而控制头标记的位移,实现例如从地面醒来并坐起、坐在凳子上、以正视图视角查看屏幕等操作。此时Player本身并没有移动,而是头标记在空间中运动。(我采用了两种动画方式,一种是可打断的、简单快速响应的 tween 动画,一种是演出复杂、不可打断的手动 k 帧动画)
同时得益于头部节点的独立性,更多的扩展效果得以加入,这些效果只需要读取根节点上数值的变化和事件的触发,做出对应的反应。例如走路时左右晃动模拟脚步的摄像头运动,根据步长播放脚步声,在摄像头前放置灯光和飞蝇这些我就不一一介绍了。感兴趣的朋友可以评论交流。
屏幕效果与UI交互
这次设计的 3D 场景中的主要难点是监控摄像头的查看和控制、对电脑中 UI 的独立控制。最开始我并没有想直接做在 3D 中,虽然效果比较好,但是也比较复杂,不如点击后直接弹出一个 UI 来查看和操作。不过我最终还是选择了 3D 直接展示,因为效果真的很棒!而且 Godot 引擎中很容易可以实现这一点。
游戏中的屏幕一共有三种:监控室的电视、资料室中的电脑、Player 手上的手机。
三种屏幕的基础显示都很简单,Godot 中有一个节点叫做 SubViewport。这个节点可以独立于当前主视口生成画面,如果学习过图形学/图形 API 的朋友应该能想到,这个功能是使用独立的 FrameBuffer 实现的——即在项目中放置多个相机渲染到不同的目标上,再将目标作为某个 Mesh 的贴图显示出来。需要注意的是,Mesh 的 UV 一定要正确,否则可能出现画面不全或者位置不正确的现象。对于像有 CRT 效果的电视和电脑显示器,就可以在这个贴图的基础上使用 Shader 实现了。
可能有的朋友会问,使用这么多 Camera 和 FrameBuffer 性能不会很糟糕吗。对我们项目来说其实还好,因为这次做的是复古低分辨率的类型,整个视口的分辨率很低(512*360),这些子视口的分辨率自然更低了,所以在没有特意做优化的情况下,也不会特别卡。同时,这些显示器在不注视的情况下图像是不会更新的,也优化了一定的性能。
然后就是最大的难点了,电脑屏幕中鼠标要和画面进行交互,可以选择车辆、切换监控画面等等。
好在 Godot 中,不同视口的事件是可以主动传递的(`vp.push_input(event)`),我在当前主视口操作时将鼠标相关事件加工并传递给电脑屏幕对应的视口。事件加工主要是修改鼠标相对于视口的位置坐标,在主视口中鼠标被隐藏,但它的位移要根据比例缩放转换成屏幕视口的坐标。点击选车时,在对应相机发出射线与停车场中的车辆求交,获取信息。
另外由于实现了坐标的转换,我也顺手实现了鼠标模型的移动,让操作电脑时鼠标的模型也跟着动起来,看起来还蛮有意思的。
地图搭建
不幸的是,由于使用的引擎太小众,我的 3D 美术小伙伴没用过,所以完全没办法帮我做地编工作,地图搭建的工作就只能落在我的头上。
至于搭好一个大场景再导入进引擎,那工作不比我自己搭地图要简单多少。因为灯光等物体有一些特殊操作(如不定时闪烁),物体很多需要写导入脚本,加上沟通成本……于是我开始琢磨使用引擎里的 GridMap,搭建一个网格地图。
说实话,Godot 里的 GridMap 不算是特别好用,至少不比它的 Tilemap 好用(这里强推一下 Godot 的 2D 游戏制作能力,Tilemap 等自带工具非常顺手,完全不用找个第三方瓦片地图工具比如 Tiled 之类的)。但功能还算齐全,而且这种网格地图的渲染一般是使用了 GPUInstance 技术实现的,一个模型在空间中渲染很多次也不会有太大的性能要求,比如有几千块地板什么的。
经过一段时间的使用,我总结的小技巧如下:
1. 网格库的创建:可以先建一个场景,使用 CSG 系列节点,对不同的网格进行合并,做出基础的组件,再安装一个 CSGToMesh 插件,就可以将一组 CSG 转换为单个 MeshInstance 节点了(只有 MeshInstance3D 和下属的导航网格或物理体可以导出网格)。对于一个单层、横平竖直的地图,一般只需要地板、天花板、地板与墙壁相连的两个面、角落的三个面,这四个网格组件,就可以搭建出来。导出网格库时,推荐“应用MeshInstance变换”,这样方便搭建时网格的对齐。
2. 网格地图的绘制:不推荐将所有的地图内容绘制在一个节点上,比如地面墙壁和天花板。在后期非网格地图的细致场景搭建中,可能需要俯视图观察和修改场景,如果地面和天花板是同一个网格地图节点,就没法“打开天窗说亮话”了。所以我选择了多个 GridMap 节点完成场景中不同物体的搭建工作。编辑时将天花板隐藏,所有的错误就无所遁形了(bushi
3. 单个网格不能满足的特殊物体:例如本次地图中,我需要所有的灯都是我自己实现的一个复杂场景,但全图有几十个这种灯,一个个手摆太烦人了也容易对不准位置。所以我使用了一个 Light 的 GridMap,其中的网格只用来做摆放位置的标记,在项目运行时,会在对应位置实例化场景,并把这些标记清空。
4. 网格地图工具操作:文档里这块没说清楚,绘制地图时,当不选中某个网格块的时候,视角是可以自由移动的,选中后 WASD 键位就变成了对网格块在不同轴上的旋转了。这时再按 ESC 键就可以退出网格块的选中,可以重新自由移动视角了。另外推荐使用三视图进行地板等横平竖直物体的绘制。
可交互物体和区域
在 3D 游戏中,需要和很多物体进行交互,例如查看、拾取、操作等等。在交互时基本操作都是,看向物体,物体提示操作的按键,按键后开始交互。
刚开始实现时,我将相关逻辑写在 Player 下,比如坐在椅子上、看屏幕等等。但当物品渐渐增多,Player 的代码变得臃肿起来,不好定位问题也不方便查看和扩展。于是我将交互物体部分进行了重构。
正如开头所写,物体的交互是有一套基本操作的。那么 Player 实际关心的只有对物体的注视,和一些基本状态的维护,把对物体的操作归还给物体本身,来实现一定程度的逻辑解耦。和直觉的逻辑实现不同,让物体自己负责被操作这件事,自己的事情自己做(
所以,把 Thing 抽象为一个父类,实现被注视状态的维护,当 Player 的射线拾取到 Thing 时,就将 Thing 设置为被注视。而各个不同的物体对 Thing 进行扩展,描述当被注视时不同的操作会对物体本身和 Player 造成什么影响。例如椅子会操作Player坐过来,电视会操作Player看过来,手机会消失并通知Player已经有手机了、身上的手机可以显示了等等。
跟上述思想类似,同时也和 Player 上挂载不同的 MovementAbility 类似,我将可互动的区域也做成了继承 Ability 父类的不同的行为。当 Player 进入区域和退出区域时会触发事件,而事件会通知所有是 Ability 类型的子节点。这些子节点定义了 Player 进出区域会进行的操作。在游戏中,部分区域回声、BGM、闪屏演出、音频片段的播放等,都是一个个 Ability,节省了很多硬代码的编写,方便在场景编辑时组合使用。
其他
Godot 在 4.3 中新增了交互式音频的能力,结合 AudioStreamPlayer3D 可以实现类似于音频中间件的音频过渡、条件触发音频、播放列表等功能。说实话,不算特别好用,但是总比没有强。这次 BOOOM 我也学习和试了一下。
新增的且我主要使用的是 AudioStreamInteractive,可以包含若干剪辑和一张过渡表,可以自己配置不同剪辑间切换时的过渡方式,但这个过渡方式没那么精细,只是在多少节拍间过渡,过渡哪个位置,播放完会自动跳转到哪个剪辑,有没有淡入淡出之类,对 db 和音调等等是没法控制的。 新增的另一个 AudioStreamPlaylist 我就没用上了,可以把多个子音频按列表形式播放,本次项目用不到。
另外是本来就有的功能 AudioStreamPlayer3D,可以根据距离衰减声音,我使用这个节点开发了通过车钥匙找车的功能。
如果队友里有可以用音频中间件制作音效音乐的还是推荐用音频中间件,解耦功能又全,我看了下,现在 Godot 社区中应该是对 wwise 和 fmod 都有插件支持,看 star 数量使用的人应该不少。
总结
因为时间排布一团乱麻,这次 BOOOM 不是 solo 胜似 solo。加上本职工作也一直在加班,熬得我是昏天黑地、地崩天裂、裂得不能再开了。不过好在是最后两天及时刹车,自己构想并补了一个效果十分勉强的演出作为结尾,把项目及时交上去了。
在演出结束后,我放了一个图:
这次的故事确实没讲完,有很多设想的东西没有加进去。风格化渲染我也不满意——因为没时间做本来想实现的类似 PS1 平台的渲染缺陷风格,大概率需要建模和程序的共同配合。好在氛围感确实做到了一些,在 3D 制作上也学习到了很多。
和结束语相同的是,我的故事确实还未结束,不同的是,我不会退出游戏。
共勉!