2D可互动水的实现

2d互动水

如图,一个2D可互动水的实现思路:看到大部分教程都是用弹簧振子模型做的,我就用简单的波的叠加和自定义的Sprite实现了.

等有时间我也出一期教程.

目前只在3.8.7版本上测试了.如果漂浮物移动太快,会出现抖动的波.还没想好怎么优化.

欢迎体验:仓库链接

22赞

插眼…

膜拜大佬~~

mark 插眼研究

我记得官方的论坛上的小案例有这个

1赞

是用 Box2d的liquidfun做的那个吗

插眼《2D可交互水》

mark 下

大佬!mark!!!

事情的起因是楼主最近在看<游戏物理引擎开发>,有一个叫做隐式弹簧的东西很有意思.正好之前做的是显式弹簧(弹簧振子模型)的效果,这次正好试试新的.

水面的效果将分为物理和渲染两部分

物理效果的模拟

弹簧振子模型是很常见了,我之前有个项目需要形变的效果,就做了一个相应的史莱姆模型.但是发现效果并不理想:

  1. 物体的位置是由力驱动的,然而计算位置要经过: 力—>加速度 —> 速度 —> 位置这个过程,非常麻烦
  2. 计算是有误差的,误差的累积会让结果越来越偏离正确值

那么隐式弹簧是什么呢?简单来说就是直接用弹簧的性质来模拟弹簧运动,而不是直接计算弹簧的运动情况.不再计算速度和加速度,我们关心的结果只有一个–就是液面的高度.

单一水滴的位置

牛顿告诉我们,当一个物体不受外力时会保持原来的运动趋势.而一个运动的物体连上了弹簧,就会往返运动,这种运动是有规律的–往返运动的幅度,周期都是固定的.这种运动也叫简谐运动.这里我们假设水面就是一连串的简谐运动的振点,并且忽略初始相位,它们将只在竖直方向上上下来回运动.

既然是有规律的,那就能写出公式:

H(t) = A \sin(2\pi f t)

  • t 表示从开始运动后过去的时间
  • H(t) 表示在这个时刻此振点的高度
  • A 是振点的振幅,也就是能向上/向下运动的最远距离
  • f 频率,也就是振动的快慢

对应代码

 get height() {
        return this.amplitude * this.decay * Math.sin(this.t * 2 * Math.PI * this.frequency)
    }

相应的数据定义

export interface IWaveInfo {
    waveLength: number    //波长
    amplitude: number    //初始振幅
    frequency: number   //频率
    phase: number       //相位
    pos: Vec2           //振源位置
}

interface IWave extends IWaveInfo {
    get t(): number //时间
    get decay(): number //衰减系数
    get height(): number //当前振子高度
    get speed(): number //波沿着水平方向传播的速度
    get validRange(): number //振子可影响的范围 
    getHeightAt(dis: number)//距离为dis处的波的高度
}

export class Wave implements IWave {
    waveLength: number
    amplitude: number
    frequency: number
    phase: number
    pos: Vec2 = new Vec2()

    get t() {
        return (Date.now() - this._startTime) / 1000
    }

	...

    static SHAKE_TIME: number = 4.0 //波的寿命
    _startTime: number//开始振动的时间
    constructor(waveInfo: IWaveInfo) {
        this.waveLength = waveInfo.waveLength
        this.amplitude = waveInfo.amplitude
        this.frequency = waveInfo.frequency
        this.phase = waveInfo.phase
        this.pos.set(waveInfo.pos)

        this._startTime = Date.now()
    }
}

那么此时这个水滴的位置就可以写出了:
P(t) = Vec2(x_0, y_0 + H(t))

其中$ (x_0, y_0) $ 是这个水滴的初始位置.对应代码:

 updateSurface() {
        this._surface.forEach((p) => {
            const h = this.getWaveHeightOfParticle(p)
            //水滴的初始高度就是UITransfom的height
            p.pos.y = this.targetUIT.contentSize.height + h
        })
    }

当然,真实世界里是不存在不受外力的水的,浪花总会平静下来.那么就需要有一个衰减系数:

    get decay() {
        if (this.t > Wave.SHAKE_TIME || this.t < 0.) return 0.0
        else return (1. - this.t / Wave.SHAKE_TIME) ** 2
    }

这里规定这个波的最大持续时间为SHAKE_TIME.衰减系数 decay将乘在振幅A

波的干涉

液面是存在张力的,那么一个水滴的运动将会沿着液面传递给其他水滴,从而带动其他水滴的运动.也就是水波了.
Pasted image 20260413174520

两个波相遇将会产生干涉,作用在交点处的水滴的最终效果(水滴的高度)相当于:每个波分别作用在该点产生的效果的叠加.这也称为波的叠加原理.对应代码:

   getWaveHeightOfParticle(particle: vParticle): number {
        let height: number = 0
        this._waves.forEach((w) => {
            const dis = Vec2.distance(w.pos, particle.pos)\
            //将各个波产生的高度累加
            height += w.getHeightAt(dis)
        })

        return height
    }

