Cocoscreator: 用shader实现3D弯曲赛道

1,028 阅读9分钟

背景

在3D跑酷游戏中,为了提高游戏的可玩性,很多游戏往往会添加丰富的障碍和道具,除此之外,有些游戏还通过一些视觉效果来使游戏画面看起来更丰富有趣,如下图游戏中采用了曲面赛道,让游戏更有立体感和纵深感。

本文就来讲讲如何在Cocos Creator3D中实现弯曲或曲面赛道。

方案分析

要实现曲面的效果,有几种方案:

直接使用曲面模型

这是最直观最容易想到的实现方案,从模型层面直接将效果做好,省去了其他处理,但这种方案也存在着很多严重的问题:

  • 模型复用不便,模型生成时的状态几乎决定了它的使用场合,这对于游戏开发中需要大量复用资源以减小包体来说有严重的问题。

  • 对于跑酷游戏这种物理需求并不复杂的游戏来说,大部分的游戏逻辑都可以直接通过计算直接完成而并不需要依赖物理引擎实现,对于正常的模型来说,规则的形状对于逻辑实现是很友好的,但是启用曲面模型就会对这种计算带来很多困难,几乎只能通过使用物理引擎来实现,过多的物理计算对性能是会有较大的影响的。

包体不友好,性能不友好,异型模型还会对制作带来麻烦,对于只是为了实现显示效果来说,这些损耗得不偿失。

使用材质系统

要实现曲面效果,实际上影响的只有现实效果,与其他的任何系统是不相关的,它不应当影响到其他无关的模块,既然只改变显示,那采用材质系统相交与采用曲面模型的方案有着诸多优势:

  • 不必使用物理引擎,简单的物理效果可以通过计算来实现,效率更优。
  • 模型可复用,想要实现不同的弯曲效果也很方便,只要使用带有曲面效果的不同参数的材质即可实现同一模型的不同效果。相较于方案一的多重模型来说,只需要几个材质即可解决问题。
  • 参数可配置,可以通过参数调节来得到不同的效果。

分析看来,相较于直接使用曲面模型的方案来说,使用材质系统实现的方案优势很明显,没有额外的开销,也没有太大的包体负担。

综上所述,使用材质系统实现更能满足我们的需求,因此采用材质系统来实现这个效果。

思路分析

从需求来看,目的是实现一个与我们的观察点相关的模型变形,既然只是变形,并不涉及到颜色的变化和处理,那么需要处理的就只有顶点着色器部分,并不涉及片段着色器。

在 Shader 中,通过顶点着色器即可完成对模型顶点位置的操作。

明确了是对顶点位置进行操作后,我们将摄像机所在的点定为原点。

由于我们的摄像机是固定在人物背后,且赛道始终保持向 Z 轴负方向延伸,所以可以将模型与摄像机的 Z 轴方向的距离看作函数的输入值,想要得到曲面的效果,模型的点的变化规律如下:

距离原点越远的点产生的偏置值越大(函数在区间内为增函数) 距离原点越远的点偏置的变化速度越快(函数的导数为一次函数) 由上述两条规律不难得出,二次函数的变化规律与我们想要实现的曲面效果的规律契合,所以我们的顶点着色器的运算为一个关于顶点位置 Z 值的二次函数运算。

我们刚刚得出的规律是建立在一个特定空间下的,即以摄像机为原点的空间,这个空间正是空间变换中的观察空间阶段,所以我们之后对顶点的操作正是在这个空间中进行才能够得到正确的结果。

具体实现

  1. 创建名为curved的effect文件
CCEffect %{
  techniques:
  - name: opaque
    passes:
    - vert: unlit-vs:vert
      frag: unlit-fs:frag
      // property 列表将会将属性暴露在编辑器面板上方便我们的编辑和更改。
      properties: &props
        mainTexture:  { value: grey         }
        allOffset:    { value: [0, 0, 0, 0] }
        dist:         { value: 1            }
  - name: transparent
    passes:
    - vert: unlit-vs:vert
      frag: unlit-fs:frag
      depthStencilState:
        depthTest: true
        depthWrite: false
      blendState:
        targets:
        - blend: true
          blendSrc: src_alpha
          blendDst: one_minus_src_alpha
          blendDstAlpha: one_minus_src_alpha
      properties: *props
}%

