首页 文章详情

一网打尽!炫酷枪火打击视频+图文+源码!哔哔哔......

Creator星球游戏开发社区 | 379 2021-12-29 11:57 0 0 0
UniSMS (合一短信)

前言

nowpaper人称一爸,是一个射击游戏的爱好者,从最早的Doom到Quake,再后来一路CS、荣誉勋章、使命召唤、守望先锋,FPS游戏一直让我痴迷其中。

对于射击游戏而言,一个好的子弹射击效果,绝对是射击游戏核心体验,目前我最喜欢的射击感、速度感和打击感的游戏,非《守望先锋》莫属。

子弹射出和打击到墙面瞬间的细节,虽然不起眼,但绝对是提升游戏品质的关键,这种体验在游戏开发中,如何实现的呢?

今天一爸就尝试一下,让我们在Cocos Creator中复刻一下守望先锋的枪弹射击效果。

《守望先锋》的美术和TA肯定不是我这半吊子能比的,因此我想在本视频中,能做出一个75分的效果即可,主要是讲解和研究,在Creator3中如何实现,《守望先锋》里的武器都太科幻,我们只借鉴它的枪弹表现力。

项目素材和源码

炫酷枪火打击实现

https://store.cocos.com/app/detail/3473
注意:仅为视频中的资产,不包含靶场、第一人称、第三人称、官方人物结合的有关内容

在线测试版地址,请选择《3D射击效果》
http://www.pktgame.com/ 19

效果

让我们先来看看成品大概是什么样子。这是一个模拟的靶场,滑杆调整角度,设置界面可以调整参数。

可设置项有子弹速度、偏移、弹容量,重填时间、射速、单次子弹数这六个参数,基本可以涵盖各种常规的射击枪械。

为了演示,枪械方面没做太复杂的模型,直接是方块代替。

在第一人称和第三人称的测试场景中,可以更加清晰的看到实际应用效果
动画2

特效原理

在特效方面我们做一下拆解,如果实现这样的子弹射击效果,需要以下几个方面,枪口喷射的火焰,子弹飞行的轨迹,击中目标后的特效,如果有条件的话还需要音效

动画3

枪口的火焰

枪口喷射的火光,我们参考一下实际效果

它是由外散火焰和一个散射的外圈组成,并且喷射时候会带上一个光晕。这么来看它至少有两个粒子系统来表现,使用一个粒子系统来制作喷射火光,参数中的核心数据是Bursts,这个火光粒子的生命周期实际上很短。

因此要用Bursts来表现它的短暂张力,后面的所有特效也是同样的处理,注意Bursts模块在3.3.0的版本中有bug,不能显示count数量,因此需要3.3.2以后的版本才能制作。

具体的参数就不列举了,这是一个非常消耗时间的工作,通过慢速给大家看一下它的具体组成。

枪口火焰是一个交叉的面片,给一个粒子材质随机旋转,并使用贴图动画模块切换纹理。

飞溅火焰是由一个喇叭型的模型,从小变大的动画过程,而光晕则使用了一大号爆发粒子,瞬间闪烁造成的视觉效果。

飞行的子弹

子弹飞行的轨迹相对简单,它是两个主要部分组成,一个冲击的粒子,一个是拖尾粒子。

冲击粒子由一个喇叭模型表现子弹破空效果,这和枪火那个喷射粒子基本一致,只不过它是以循环闪烁的方式表现。

拖尾粒子是在Z轴上拉长的单个循环粒子,同样也是用Bursts产生,来表示飞行中不稳定光感波动

击中的特效

击中墙壁效果,是所有粒子效果中最为复杂的,它由炸裂、火花、烟雾、斑痕、光晕,通过分解挨个说一下原理。

炸裂效果是命中时的溅射,使用两个开口模型粒子实现,采用和枪火喷射一样的处理即可,只不过它是缩小了一圈而已。

火花这个是最难的,我使用的是圆锥型喷射模块,随机飞溅出几个粒子,并且它还得带有重力的物理特性,除此之外大小也是一个难题,太大显得不真实,太小又看不清楚,调它的时候着实费了不少力气。

烟雾的表现还好,只需要一个简单上升粒子即可,虽然如此但它的数值想调得自然还是比较难。。。

命中斑痕经过研究后,发现很多游戏表现手法都是双层重叠,命中点一层,扩散点一层,命中点很快消失,扩散点会逐步消失。

更细一点作法是,依据物体表面材质,用不同贴图表示瘢痕,有得对此还使用了消解效果的shader,这方面我不想增加复杂度,因此就不用shader了,直接以渐变消失的粒子效果处理。

代码逻辑

在写代码之前,我们先做一下功能的需求分析,用下面的脑图来表示需要什么。