水波产生到水波对其他水滴产生作用是有时间差的,因为水波的传播是有一个速度的

    get speed(): number {
        return this.frequency * this.waveLength
    }

那我们也就能得到一个波能产生作用的有效距离了

 get validRange() {
        return this.speed * Wave.SHAKE_TIME
    }

波超出这个距离,衰减系数将变成0,也就是没有任何作用

因为波是一圈一圈传播出去的,所以对某个水滴的最终的作用效果,将只取决于这个水滴和此波的振源的距离.终于,我们能写出波在指定位置的高度了

    getHeightAt(dis: number) {
        if (dis > this.validRange) return 0 //超出有效范围
        else if (this.t < dis / this.speed) return 0 //波尚未传播到此处
        else {
            const costTime = dis / this.speed //波传播耗时
            return this.amplitude * this.decay * Math.sin((this.t - costTime) * 2 * Math.PI * this.frequency)
        }
    }

接入Box2d

对于液面高度的计算已经结束,下面考虑如何让一个物体能够发出波,并且能受到浮力.

如何发波

假设物体都是box的形状,那么它将会在进入液面时产生波浪;在液体中也会因为运动而产生波浪.所以我们要在产生碰撞每一帧内都产生波浪,直到碰撞结束.

当然,不需要太精准,实际上我考虑的只有box碰撞体的四个顶点而已.波浪将由四个顶点发出,浮力也只作用在四个顶点上.

    _flotage: Set<BoxCollider2D> = new Set()
 onBeginContact(selfCollider: BoxCollider2D, otherCollider: BoxCollider2D, contact: IPhysics2DContact) {
        this.addFlotage(otherCollider)
        otherCollider.worldPoints.forEach((p) => {
            this.creatWaveAt(p, otherCollider)//接触时产生波浪
        })
    }
    onEndContact(selfCollider: BoxCollider2D, otherCollider: BoxCollider2D, contact: IPhysics2DContact) {
        this.deleteFlotage(otherCollider)
    }
    
     protected update(dt: number): void {
        this._flotage.forEach((f) => {
            if (f == null || f == undefined) return

            f.worldPoints.forEach((p) => {
                this.applyFloatForceAt(p, f);//浮力
                this.creatWaveAt(p, f);//波浪
            })

        })
    }

creatWaveAt方法中参数很多,改一改效果就会不同,所以就不细说了.

下面开始浮力模拟

浮力

浮力作用在boxCollider的四个顶点上.

为什么浮力不作用在质心呢?因为在这里漂浮物还是很大的,不能忽略它的形状.如果是模拟很小的漂浮物,就可以直接作用在质心了

我们将漂浮物顶点p这个世界位置转换为水体的本地位置,然后根据这个本地位置计算浮力.再使用applyLinearImpulse把冲量施加给漂浮物的rigidbody就ok了.

_tempV3: Vec3 = new Vec3()
_tempImpulse: Vec2 = new Vec2()
_tempPos: Vec2 = new Vec2()

 private applyFloatForceAt(p: Readonly<Vec2>, flotage: BoxCollider2D) {
        this._tempV3.set(p.x, p.y);
        this.transform.convertToNodeSpaceAR(this._tempV3, this._tempV3);
        this._tempPos.set(this._tempV3.x, this._tempV3.y);
        const floatForce = this.getFloatForceAt(this._tempV3);
        this._tempImpulse.set(0, floatForce);
        flotage.body.applyLinearImpulse(this._tempImpulse, p, true)
    }

因为要频繁计算,所以复用Vec2可以减少很多new的开销和gc的开销
冲量和时间相关,这里没有使用update(dt)中的dt,可能会因为帧率波动而出现不准确的情况.
不过我就怎么方便怎么来了

那么,浮力怎么算呢?初中时我们学过浮力的计算公式: F = \rho_液 * g * V_排
液体的密度和重力常量都可以在rigidbody面板中调整,只有排出液体的体积未知.
不过又不是造火箭,大概意思到了就行.不需要体积,我们直接计算液体压强就能模拟了.
P = \rho g h
ok,计算位置深度后,就能得到浮力.

	getDepthAt(localPos: Vec3): number {
        return this.collider.worldAABB.height - localPos.y
    }

    getFloatForceAt(localPos: Vec3): number {
        const depth = this.getDepthAt(localPos)
        if (depth < 0) return 0

        const force = this.collider.density * this.rigid.gravityScale * depth
        return force
    }

那么物理模拟的部分就结束了

2赞

自定义顶点部分依旧有时间再更

1赞

可以的。不错

大佬666

插眼留名,回头再看

高级,插眼学习。感谢分享~

感謝分享~高手高手高高手