CCProgram unlit-vs %{
  precision highp float;
  #include <cc-global>
  #include <cc-local-batch>
  #include <input>

  in vec2 a_texCoord;
  out vec2 v_uv;

  // 需要决定哪些数据是需要作为 uniform 传入 shader 中对效果做出影响的了,结合之前分析的需求:
  // 需要有一个决定模型点在各个分量轴上偏置值的偏置位置信息,我们使用一个 vec4 来存储这个偏置值(allOffset); 
  // 需要有一个决定偏置变化的系数的值,使用一个 float 即可(dist); 还可以添加模型的主贴图等(mainTexture)
  uniform Constants {
    vec4 allOffset;
    float dist;
  };

  highp vec4 vert () {
    vec4 position;
    // 按照引擎要求对接骨骼动画和数据解压,直接在开头调用 CCVertInput 工具函数。
    CCVertInput(position);

    highp mat4 matWorld;
    // 模型资源在场景中可能出现很多重复的,这样就需要对模型进行动态合批,对接引擎的动态合批流程,在包含 cc-local-batch 头文件后,通过 CCGetWorldMatrix 函数获取世界矩阵。
    CCGetWorldMatrix(matWorld);

    //-   在分析时提到,需要在观察空间下对顶点进行处理,所以需要将坐标转换到观察空间下。曲面效果是和 Z 坐标直接相关的,所以系数也是直接影响 Z 坐标的。
    //-   dist 系数为影响变化的系数,所以在和 vpos.z 的运算时,可以使用乘法也可以使用除法,但这个改变会直接影响 dist 的取值,所以在决定是使用除法还是乘法后,需要对值进行对应修改,且注意使用除法时 dist 的值不可为 0。
    //-   对于各轴分量的修改,需要 allOffset 参与运算然后造成影响,此处的 zOff 的平方运算即为分析中的二次函数符合变化规律的实现。
    //-   在处理完成之后,按照正常的变换逻辑继续将观察空间通过投影矩阵变换为裁剪空间下的坐标之后继续传递给片段着色器即可。
    highp vec4 vpos = cc_matView * matWorld * position;
    highp float zOff = vpos.z / dist;
    vpos += allOffset * zOff * zOff;
    highp vec4 pos = cc_matProj * vpos;

    v_uv = a_texCoord;
    #if FLIP_UV
      v_uv.y = 1.0 - v_uv.y;
    #endif
    return pos;
  }
}%

CCProgram unlit-fs %{
  precision highp float;
  #include <output>

  in vec2 v_uv;
  uniform sampler2D mainTexture;

  vec4 frag () {
    vec4 o = vec4(1, 1, 1, 1);

    o *= texture(mainTexture, v_uv);

    return CCFragOutput(o);
  }
}% 
  1. 使用模型road01.fbx, 将模型材质中的Effect替换成curved文件

image.png

  1. 导入球的模型,并创建Ball组件,用来控制球在Z轴负方向的移动和旋转
import { _decorator, Component, Vec3, Quat } from 'cc';
const { ccclass } = _decorator;

@ccclass('Ball')
export class Ball extends Component {
    speed: number = 20;
    // 立即加速
    increaseSpeedImmediately() {
      if (this.speed < 200) {
        this.speed += 8;
      }
      this.speed = this.speed > 200 ? 200 : this.speed;
    }

    // 平滑加速
    increaseSpeed (deltaTime: number) {
      if (this.speed < 40) {
        this.speed += deltaTime*8;
      }
      this.speed = this.speed > 40 ? 40 : this.speed;
    }

    // 平滑减速
    decreaseSpeed (deltaTime: number) {
      if (this.speed > 4) {
        this.speed -= deltaTime*8;
      }

      this.speed = this.speed < 4 ? 4 : this.speed;
    }