最基础的就是枪和子弹,枪械代码主要的功能是发射子弹,它通过Prefab来创建子弹,从发射点发射出去,发射过程需要扳机控制,对应的会产生喷射特效,枪火特效可以重复使用一个粒子特效,不用每次都产生。

如果想做出真实的枪械射击感,我们需要对枪械的参数进行细分,让我们来看看射击游戏各种参数到底有多丰富。

这是一款吃鸡游戏的参数列表,各种参数组合就成了各种不同的枪械。

在这里,我只用了最具代表性的五个射击参数,和一个射击偏移物理参数,这些组合足够我们做出大部分的常规枪械了。

子弹的需求就不用这么细分了,仅仅需要速度、移动方向向量、存在时间,它的最主要的功能就是处理移动和进行碰撞检查。

子弹算法原理

我们先来想想在游戏开发中,开枪射击的两种常规开发方式。

  • 第一种是射线检查
  • 第二种是物理碰撞

先说第一种射线检查思路,当射击后枪械指向方向会出一条射线,射线命中模型的点,就是击中点,然后我们在这个基础上做出两种方案。

一是直接命中,没有子弹的事,也就是说开枪的瞬间直接命中了目标,完全没有考虑速度问题,这种对于近距离是没问题的,但是远距离的话。。。如果想看到弹道,那就是不可能的。

二是在世界中产生一个子弹,依据发射点和命中点的距离,和子弹的飞行速度,计算一个插值运动,让飞行粒子沿着它飞到目标即可。但是你会发现一个致命问题,如果子弹速度过慢,在它的弹道中间突然出现了物体,也不会击中物体的。

第一种射线检查似乎不太完美,毕竟子弹命中目标,不是和开火同一个时间发生,那么使用子弹碰撞是否可以呢?

子弹在飞行中碰到什么就是什么,但是碰撞在高速移动的物理世界中,并不能简简单单的这么处理,因为游戏世界不是真实世界,就比如可能会穿模,也可能碰撞点和预期击中点不一致。
动画5

这么看起来似乎到了死胡同

那么,那种更加合适呢?如果是你怎么做呢?

最佳的处理方案是,两者结合,准确的说是各自取了一部分。

在开火的时候,我们仍然让子弹产生,并且按照预定的轨迹飞行,当然了,这个子弹可以可见,也可以不可见,通常为了游戏体验,我们都会弄一个粒子特效让飞行过程可见,子弹飞行的过程中,要用物理碰撞检查吗?

其实不然,应该采用射线检查,没错就是让子弹进行射线检查,而不是发射器发射出去的射线。
为什么这么说,我们这样来看,子弹在飞行的时候,它的下一个点的轨迹是可以预测的。从当前帧的点到下一个帧的点,这就是一条射线,如果这条射线命中了任何符合条件的碰撞体,就可以判定是命中了。
由于射线检查可以明确的得到碰撞点信息(PhysicsRayResult),因此它完全可以作为下一帧的子弹命中点。
当然了,也可以加入物理碰撞体来增加真实度,比如子弹重力和风力影响,这需要作额外的运算,有机会再填坑 有了这个思路,我们就可以按照它写代码了。

子弹代码

关于子弹组件的脚本代码,需要speed、vector变量作为计算处理。

其中vector我们做一下处理,在被赋值的时候,对速度进行一次计算,标记它在一个单位时间内应该走多远,这样做是为了避免额外的计算量。

在Update里面加入向量移动,并且在移动之后检查下一帧是否会碰撞到任何刚体。

我们写一个检查方法,按照前面说的原理,通过步长长度和向量的计算,引出一条射线,用它到物理世界中检查它前方是否碰撞,如果有碰撞,则处理碰撞逻辑。

「BulletSc.ts 的代码」

import { _decorator, Component,  Vec3, v3, geometry, physics, RigidBody, game } from 'cc';
import { AutoRecycleSc } from './AutoRecycleSc';
import { ImpactHelperSc } from './ImpactHelperSc';
const { ccclass, property } = _decorator;

@ccclass('BulletSc')
export class BulletSc extends Component {

    private _speed: number = 200;
    public get speed(): number {
        return this._speed;
    }
    public set speed(v: number) {
        this._speed = v;
        if (this._vector) {
            this._vector = this._vector.normalize().multiplyScalar(this.speed);
        }
    }
    private _vector: Vec3 = null;
    setVector(v: Vec3) {
        this._vector = v.clone().multiplyScalar(this.speed);
        this.node.forward = v;
        BulletSc.preCheck(this, this.speed / 60);
    }
    private _vec3 = v3();

