[CocosCreator 2.x][Shader]在2D图片中实现波纹效果

效果图

先上效果图:
QQ2024527-14206
在这之前,我们要先实现一个阉割版的效果:
效果图-2

代码会贴在文末哦

原理

单个物体

首先我们要建立一个物体震动的数学模型

我们可以先简单的使用 sin 函数来模拟:

let power = sin(x);//(单位:像素)

x代表时间,函数代表震动幅度随时间的变化,如图:
image_sin
物体会无限的震荡下去,我们想要的是物体在某个时间点停止震荡,

我们可以在 sin(x) 前面乘上一个系数,来控制震荡的幅度,

设置物体的震动时间:

let shakeTime = 5;//(单位:秒)

函数可以暂定为:

let decay = 1-(1/shakeTime) x;

x轴表示时间, 函数代表震动衰减随时间的变化,如图:

如图:
image_decay
物体的震荡幅度会随着时间而减小,直至为零,

我们可以修改shakeTime的大小,来观察波形:

function_decay
目前,震动的次数是不可控的,

因此我们设置一个次数的变量,来控制震动次数:

let shakeCount = 10;//(单位:次)

我们把power函数修改为为:

let power =sin(x * 2 * π * (repeatCnt/shakeTime));

让函数在shakeTime内,震动repeatCnt次;
function_repeatCnt
好了, 单个物体的震动模拟我们已经完成了。

接下来,我们来模拟多个物体的震动,

多个物体

假设震源A ,在AB个方向上存在三个点, C,D,E, 如图:

wave_monitor

那么 CDE三个点,随时间震动的函数,我们大概可以分别表示成

image_monitor_c image_monitor_d image_monitor_e

因为点离震源的距离不同,所以开始震动的时间不一样,

我们假设:

// 离震源的距离
let distance;

// 震波移动速度
let waveSpeed = 400;//(单位:像素/秒)

// 时间偏移
let timeOffset = distance/waveSpeed;

加入以上参数以后,我们重新修改power方法;

let time = x - timeOffset;//(单位:秒)
let power =sin(time * 2 * π * (repeatCnt/shakeTime));

到此,我们已经收集到所有的数据;

震动的方向,则直接取A到该物体的向量即可。

可以开始进行物体震动的模拟了;

接下来,把上述的理论运用到代码中!

附: 图中用到的工具:https://www.geogebra.org/

代码实现

先定义好上面提及的常量 和方法

// 物体震动时长
const GRID_SHAKE_TIME = 0.4;
// 物体震动周期次数
const GRID_SHAKE_COUNT = 1.5;
// 波浪强度
const WAVE_POWER = 10;
// 波浪移动速度
const WAVE_SPEED = 200;
// 波浪影响范围
const WAVE_EFFECT_RANGE = 200;
    // 衰减系数
    getDecay(time: number) {
        if (time < 0 || time > GRID_SHAKE_TIME) {
            return 0;
        }
        return 1 - (time / GRID_SHAKE_TIME);
    }
    // 物体振幅
    getWavePower(time: number) {
        let k = this.getDecay(time);
        let power = WAVE_POWER * Math.sin(time * 2 * Math.PI * (GRID_SHAKE_COUNT / GRID_SHAKE_TIME));
        return k * power;
    }

实现的思路:

  1. 先在场景上布置好物体,布置物体的时候,需要记录物体的初始位置,后续叠加上位置偏移,得到修正位置;

  2. 鼠标点击,模拟震动的发生,记录好震源位置,和发生震动的时间戳,这里用数组存放震源信息, 因为波是可以叠加的, 有多个震源,直接计算所有的震动位移并且叠加即可;

     let info = {
                    pos: location,
                    startTime: now
                }
                this.waves.push(info);
    
  3. 在每一帧,根据物体和震源的位置,当前时间和震动发生时间,计算物体应该往哪个方向,偏移多少距离,

    update(dt) {
            let now = Date.now();
    
            for (let i = 0; i < this.grids.length; i++) {
                const grid = this.grids[i];
                for (let j = 0; j < this.waves.length; j++) {
                    // 波浪信息
                    let wave = this.waves[j];
                    // 方向, 震源指向物体本身
                    let foward = grid.node.getPosition().sub(wave.pos);
                    // 离震源的距离
                    let distance = foward.len();
    
                    let timeOffset = distance / WAVE_SPEED;
                    let time = (now - wave.startTime) / 1000 - timeOffset;
                    let power = this.getWavePower(time);
                    // 这里再以离震源的距离,做一个整体的振幅衰减
                    let decay2 = Math.max(0, 1 - distance / WAVE_EFFECT_RANGE);
                    power *= decay2;
                    // 以power为新的模,设置物体的位置偏移
                    let offset = foward.normalizeSelf().mul(power);
                    grid.setWave(offset);
                }
            }
    
            for (let i = 0; i < this.grids.length; i++) {
                const grid = this.grids[i];
                grid.updatePos();
            }
        }
    
  4. 过期数据清理, 当震源存着的时间超过了: 传播到最大可影响范围的时间+物体震动时间之后, 震源将不再对游戏世界有影响,此时可以移除

     let maxTime =  WAVE_EFFECT_RANGE / WAVE_SPEED + GRID_SHAKE_TIME;
            while (this.waves.length > 0) {
                let wave = this.waves[0];
                if ((now - wave.startTime) / 1000 > maxTime) {
                    this.waves.shift();
                } else {
                    break;
                }
            }
    
  5. 手指滑动,触发记录位置的频率太高,需要在记录的时候,缓存上次记录的信息,当手指滑动距离超过一定值,或者时间超过一定值,才记录新的位置信息;否则会有鬼畜的表现

  6. Grid.ts 只有3个简单的方法