    quat: Quat = new Quat();
    // Z轴负方向移动
    moveForward(deltaTime: number) {
      this.node.position = this.node.position.add(new Vec3(0, 0, -this.speed*deltaTime));
      // 获取球旋转的四元数
      Quat.rotateX(this.quat, this.quat, -0.1 * this.speed/4);
      // 赋值旋转四元数
      this.node.rotation = this.quat;
    }
}

将Ball.ts挂载在球节点上

image.png

  1. 创建Follow组件,用来控制摄像机跟随球
import { _decorator, Component, Node, Vec3 } from 'cc';
const { ccclass, property } = _decorator;

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

    // 球节点
    @property(Node)
    target: Node | null = null;

    // 和球节点的位置偏移量
    @property(Vec3)
    offset:Vec3 = new Vec3();

    // 增加摄像机视野
    increaseDistance(deltaTime: number) {
        let z = this.offset.z;
        if (z < 20) {
            z += deltaTime;
        }
        z = z > 20 ? 20 : z;
        this.offset.z = z;
    }

    // 减小摄像机视野
    decreaseDistance(deltaTime: number) {
        let z = this.offset.z;
        if (z > 5) {
            z -= deltaTime;
        }
        z = z < 5 ? 5 : z;
        this.offset.z = z;
    }

    tmpPos = new Vec3();
    update(deltaTime: number) {
        // 获取球节点的位置
        this.target?.getPosition(this.tmpPos);
        // 球的位置叠加偏移量
        this.tmpPos.add(this.offset);
        if (this.tmpPos.x <= -0.5) {
            this.tmpPos.x = -0.5;
        }
        if (this.tmpPos.x >= 0.5) {
            this.tmpPos.x = 0.5;
        }
        this.node.position = this.tmpPos;
    }
}

将Follow.ts挂载到摄像机上,并将球节点拖到target上

image.png

5,创建GameScene.ts,用来控制游戏