    update(deltaTime: number) {
        if (this._vector) {
            Vec3.multiplyScalar(this._vec3, this._vector, deltaTime);
            this.node.position = this.node.position.add(this._vec3);
            BulletSc.preCheck(this, this._vec3.length());
        }
    }
    private static preCheck(b: BulletSc, len: number) {
        const p = b.node.worldPosition;
        const v = b._vector;
        const ray = geometry.Ray.create(p.x, p.y, p.z, v.x, v.y, v.z);
        const phy = physics.PhysicsSystem.instance;
        if (phy.raycast(ray, 0xffffff, len)) {
            if (phy.raycastResults.length > 0) {
                let result = phy.raycastResults[0];    
                game.emit(ImpactHelperSc.AddImpactEvent,b,result);
                if (result.collider.getComponent(RigidBody)) {
                    result.collider.getComponent(RigidBody).applyForce(b._vector, result.hitPoint);
                }
                if (b.getComponent(AutoRecycleSc))
                    b.getComponent(AutoRecycleSc).recycle();
            }
        }
    }
}

处理碰撞判定这个计算方法,不是每个对象独有的,因此使用静态方法会是一个不错的选择。

在设置向量的位置也要进行一次判定,这是因为有时候速度很快,它创建的时候,在下一帧先进行了移动,直接飞到了很远的地方,再去检查的时候可能就不对了,所以在子弹生成的瞬间就要进行判定,避免穿模。

另外我们再建立一个自动回收的脚本,将来可以挂在子弹、瘢痕、弹壳等上面。

通过一个延迟时间变量,在合适的时机自动回收掉物体,有了这个脚本,以后可以很方便的扩展出对象池回收站的功能,在本文中就不多赘述了。

import { _decorator, Component, Node } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('AutoRecycleSc')
export class AutoRecycleSc extends Component {
    @property
    deltaTime = 5;

    update (dt: number) {
        this.deltaTime -= dt;
        if(this.deltaTime <=0){
            this.recycle();
        }
    }
    recycle(){
        this.deltaTime = 1000;
        this.node.destroy();
    }
}

枪械逻辑

枪械的组件脚本,利用ccclass制作一个配置项GunOverView,包含枪械的概述,包含子弹速度、弹夹大小、射击速度、重填时间、同时子弹数,以及偏移震动的范围参数,通过可外部引用属性,来获取到枪火特效,子弹发射点,子弹的预制体,这些是从场景或者项目中需要获得的对应的引用。

@ccclass("gun_overview")
export class GunOverView {
    @property
    bulletSpeed = 200;
    @property
    ammoPerMag: number = 10;
    @property
    timeBetweenShots: number = 0.3;
    @property
    timeReload: number = 1;
    @property
    meanwhile: number = 1;
    @property
    speadValue: number = 1;
}

3个变量来处理射击状态、计算缓存,一个计数器,计数器是用来计算射击、子弹消耗、重填计时。

isShotting = true;
private vec3: Vec3 = v3();
private timer: Timer = new Timer();
// other class
class Timer {
    shot: number = 0;
    ammo: number = 0;
    reload: number = 0;
}

射击方法里,同时射出的子弹数量循环创建子弹,封装一下createBullet。

createBullet() {
// to create bullet
}

枪口的朝向这个向量,就是子弹要沿着飞行的向量。

当子弹创建的同时,设置起始位置,设置速度,飞行向量需要重新计算一下。

因为我们还有一个重要的体验参数就是震动,按照角度随机将飞行向量做一下旋转。

这里是用向量变换和四元数相乘,获得新的向量。

新向量就是子弹的朝向方向,因此我们把它设置到子弹脚本里的向量即可。

// ... Part
@property(GunOverView)
gunOverview: GunOverView = new GunOverView();
@property(Node)
fireEffect: Node = null;
@property(Prefab)
bullet: Prefab = null;
@property(Node)
muzzleNode: Node = null;
// ... Part
createBullet() {     
    this.vec3 = this.muzzleNode.forward.clone();
    const b = instantiate(this.bullet);
    director.getScene().addChild(b);
    b.setWorldPosition(this.muzzleNode.worldPosition);
    b.setWorldRotation(this.muzzleNode.worldRotation);
    b.getComponent(BulletSc).speed = this.gunOverview.bulletSpeed;
    let rot = this._quat;
    const speadValue = this.gunOverview.speadValue;
    Quat.fromEuler(rot, (Math.random() * 2 - 1) * speadValue,
        (Math.random() * 2 - 1) * speadValue, (Math.random() * 2 - 1) * speadValue);
    Vec3.transformQuat(this.vec3,this.vec3.normalize(),rot);
    b.forward = this.vec3;
    b.getComponent(BulletSc).setVector(b.forward);
    b.worldScale = this.muzzleNode.worldScale;
}
// ... Part

Update中计算计时器,按照射击条件发射,当子弹的数量足够的时候,计算射击冷却时间。

产生发射行为,子弹随之消耗增加,当达到最大的时候触发reload,整体的流程就是这样。

其中很多代码可以提取出来,比如射击、创建子弹、重置状态等等。