ox: number;
    oy: number;
    init(x: number, y: number) {
        this.ox = x;
        this.oy = y;
        this.node.x = x;
        this.node.y = y;
    }

    offsets: cc.Vec2 = cc.v2();
    setWave(offset: cc.Vec2) {
        this.offsets.addSelf(offset);
    }

    updatePos() {
        this.node.x = this.ox + this.offsets.x;
        this.node.y = this.oy + this.offsets.y;
        this.offsets = cc.v2();
    }
  1. 附上源码

code.zip (2.0 KB)

进阶版

这是我第一次写发有关shader的帖子,我会写的尽量详细,

后续如果有发shader相关的帖子,只会解释核心代码;

  • 新建一个Effect文件,和一个Material文件
    image_shader_guid1
    并且都命名为WaveEffect
    image_shader_guide2
    上面的图标是.effect文件,下面的图标是.material文件

  • 绑定 effect文件
    image_shader_guide3
    选中material文件, 在Effect栏,点击下拉框找到需要绑定的Effect文件,并且点击应用保存;

  • 找到片元着色器的main方法,在下图位置开始修改代码
    image_shader_guide4
    CCTexture(texture, v_uv0, o);的意思,是根据当前像素的uv值,对图像进行采样(计算像素的颜色值);
    我们要计算一个新的uv,代替这个v_uv0进行采样;

  • 先实现单个固定震源对图片的影响
    定义常量和变量
    image_shader_guide5
    注意:
    i.我使用的图片是336*600;后续会改成参数形式;
    ii.如果要使用cc_time.x;上方需要引用 #include
    iii.任何使用修改uv的shader,统统需要把素材的Packable取消勾选,(自动图集会修改素材的uv,导致计算不正确)
    image_shader_guide_6

  • 翻译上个demo的两个方法
    image_shader_guide7

  • 翻译计算位置的核心代码
    image

  • 用新的uv,代替这个v_uv0进行采样; 到这里, 单个震源产生的震动效果,就写好啦;我们可以在游戏里看看效果;

  • 给Sprite组件,添加上我们创建的材质(代码是在Effect文件上修改, Sprite使用的是Material文件)
    image

  • 效果如下
    QQ2024527-104940

  • 效果已经实现了,但是现在波纹只会在 (100,100)的位置产生;接下来,我们把震动位置,和震动发生时间暴露给代码, 通过代码传递参数给shader,由此来实现任意点的波纹效果

  • 在Effect文件的最上放, 定义properties
    image

  • 在片元着色器中, 定义好uniform,并且修改波纹位置
    image

  • cocos代码, 监听的部分不做改变,; 代码中取得sprite 并且缓存材质
    image

  • update方法中, 只使用waves[0]的信息产生波纹, 因为Effect文件中,我们只处理了一个震源信息:
    image

  • 到这一步,效果已经很不错啦:
    QQ2024527-112018

  • 接下来要处理多个震源的数据, 并且传到shader中使用, 修改第五步update方法里, 红框标记的代码
    image

  • 新建一个texture2d对象,并初始化像素数组,把震源信息以像素的形式,存入数组,
    第一个震源信息的位置X 存入第一个像素的R通道;
    第一个震源信息的位置Y 存入第一个像素的G通道;
    第一个震源信息的存在时间, 存入第一个像素的B通道;
    A通道不使用;
    第二个像素同理;

  • 处理完之后我们在 effect文件中,定义震源数量, 和震源信息图像数据
    image
    image

  • 之前只接受了,一个震源信息,计算出offset; 现在针对每个震源信息分别计算出offset并累加, 计算出最终的偏移量
    image

  • 修改完成之后, 文章开头的效果,就实现啦

QQ2024527-14206

20赞

感谢分享!请问大佬这个函数生成的曲线是那个网站啊?

能提供个完整版本的源码吗 感谢感谢

图中用到的工具:https://www.geogebra.org/

wave_demo.zip (330.5 KB)

very good 现在它是我的了! :laughing:

拖图片拖材质拖组件完了能跑起来 就是有个小问题textureSizeY这个属性没有暴露出去 应该是忘了加 :joy:
image
还有这两个属性有办法自动获取吗 不手动填的话编辑器场景里加了材质的图片显示有问题 不过好像把effect里的默认值改成一个很大的值也没问题

哈哈 谢谢呀!

textureSizeY 忘了暴露啦!
默认值, 在.effect文件的头部可以直接设置
image

这个值设置的不对,会导致鼠标点击传值到shader的时候, 计算具体位置有偏差, 可能是波纹的中心和点击位置对不上;

大佬 提两个小问题哈

触点坐标转节点坐标系应该是这样:joy:
this.node.parent.convertToNodeSpaceAR(event.getLocation());

然后触摸回调一开始先判断一下触点是否在节点包围盒比较好
if (!this.node.getBoundingBoxToWorld().contains(event.getLocation())) return;

event.getLocation() 是世界坐标, 直接找到想要转换的节点convert就行了吧

包围盒的判断确实要加一下, 我偷懒了 :rofl:

:joy:如果节点坐标刚好是(0, 0) 那么this.node.convertToNodeSpaceAR(event.getLocation())和this.node.parent.convertToNodeSpaceAR(event.getLocation())的结果是一样的
你用你的例子试一下就好了 把节点坐标偏移一下 生成的水波位置就偏了

因为节点坐标系是节点在父节点坐标系的坐标 虽然听上去有点绕api用起来也有点绕但确实是这样理解和使用的 :joy:

还有节点的width和height都需要除以ScaleX和ScaleY才是实际大小 否则节点缩放后 生成的水波位置也会偏

ok,回去马上改