import { _decorator, Component, Prefab, Node, instantiate, Vec3, Material, MeshRenderer, Vec4, input, Input, EventKeyboard, KeyCode, EventMouse, view, EventTouch } from 'cc';
import { Ball } from './Ball';
import { Follow } from './Follow';
const { ccclass, property } = _decorator;

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

    // 赛道预制体
    @property(Prefab)
    whitePlane: Prefab = null;

    // 赛道根节点
    @property(Node)
    ground: Node = null;

    // 球节点
    @property(Node)
    playBall: Node = null;

    // 摄像机节点
    @property(Node)
    camera: Node = null;

    // 赛道节点的最新Z坐标
    _planePosZ: number = 0;

    // 赛道节点列表
    roadList: Node[] = [];

    isLeftDown:boolean = false;
    isRightDown:boolean = false;
    isIncrease: boolean = false;
    isDecrease: boolean = false;

    // 赛道弯曲偏移列表
    offsetArr: Vec4[] = [
        new Vec4(-30, 0, 0, 0),
        // new Vec4(-20, 0, 0, 0),
        new Vec4(-10, 0, 0, 0),
        // new Vec4(0, 0, 0, 0),
        // new Vec4(10, 0, 0, 0),
        // new Vec4(20, 0, 0, 0),
        // new Vec4(30, 0, 0, 0),
    ];

    static BEND_TIMES: Array<number> = [1000, 2000];//, 7E3, 1E4, 11500, 1E4, 11500];

    /**
     * 当前弯曲时间
     */
     private _curBendTime: number = 0;
     /**
      * 弯曲索引
      */
     private _dstIndex: number = 0;
     /**
      * 弯曲改变时间
      */
     private _changeTime: number = 0;
     /**
      * 当前弯曲角度
      */
     private _curBendAngle: Vec4 = new Vec4(0, 0, 0, 0);

     private _ballScript: Ball | null = null;
     private _ballMeshRenderer: MeshRenderer | null | undefined = null;
     private _ballMaterial: Material | null | undefined = null;
     private _ballMaterial2: Material | null | undefined = null;

    time = 0;
    start() {
        input.on(Input.EventType.KEY_DOWN,this.onKeyDown,this);
        input.on(Input.EventType.KEY_UP,this.onKeyUp,this);
        input.on(Input.EventType.KEY_PRESSING,this.onKeyDown,this);
        input.on(Input.EventType.MOUSE_MOVE,this.onMouseMove,this);
        input.on(Input.EventType.TOUCH_MOVE,this.onTouchMove,this);
    }

    onLoad() {
        this.playBall.position = new Vec3(0, 0.3, 0);
        const ball: Node | null = this.playBall!.getChildByName("Solid_001");
        this._ballScript = this.playBall.getComponent(Ball);
        this._ballMeshRenderer = ball?.getComponent(MeshRenderer);
        this._ballMaterial = this._ballMeshRenderer?.materials[0];
        this._ballMaterial2 = this._ballMeshRenderer?.materials[1];
    }

    // 控制弯曲
    updateBendAngle(deltaTime: number) {
        this._curBendTime += (this._ballScript?.speed || 0) / 20 * deltaTime * 1000;
        if (this._curBendTime >= this._changeTime) {
            this._dstIndex = this.randomAngle(this._dstIndex);
            this._changeTime = GameScene.BEND_TIMES[this._dstIndex];
            this._curBendTime = 0;
        }
        var a = this._curBendTime / this._changeTime;
        var dstAngle = this.offsetArr[this._dstIndex];
        var bendAngle = new Vec4(0, 0, 0, 0);
        Vec4.lerp(bendAngle, this._curBendAngle, dstAngle, a / 20);
        this.setBendAngle(bendAngle);
    }

    setBendAngle(angle: Vec4) {
        // 设置赛道的shader 钉钉偏移量
        this._curBendAngle = angle;
        this.roadList.forEach(item => {
            const meshRenderer = item?.getComponent(MeshRenderer);
            const material = meshRenderer?.materials[0];
            material?.setProperty("allOffset", angle);       
            material?.setProperty("dist", 80);      
        })

        // 设置球的shader 钉钉偏移量
        this._ballMaterial?.setProperty("allOffset", angle);
        this._ballMaterial?.setProperty("dist", 80);
    }

    /**
     * 随机角度索引
     * @param dstIndex
     */
    private randomAngle(dstIndex: number) {
        var randomIndex: number = 0;
        do {
            randomIndex = Math.floor(Math.random() * GameScene.BEND_TIMES.length);
        }
        while (randomIndex == dstIndex);
        return randomIndex;
    }

    update(deltaTime: number) {
        this.playBall.getComponent(Ball)?.moveForward(deltaTime);

        // 创建赛道
        if (this._planePosZ - this.playBall.position.z <= 200) {
            this.createPlane(this._planePosZ);
            this._planePosZ -= 2;
        }

        // 平滑加速
        if (this.isIncrease) {
            this.playBall.getComponent(Ball)?.increaseSpeed(deltaTime);
            this.camera.getComponent(Follow)?.increaseDistance(deltaTime);
        }

        // 平滑减数
        if (this.isDecrease) {
            this.playBall.getComponent(Ball)?.decreaseSpeed(deltaTime);
            this.camera.getComponent(Follow)?.decreaseDistance(deltaTime);
        }

        // 删除摄像机后面的赛道节点
        for (let i = 0; i < this.roadList.length - 1; i++) {
            const road = this.roadList[i];
            if (road.position.z - this.camera.position.z >= 2) {
                road.destroy();
                this.roadList.splice(i, 1);
                i--;
            }
        }

        this.updateBendAngle(deltaTime);

    }

    // 创建赛道
    createPlane(posZ: number) {
        const plane = instantiate(this.whitePlane);
        plane.position = new Vec3(0, 0, posZ);
        plane.scale = new Vec3(2, 0.1, 2);
        plane.parent = this.ground;
        this.roadList.push(plane);
    }

    onKeyDown(event:EventKeyboard) {
        if(event.keyCode == KeyCode.KEY_A){
            this.isLeftDown = true;
            if (this.playBall.position.x > -0.8)
                this.playBall.position.add(new Vec3(-0.1, 0, 0));
            console.info("ball position: ", this.playBall.position.x);
            console.log('key down A');
        }

        if(event.keyCode == KeyCode.KEY_D){
            this.isRightDown = true;
            if (this.playBall.position.x < 0.8)
                this.playBall.position.add(new Vec3(0.1, 0, 0));
            console.info("ball position: ", this.playBall.position.x);
            console.log('key down D');
        }

        if(event.keyCode == KeyCode.KEY_W) {
            this.isIncrease = true;
        }

        if(event.keyCode == KeyCode.KEY_S) {
            this.isDecrease = true;
        }

        if(event.keyCode == KeyCode.KEY_F) {
            this.playBall.getComponent(Ball)?.increaseSpeedImmediately();
        }

        const follow = this.camera.getComponent(Follow);
        if(event.keyCode == KeyCode.ARROW_LEFT) {
            follow?.offset.add(new Vec3(-0.2, 0, 0));
        }

        if(event.keyCode == KeyCode.ARROW_RIGHT) {
            follow?.offset.add(new Vec3(0.2, 0, 0));
        }

        if(event.keyCode == KeyCode.ARROW_UP) {
            follow?.offset.add(new Vec3(0, 0.2, 0));
        }

        if(event.keyCode == KeyCode.ARROW_DOWN) {
            follow?.offset.add(new Vec3(0, -0.2, 0));
        }
    }

    onKeyUp(event:EventKeyboard) {
        if(event.keyCode == KeyCode.KEY_A){
            this.isLeftDown = false;
            console.log('key down A');
        }

        if(event.keyCode == KeyCode.KEY_D){
            this.isRightDown = false;
            console.log('key down D');
        }

        if(event.keyCode == KeyCode.KEY_W) {
            this.isIncrease = false;
        }

        if(event.keyCode == KeyCode.KEY_S) {
            this.isDecrease = false;
        }
    }

    // 鼠标滑动控制球左右移动
    onMouseMove(event:EventMouse) {
        const deltaX = event.getLocation().x - event.getPreviousLocation().x;
        const size = view.getVisibleSize();
        let deltaMove = 6 * deltaX/size.x;
        if (deltaX < 0) {
            if (deltaMove + this.playBall.position.x <= -0.9) {
                deltaMove = -0.9 - this.playBall.position.x;
            }
            if (this.playBall.position.x > -0.9) 
                this.playBall.position.add(new Vec3(deltaMove, 0, 0));
        } 
        if (deltaX > 0) {
            if (deltaMove + this.playBall.position.x >= 0.9) {
                deltaMove = 0.9 - this.playBall.position.x;
            }
            if (this.playBall.position.x < 0.9)
                this.playBall.position.add(new Vec3(deltaMove, 0, 0));
        }

        console.info("deltaX: ", deltaX);
        // console.info("getScrollX: ", event.getScrollX());
    }

    // 触摸控制球的移动
    onTouchMove(event: EventTouch) {
        const deltaX = event.getLocation().x - event.getPreviousLocation().x;
        const size = view.getVisibleSize();
        let deltaMove = 4 * deltaX/size.x;
        if (deltaX < 0) {
            if (deltaMove + this.playBall.position.x <= -0.9) {
                deltaMove = -0.9 - this.playBall.position.x;
            }
            if (this.playBall.position.x > -0.9) 
                this.playBall.position.add(new Vec3(deltaMove, 0, 0));
        } 
        if (deltaX > 0) {
            if (deltaMove + this.playBall.position.x >= 0.9) {
                deltaMove = 0.9 - this.playBall.position.x;
            }
            if (this.playBall.position.x < 1.0)
                this.playBall.position.add(new Vec3(deltaMove, 0, 0));
        }
    }
}

具体代码就不多解释了,可以看注释理解

经过这些步骤后,可以看到效果如下:

nfeyv-5q3kd.gif

可以通过设置offsetArr来控制赛道弯曲程度: 向右弯曲,可以把offsetArr数组的元素X坐标设置成正数,这里设置成30,如图:

image.png

向下弯曲及曲面效果,可以把offsetArr数组的元素Y坐标设置成负数,这里设置成-10,如图:

image.png

向上弯曲,可以把offsetArr数组的元素Y坐标设置成正数数,这里设置成10,如图: image.png

好了,3D弯曲效果就说到这里,这里还得感谢Cocoscreator官方提供的思路