部分读者可能不知道这里的光枪是什么。下面这个游戏就是一个光枪游戏:
FPS的shooter就是指射击要素。现在我们有了一个角色,但是他除了在场景中到处晃之外什么也没法做。
准星
实际上很多偏真实的FPS的准星都是动态的,但是不管怎么花哨,基本的道理都是要在画面中间显示一个东西,这样我们才能知道我们要打哪里。
我们先做一个简单的静态准星,一方面可以让手上的游戏更像样一点,一方面也能够更方便地知道我们把摄像机对准到哪儿了。
像往常一样,作为UI元素,我们需要在场景中放一个CanvasLayer来容纳它,按照惯例,可以叫它HUD。我们可以把它放在玩家角色的场景中,后续我们可能要给他添加更多的和玩家相关的UI元素。
然后加入一个CenterContainer。顾名思义,这个容器会让其子节点保持在正中。接下来就是简单地放一个准星纹理到里面就算有模有样了。
提醒一下:由于这里是在一个3D场景中放入了一个CanvasLayer然后编辑一些非3D的节点,所以如果想在编辑器中看到它或者直接在视口中编辑,需要点击工具栏中间的2D和3D来切换。
现在启动游戏就能看到一个在画面中间的准星了。
我到底瞄准没有
我怎么知道我对着哪里?我怎么知道我朝这个方向开枪能不能打到敌人?
实际的情况可能会更复杂,但是这个问题最简单的抽象就是,从一个点(比如屏幕中心)发出一条射线,能否和空间中的某个几何体相交。
没错,这就是一个数学问题。但是游戏引擎的功能就是为了不让我们从头来解决这些问题。
主流的游戏引擎中都有相应的API来处理这个问题。比如Unity叫它Raycast(射线投射),Unreal叫它LineTrace(“直线”追踪,幸好它没叫RayTrace)。当然不管怎么取名字,做的事情都是差不多的,只是总得取个名字。
毫无疑问Godot也有相应的API,Godot在文档中选择叫它Raycast。
手动挡
Godot提供了一个相对较复杂的API来和物理系统交互来查询射线和具体可碰撞对象的相交情况。
因为要和物理系统交互,这里自然而然地要在_physics_process中写一些代码。我们定义一个fire方法,在_physics_process中适时调用。在先简述一下实现流程。
首先需要通过3D节点的get_world_3d方法获得当前的World3D的引用,这个World3D总之就是一个包罗万象的抽象资源,我们需要从它的direct_space_state属性中拿到当前物理系统状态。
实际进行射线投射的方法是space_state.intersect_ray,它的唯一参数是一个代表射线查询参数的PhysicsRayQueryParameters3D对象。这个函数的作用顾名思义,intersect就是“相交”,ray就是“射线”。
要调用这个方法,我们自然要填写一个PhysicsRayQueryParameters3D。对于一些参数比较复杂的函数,你会经常看到这种把一堆参数打包成一个单独的类的做法。一方面是方便配置,另一方面也是因为用一堆参数去调用一个函数的时候很难看,也不好排版。这个参数类最重要的自然是射线的起点和终点。起点,应该是屏幕中心,我们先试着从屏幕中心位置……
真的是屏幕中心吗?我们的屏幕并不是一个存在于游戏的3D世界中的一个物件,何来的“从屏幕中心引一条射线通过3D场景中的指定位置”呢?
一般来说我们的屏幕是2D的,就相当于把一系列像素排列好就得到了我们看到的画面。实际上,3D渲染引擎中的摄像机可以说是由一些参数定义的一个矩阵(或者说一系列矩阵),矩阵是一个明确的数学概念,它可以用来操作向量。3D场景中的东西,需要满足一定要求才会被渲染,它们首先会被变换(这实际上也是一个数学概念)到摄像机面前的一个平面上(就像是“被看到”),最后再变换到我们的屏幕上。
因此“屏幕中心”指的是透过摄像机“所看到”的这个画面的中心。它并不一定总是和我们所在的真实世界的屏幕中心在同一个位置上。这背后更多的数学知识,(可能)会在后面讲到。
Camera3D节点提供了一些方法来方便构造这种射线。比如这个名字就很直白的方法project_ray_origin,其参数是屏幕上的一个位置。这个方法会把屏幕上的一个位置转换(专业点说是变换)到3D空间中去。
project_ray_origin的唯一参数就是一个二维向量。它代表视口坐标
,因为显然我们游戏的画面并不总是等于整个屏幕,甚至于观察某个场景的视口也不完全等于整个窗口。所以刚才说的屏幕准确地说应该是视口。要获得视口信息,使用get_viewport即可拿到容纳当前节点最近的Viewport。然后通过其size属性即可拿到尺寸进而求出中心坐标。这样我们就求得了射线起点。
func fire(): var viewport_center = get_viewport().size / 2.0 var origin = camera.project_ray_origin(viewport_center) # TODO
理论上应该这样写没错。但是实际上在我们的情况下origin就始终等于摄像机的global_position。你可以试着给project_ray_origin传入任意值,只要摄像机不动,这个结果就应该是不变的。
前面讲到Godot(以及很多游戏引擎)的3D摄像机都有透视和正交两种模式,它们会按不同的规则将3D空间中要渲染的物体的各个顶点投影到这个虚拟摄像机面前的一个平面(近裁剪平面,图中的near)上。对于透视摄像机,这些射线的起点实际上都在同一个位置,想象一下从眼睛处发出无数条穿过并覆盖整个近裁剪平面的射线。3D空间中原本的点,和它经过透视投影投到近裁剪平面上的点连起来之后都会汇聚到同一个点上,但是方向各不相同。
而对于正交模式的摄像机,3D空间中的点和投影之后得到的点的连线都是平行的——但是方向相同。
因此在我们的情况中,这个origin换成camera.position也是一样的。
拿到这个点之后,我们就把它作为起点,然后确定一个终点。至少在FPS的情况下,这条射线应该朝着视线方向射出去。如何确定这个方向呢?正如刚才所说,透视情况下射线的起点不变,但是需要用终点确定方向。我们这里希望能够从屏幕中心射出去,那么自然就考虑想要“摄像机的前方”。如何获得这个前方呢?
先说结论,如果要真正地获得这个前方,可以这样写:
parameters.to = origin + (-camera.global_basis.z) * 1000
好吧,这里确实跳过了太多说明。主要还是背后涉及不少数学知识,这里不得不再次搬出那句话,后面可能会补充讲解。但是还是要简单说明一下。
当然最主要的就是这里的global_basis。global_basis等价于global_transform.basis。前面提到过transform属性保存了节点位置、旋转、缩放等属性的数据。但是呢,transform的主要数据其实就是一个origin和一个basis。
实际上每个(Node2D和Node3D)节点的transform都分transform和global_transform。前者实际上是局部(local)transform。为什么要分这个东西呢?
因为一个物体的位置、朝向信息在不同坐标系中有不同的值。
全局(或者说世界)坐标系就相当于一个最上层的根节点,在它之外就没有其它东西了,它自己也不会动。这样场景中每个东西都可以用这个坐标系的坐标来描述。可以说一定程度上,全局坐标可以理解为一种“绝对坐标”。它的原点就是(0, 0, 0),坐标轴和Godot的坐标系定义始终相一致。
假设我们的玩家角色在原点,然后使用rotate_y逆时针(从上往下看)转了90度。然后再让摄像机顺时针旋转90度。此时摄像机还是朝向前方(-z方向)。那么这个过程中什么变了什么没变呢?
rotate_*方法是对局部transform的basis进行修改(实际上Transform和Basis基本上是不可变类型,很多操作都会直接得到一个新的实例)。上述操作都是修改局部的朝向,和在检视面板中直接改数字是一样的。
局部transform在一定程度上可以解释为“相对transform”。试着在_ready中观察输出:
print(camera.global_rotation) print(camera.rotation) rotate_y(deg_to_rad(90)) print(camera.global_rotation) print(camera.rotation) camera.rotate_y(deg_to_rad(-90)) print(camera.global_rotation) print(camera.rotation)
摄像机作为玩家角色的子节点,玩家场景整个旋转时,其子节点也跟着旋转,进而摄像机和他的父节点的相对transform是不变的。
摄像机作为玩家角色的子节点,玩家场景整个旋转时,其子节点也跟着旋转,进而摄像机和他的父节点的相对transform是不变的。
但是作为观察者,我们跳出玩家角色的场景,从外部观察,此时摄像机的朝向已经发生变化,这个变化就体现在global_rotation上。
随后,我们又单独把摄像机转回去,此时它和整个玩家场景的根节点相对来说就不在同一个方向上,因此其rotation必然是不为0的(当然这取决于初始状态,我们这里的玩家场景的根节点和摄像机节点初始状态transform的各个数值都是默认值0)。
那么Basis到底是什么呢?Basis基本上对应着数学中的基(也称“基底”)概念。说严谨点它可以确定一个向量空间。说具体点但可能不那么严谨的话,就是它可以确定一个具体的坐标系。“坐标”实际上就是对组成基的向量进行线性组合(啥是线性组合?就是倍乘和相加)得到的一个向量。我们很多时候都是谈的一种特殊情况,那就是原点在(0, 0, 0),基为{i=(1, 0, 0), j=(0,1, 0), k=(0, 0, 1)}的坐标系。例如坐标为(3, 4, 5)的点意思就是它的坐标是3i+4j+5k。但实际上“坐标系”有无数个,甚至它的坐标轴也可以不相互正交(垂直)。
basis就是三个向量,分别表示这一个“坐标系”的x,y,z坐标轴到底是哪个方向。当然这里肯定是世界的、绝对的坐标来表示,毕竟我们总要有一个基准来表示其它非基准的东西。
总之,-camera.global_basis.z总是表示摄像机(在全局坐标系的)前方。这里的负号自然还是因为这是Godot规定的摄像机的“前方”。
这个前方总是表示摄像机真正的前方,因为它是“绝对”的,不管玩家节点本身朝哪一方。你可以试一下把global_basis换成basis是什么效果。
当然,这里其实也不用这么复杂:
parameters.to = origin + camera.project_ray_normal(viewport_center) * 1000
Camera3D
还提供了一个和project_ray_origin成对的方法project_ray_normal。normal在这里不是“正常”的意思,它表示数学概念“法线”(法向量)。这里指的就是从透过这个摄像机观察的视口上的某一点投射出来的射线的方向。实际上法线在很多时候指的是在曲面上某一点上和曲面垂直的直线,而通过法向量就可以确定“内外”或者这一点上曲面的“朝向”。
但是,为什么还要把origin加在前面呢?这一堆查询参数的to属性定义为射线检测的终点。把这个问题一般化,就是给定一个点,一个方向,那么从这个点沿某个方向引一条线,最后得到的点的坐标为多少?
例如起点为A点,射线方向为u(1, 0)。那么你就要求出B。把A点的坐标视为向量,那么向量a(对应B的坐标)就应该是A+u。这个可以按照中学讲过的所谓平行四边形法则来想,也可以简单地想成,把u的起点挪到我们的起点A处就得到了终点B。还是再次提醒一句,在用向量、坐标讨论这种几何问题时,我们很多时候不区分点和向量。但是,在具体的问题中,我们又可能要加以区分以便于理解。
但是,依然由于我们这里是从一个特殊的起点开始进行射线检测,因此不加上origin也是一样的。因为我们的origin本身就在这个方向上。
你的最后一个问题是为什么还给射线方向乘了个1000?首先,这个1000是我随便写的,它不一定要是1000。project_ray_normal返回的是一个长度为1的规范化之后的向量,所以它相当于只代表方向,不关心长度。但是这里所谓的射线检测的终点是由射线检测的范围(射线的“长度”)确定的,所以我们要乘上一个长度。这里你可以自己export一个变量,但应当是一个较大的数字。想象一下武器的射程。
最后,处理这个问题比较通用的写法是这样的:
var viewport_center = get_viewport().size / 2.0 var origin = camera.project_ray_origin(viewport_center) var parameters = PhysicsRayQueryParameters3D.new() parameters.from = origin parameters.to = origin + camera.project_ray_normal(viewport_center) * 1000 var result = get_world_3d().direct_space_state.intersect_ray(parameters)
一些游戏可能需要“从屏幕上点到场景中”,比如点击选中,或者各种类型游戏中的点击移动到指针位置,都没那么特殊,所以需要更通用的写法。但是我们这里足够特殊,所以可以简化成:
var parameters = PhysicsRayQueryParameters3D.new() parameters.from = camera.global_position parameters.to = camera.project_ray_normal(get_viewport().size / 2.0) * 1000 var result = get_world_3d().direct_space_state.intersect_ray(parameters)
说了这么多终于说到获得结果了。比较奇葩的是intersect_ray返回的是一个字典,具体包含哪些内容在文档中有说。当然我们目前主要关心两个东西,一个是collider,即谁和这条射线发生了碰撞;另一个是position,即在哪里发生了碰撞。
最后再提醒一句,射线检测需要被检测物能够进行碰撞检测才行。一般的对象需要你自己配置CollisionShape等节点,CSG的话就简单启用一下碰撞就可以了。
自动挡
其实上面手动挡的代码也不复杂,只是要解释原理的话要说很多。
Godot实际上还提供了一个更简单的节点专门用来进行射线检测。这个节点就叫RayCast3D(2D场景中为RayCast2D)。
RayCast3D节点最主要的属性就是target_position,也就相当于我们刚才求的to参数。它的射线由RayCast3d的位置和target_position确定。
设置了这个属性之后基本上就可以工作了。我们可以通过它的get_collider和get_collision_point来获得相交的对象和点。
那么和手动挡区别在哪儿呢?区别就在于RayCast3D会在每个物理帧(_physics_process)里进行检查,而手动挡的处理时机由我们控制。当然毫无疑问,我们在手动挡里能够实现更细粒度的控制。但是RayCast3D对于本身就需要持续检测的情况会更方便。
方便调试的击中指示物
现在就算打中了一个东西,我们最多在终端输出一下位置,我们没法在画面上看到任何东西。
为了方便开发过程中的调试,我们可以写一些简单的代码来表示我们击中了某个位置。这个功能对于现在的我们来说太简单了。我们都已经可以拿到击中的位置了,基本上也就没什么困难了。
当然,你可以单独建一个场景专门来表示这个指示物方便后续调整。不过这里我就简单地直接写代码了。
击中后,我在position处生成一个新的节点。这里我就直接构造一个CSGSphere,也就是一个简单的球体。
var indicator = CSGSphere3D.new() indicator.radius = 0.25 indicator.position = result.position var timer = get_tree().create_timer(3.0) timer.timeout.connect(func(): indicator.queue_free()) get_tree().root.add_child(indicator)
不过说到这里要注意一下。基本上只有Godot内置节点直接用构造函数(new)才能直接构造一个对应类型的节点。如果你有一个自己写的脚本然后试图用构造函数构造整个场景的话实际上是不行的。要构造自己的场景只能通过对场景的引用PackedScene调用instantiate来构造场景。
不过为了避免不小心或者故意在场景中生成了一大堆指示物,这里用个计时器定时销毁它。
很简单,现在启动游戏并射击就可以看到了。
一不留神,我们又实现了射击游戏中基本的射击功能。