update(dt:number){
    if(!this.isShotting)return;
    if(this.timer.ammo > 0){
        this.timer.shot += dt;
        if(this.timer.shot >= this.gunOverview.timeBetweenShots){
            this.timer.shot = 0;
            this.shot();
            this.timer.ammo -= 1;
            this.timer.reload = 0;
            if(this.timer.ammo <=0){
                //重填
            }
        }
    }else{
        if(this.timer.reload >= this.gunOverview.timeReload){
            this.timer.ammo = this.gunOverview.ammoPerMag;
            this.timer.reload = 0;
        }
        this.timer.reload += dt;
    }
}
resetState(){
    this.timer.shot = this.timer.ammo = this.timer.reload = 0;
}

射击方法里我们会尝试调用粒子系统,目前我用了一种遍历子节点的方式,播放粒子特效。

所以还要写一个粒子效果帮助者的类,这个帮助类可以在多个地方使用 ParticleEffectHelper.ts。

import { ParticleSystem,Node } from "cc";
export module ParticleEffectHelper{
    export function Play(node:Node){
        const arr = node.getComponentsInChildren(ParticleSystem);
        for(let a of arr){
            a.stop();
            a.play();
        }
    }
}

命中表现

正如我们前面提到的,当命中的时候,我们可以获得碰撞点。在碰撞点的位置上生成瘢痕特效,除此外还需要依据碰撞面的法线,来确定生成面的朝向旋转。

为此,需要写一个命中点管理组件脚本,它的作用是为合适的碰撞点添加击中效果。

比如游戏中,命中到墙壁之类的要处理瘢痕,命中敌人就直接飙液体了。

所以这个组件脚本,我们通过监听一个添加碰撞消息,来处理碰撞事件,在事件接收参数中包含子弹信息,和物理命中点的射线信息。

在此,计算和处理命中点的特效位置和朝向,射线命中测试中包含了命中法线信息,命中特效的朝向跟着法线指向即可。

最终将生成的特效添加到目标物体上,现在回到子弹的脚本中,为它的命中时添加事件派发,告诉命中帮助脚本击中目标了。

ImpactHelperSc.ts

import { _decorator, Component, Node, Prefab, game, PhysicsRayResult, instantiate } from 'cc';
import { BulletSc } from './BulletSc';
const { ccclass, property } = _decorator;
 
@ccclass('ImpactHelperSc')
export class ImpactHelperSc extends Component {
    public static AddImpactEvent:string = "AddImpactEvent";
    @property(Prefab)
    impact1:Prefab = null;

    start () {
        game.on(ImpactHelperSc.AddImpactEvent,<any>this.onAddImpactEvent,this);
    }
    private onAddImpactEvent(b:BulletSc,e:PhysicsRayResult) {
        const impact = instantiate(this.impact1);
        impact.worldPosition = e.hitPoint.add(e.hitNormal.multiplyScalar(0.01));
        impact.forward=e.hitNormal.multiplyScalar(-1);
        impact.scale = b.node.scale;
        impact.setParent(e.collider.node,true);
        
    }
}

现在返回到Creator中,简单拼接一下枪械射击点,然后放置一个墙面。

由于Creator默认场景实在一言难尽,我只得自己手动调整一下,达到令人满意的效果。

制作一个简单的枪械发射器,模样看起来差不多就可以,做一个子弹Prefab,放上特效,并且挂子弹组件脚本,以及自动回收脚本,之后稍微花一些时间修正一下。

将这个帮助脚本ImpactHelperSc,添加到一个场景节点上,再把命中点的prefab添加给它引用项。
image

完成现在试试效果,给摄像机加入了自由控制脚本,飞近一点看看如何。

为了确认弹坑点位置和朝向的准确性,弄一个圆球,可以看出命中点的特效还是很不错的。

虽然和守望先锋有很大的差距,但是已经提供了特效思路,其实其中还有更多的优化空间。

动画6

注意事项

请注意,本特效的视频制作时,使用的是Creator3.3.2版本。

由于粒子的shader运算问题,官方引擎中的代码块,在处理模型粒子的时候,不支持跟随节点转动,这个问题对我来本来无解。

但Creator技术群内的大佬炫烨,给出了及时帮助,提供了一个正确的代码块,否则的话,我根本无法完成它,在此特别表示道谢。

3.4版本,本视频中提到的粒子特效不能跟着旋转的问题,已经解决了,这次粒子系统更新,能够让粒子指定参照坐标系,因此不需要替换代码块。

结束

我是Nowpaper,一个混迹游戏行业的老爸,感谢的阅读,如果您喜欢这篇文章,在B站和cocos论坛支持一下,那就是对我莫大的鼓励,我们下次再见!



good-icon 0
favorite-icon 0
收藏
回复数量: 0
    暂无评论~~
    Ctrl+Enter