V7投稿 | 【YH】如何做 一条丝滑的绳子

【YH】 如何做一条丝滑的绳子

大家在日常开发中,一定碰到过类似的需求

比如说

  • 1.做一条绳子,能够随手拖动,或者说具有物理效果,如《割绳子》

  • 2.我想要做一个赛道,或者说路径图,如《祖玛》

  • 3.我想让这个物品能弯曲,如《垫锅炒香肠》

  • 4.扭曲的激光,雷电,扭曲的光线,如《雷电》,《God of Light》

图示例:
微信图片_20240418111522
微信图片_20240418111341

我之前就被这些需求,困扰过,且找不到解决方案

今天回过头,再看这些,于是想整理一下,给自己交个卷。

需求拆分

绳子为例,所有渲染效果其实都是可以分割为两块,数据与渲染

  • 1.获取并处理绳子的数据点

  • 2.根据轨迹点,绘制出绳子

1.获取并处理绳子数据点

这里其实有很多种方案,

  • 你可以手动指定一组坐标作为绳子的轨迹

  • 也可以通过物理系统,通过刚体关节,模拟出带有物理属性的绳子

  • 如果不想使用物理,反向动力学模拟出的绳子效果,也是非常不错的

但是这里需要注意的是,绳子的数据点,需要足够平滑,不然绳子会非常生硬。

举一个极端的例子,你这个绳子只有4个点,并组成了一个正方形,绘制出来的效果肯定不会很好,很生硬

这中间我们需要做一些数据处理,因为你是以一组点连起来,就算你需要就是个正方形的绳子,但是别忘记,绳子是有宽度的,这点很关键。

就比如 为什么拖尾在移动转折特别快的时候,效果很很怪,就是因为曲线不够平衡,采样重叠

所以,简单一点处理这个,我们可以在这中间进行插值,以达到曲线的效果。

虽然贝塞尔曲线同样也能达到这个效果,且控制更精准,但是这里只需要保证平滑性,所以这里我们选择使用插值。

这里我选用了 CatmullRomSpline (Catmull-ROM样条),会在生成平滑曲线的同时,也经过原有的点
image

关键代码(CatmullRomSpline):


//获取插值

function cardinalSplineAt(p0, p1, p2, p3, tension, t) {

    var t2 = t * t;

    var t3 = t2 * t;

    /*

     * Formula: s(-ttt + 2tt - t)P1 + s(-ttt + tt)P2 + (2ttt - 3tt + 1)P2 + s(ttt - 2tt + t)P3 + (-2ttt + 3tt)P3 + s(ttt - tt)P4

     */

    var s = (1 - tension) / 2;

    var b1 = s * ((-t3 + (2 * t2)) - t);                      // s(-t3 + 2 t2 - t)P1

    var b2 = s * (-t3 + t2) + (2 * t3 - 3 * t2 + 1);          // s(-t3 + t2)P2 + (2 t3 - 3 t2 + 1)P2

    var b3 = s * (t3 - 2 * t2 + t) + (-2 * t3 + 3 * t2);      // s(t3 - 2 t2 + t)P3 + (-2 t3 + 3 t2)P3

    var b4 = s * (t3 - t2);                                   // s(t3 - t2)P4

    var x = (p0.x * b1 + p1.x * b2 + p2.x * b3 + p3.x * b4);

    var y = (p0.y * b1 + p1.y * b2 + p2.y * b3 + p3.y * b4);

    return cc.v2(x, y);

};

// 根据index 获取数据点

function getControlPointAt(controlPoints, pos) {

    var p = Math.min(controlPoints.length - 1, Math.max(pos, 0));

    return controlPoints[p];

};

// 根据输入点数组,获取曲线

function catmullRomSpline(points: cc.Vec2[], alpha: number): cc.Vec2[] {

    const result: cc.Vec2[] = [];

    result.push(points[0]);

    let _minSeg = 10

    for (let i = 0; i < points.length; i++) {

        let start = points[i]

        let end = points[i + 1]

        if (!end) continue

        let dis = start.sub(end).mag()

        let count = dis / _minSeg

        let p0 = getControlPointAt(points, i - 1)

        let p1 = getControlPointAt(points, i - 0)

        let p2 = getControlPointAt(points, i + 1)

        let p3 = getControlPointAt(points, i + 2)

        for (let t = 0; t <= 1; t += 1 / count) {

            const x = cardinalSplineAt(

                p0,

                p1,

                p2,

                p3,

                alpha, t);

            result.push(x);

            // console.log(result.length, x)

        }

    }

    // Add the last point

    return result;

}

这样,我们就能拿到一条光滑的曲线了。

如果你还不满足,你可以看看参考文档,里面有更多的曲线生成方式,下载exe支持实时预览哦。

参考文档:

https://www.cnblogs.com/WhyEngine/p/4020294.html

渲染(绘制)绳子

我们拿到了一组光滑的曲线后,现在就来到了渲染环节了。

简单一点的话,直接使用Graphics,就可以直接出效果了,这也是之前我用的一种替代方案

为了让绳子更逼真,我们需要使用各种不同的纹理,与cc.Sprite不同,这里的纹理是需要弯曲的。

cocos其实有个组件很贴近这个需求,MotionStreak (拖尾组件),以前我也确实是魔改MotionStreak去做的~~~

渲染本身是通过传递顶点数据顶点绘制顺序给gpu,所以我们只需要自己组装一下数据就好。

这里需要将曲线数据再处理一下,因为现在只有一条线,而绘制图片,最少是需要4个顶点2个三角形,就比如 cc.Sprite

这里我的做法是,

  • 1.将曲线数据中的每个点,根据前后两个点计算当前点的方向

  • 2.再根据方向与绳子宽度,获得绳子左右边界点的坐标。

曲线中的每个点,我们都能拿到3个数据,中心点,左右边界点。

这样就能保证绳子的宽度始终一致

7e80ee07a393477f4d8ed2c77affc28

PS: 其实中心点(曲线的轨迹),已经失去作用了,绘制三角形采样只需要左右就行了,但我还是保留了。

好了,这样子我们就拿到了完整的顶点坐标数据了。

当然拿到这些,还不够,还需要去对纹理采样,需要计算采样的UV坐标

这里,我提供了两种模式

  • 1.拉伸: 按绳子长度,拉伸整个纹理

  • 2.重复: 按绳子长度,重复整个纹理,与cc.Sprite的Tiled模式类似,理论上可以提供无限长的绳子

纹理的重复方式,将纹理重复到整个绳子长度,保证纹理不拉伸。

在处理重复模式的时候,需要记录当前点到初始点的距离,以确定采样的位置

微信截图_20240418141837 微信截图_20240418141827

仅仅需要这两张图,就能达到效果,当然你也可以尝试更好的。

这里有个小坑, 比如当前点,采样的位置是 0.9,下一个点,因为已经超过了纹理长度,采样的位置是 0.1

这里采样就会很奇怪,在这个区间绘制的效果,就是反过来绘制回去。

所以这里需要特殊处理下,加两个中间点,采样为 1 与 0,且坐标要一样, 0.9 - 1 - 0 - 0.1,这样子中间1~0的部分就不会绘制了。

动画9

关键代码:


/** 创建绳子数据,根据绳子轨迹 */

    createRopePointList(points) {

        let repePointList: RopeLinePoint[] = []

        for (let i = 0; i < points.length; i++) {

            const curRopePoint = points[i] || cc.v2(0, 0);

            const lastRopePoint = points[i - 1] || cc.v2(0, 0);

            let nextRopePoint = points[i + 1] || cc.v2(0, 0);

            let radian = Math.atan2(nextRopePoint.y - lastRopePoint.y, nextRopePoint.x - lastRopePoint.y);

            if (i == points.length - 1) {

                radian = Math.atan2(curRopePoint.y - lastRopePoint.y, curRopePoint.x - lastRopePoint.x)

                nextRopePoint = curRopePoint

            }

            const dir = nextRopePoint.sub(lastRopePoint)

            const dis = dir.mag()

            const unitDx = dir.x / dis;

            const unitDy = dir.y / dis;

            // 获取绳子左右两点位置,保持宽度一致

            let left = cc.v2(curRopePoint.x + this.ropeWidth * unitDy, curRopePoint.y - this.ropeWidth * unitDx)

            let right = cc.v2(curRopePoint.x - this.ropeWidth * unitDy, curRopePoint.y + this.ropeWidth * unitDx)

            // 计算当前绳子距离

            let ropePoint = new RopeLinePoint(curRopePoint, left, right)

            ropePoint.calculateDis(repePointList[repePointList.length - 1])

            let index = Math.floor(ropePoint.len / this.ropeTexture.height)

            let lastIndex = repePointList[repePointList.length - 1] ? Math.floor(repePointList[repePointList.length - 1].len / this.ropeTexture.height) : 0

            repePointList.push(ropePoint)

            if (index != lastIndex) {

                ropePoint.repeatEnd = true // 这里是为了在uv采样时,0.9 ~ 0.1,中间加一个 1 与 0,过渡

                let tempRopePoint = new RopeLinePoint(curRopePoint, left, right)

                tempRopePoint.calculateDis(repePointList[repePointList.length - 1])// 计算距离

                repePointList.push(tempRopePoint)

                tempRopePoint.repeatStart = true // 这里是为了在uv采样时,0.9 ~ 0.1,中间加一个 1 与 0,过渡

            }

        }

        return repePointList

    }

总结

其实效果也不是那么完美,比如说 180 度转折的时候,处理还是有点问题。

如果精益求精的话,可以再考虑在,边界点重叠的时候,对边界点进行去重,再重新组装数据。
或者说,对左右点组成的曲线数据,再做一次曲线生成,保证光滑。
4c3db2c4f1f65f9bbd93dc045a8f840
7d5719c677dcf477beff6f4daa2c9aa

这里尝试过对边界点进行处理,但是还是不太满意,而且会多出几步处理步骤

不过,这个方案,已经能满足大部分需求了。

大家再遇到类似的需求,可以试试这个方案,看看能不能帮到你哦

这里我也提供自己已经实现的一个示例,有需要的可以看看:

体验链接:https://cdn.yy0001.com/Game/Show/RopeDemo/1.0.0/index.html

商店链接:https://store.cocos.com/app/detail/6151

13赞

666666大佬真迅速

商店里面有3.8版本的吗?如果有就买

需要3.X版本的

后续马上更新支持 3.x

大佬看下我的问题呢 :rofl: :rofl:
2d绳子求助!!!

你那个关键,还是在于曲线运动,需要达到你想要的运动轨迹,物理或者模拟物理的感觉,不是渲染

提供一个思路,你可以还是用刚体试试,但是控制一个阈值,运动时,钉子相邻的两个点,即将到达钉子某个位置,把整条绳子的刚体,设为静态,或者清除所有力和速度,然后往下掉,就不会有向上的惯性了

mark,很实用的技术。。。

image 这个是物理引擎的吗

对的,绿色的点,就是个球刚体

等待3.X版的适配

God of light 还需要加UV滚动shader偏移,用的是u3d做的。

3.x 适配 已完成!

是的,理论上,达到 光线 冲击的效果,在这个基础上加个 uv滚动 shader的材质就行,两块是不冲突的