绘图优化之【光栏法】|社区征文

优化图形绘制( 光栏法 )|社区征文

前言

什么是 <光栏法>?它能做什么?

相信大家都有过图形绘制的案例.那么大家应该也都有遇到过绘制点位过多导致DrawCall暴涨的情况.

我们假设一个场景,例如: 需要一个画板的功能,用户可以再画板上随意绘制图形,之后实时将绘制的过程同步给其他用户;

这个时候我们将面临两大难题:

  • 绘制N条线段后DrawCall增加导致掉帧卡顿;

  • 产生点位数据太大,传输慢或丢包严重;

那么如何优化这两个问题呢?

首先我们得了解什么是 光栏法, 它能做什么?

简介

光栏法是一种矢量数据的压缩算法。

光栏法的基本思路是对每一条曲线上的所有点, 逐点定义一个扇形区域。若曲线的下一节点在扇形外, 则保留当前节点; 若曲线的下一节点在扇形内, 则舍去当前节点。

正文

在基本了解了算法的概念之后.我们就可以开始进入实践阶段,之后我会已比较简单的理解方式来阐述整个算法的核心;

创建画板

要实现上述功能,首先我们要在Creator 中创建一个画板;创建画板我们可以利用cc.graphics组件;

  • 在Creator节点上添加一个空节点,绑上cc.graphics组件,通过对应参数对graphics进行设置;

  • 创建一个脚本 Draw.js 并绑定在 drawLayout 节点上;

接下下来脚本中需要添加一些初始化设定个:

// 添加交互区域的 点击/移动/抬起 事件
// 处理每个事件
// 在move的时候取A,B两点画线

onLoad () {
    this._startPoint = null;
    this._draw = this.node.getComponent( cc.Graphics );
    this._draw.lineWidth = 5;
    this._points = [];
    this.node.on( cc.Node.EventType.TOUCH_START, this._touchStart, this );
},

_touchStart ( e ) {
    this._startPoint = this.node.convertToNodeSpaceAR( e.getLocation() );
    this._draw.moveTo( this._startPoint.x, this._startPoint.y );
    this.node.on( cc.Node.EventType.TOUCH_MOVE, this._touchMove, this );
    this.node.on( cc.Node.EventType.TOUCH_END, this._touchEnded, this );
},

_touchMove ( e ) {
    let location = this.node.convertToNodeSpaceAR( e.getLocation() );
    this._draw.lineTo( location.x, location.y );
    this._draw.stroke();
    this._points.push( location );
},

_touchEnded ( e ) {
    this.node.off( cc.Node.EventType.TOUCH_MOVE, this._touchMove, this );
    this.node.off( cc.Node.EventType.TOUCH_END, this._touchEnded, this );
    this._startPoint = null;
    let location = this.node.convertToNodeSpaceAR( e.getLocation() );
}

这样一来,我们就得到了一个简易的画板,可以在画板上进行一些绘制操作;

这个时候,我们也能看到绘制组件暴露出来的问题, 一笔绘制的points达到了2288组,draw call 987,这对于一个简单的图形来说,已经很吃力了;

算法原理

初始化画板工作完成后,我们先来了解一下算法的原理,算法是通过怎样的形式优化point的;

首先,我们能确定的是绘制是通过AB连点相连得到的一条直线,而一个圆可以拆解成一个N个扇形;哪我们如何简化point呢?

假设1: 从(0,0)–(0,100)绘制一条直线,那么此时的points=[{0,0},{0,100}],只有两个点,但如果是通过ToucherMove来获取的点位信息绘制就可能得到point=[{0,0},{0,1},{0,2}…{0,100}],如果我们能识别它是一条直线,就可以将中间的点位全部省略;

假设2: 如果是一个不规则图形,例如圆弧,如图2-1:

这样我们可以看到,一个圆弧上由N个点组成,但如果把它分成N个扇形区域,然后舍去扇形区域内临近的点位,反复操作,直到点位缩小到我们设置的口径内?

接下来我们结合一下光栏法的原理,<每一条曲线上的所有点, 逐点定义一个扇形区域>;

设口径为d;两点坐标为p1,p2;连接p1和p2点,过 p2点作一条垂直于p1p2的直线,在该垂线上取两点a1和a2,使a1p2=a2,p2=d/2,此时a1和a2为“光栏”边界点,p1与a1、p1与a2的连线为以p1为顶点的扇形的两条边,这就定义了一个扇形(这个扇形的口朝向曲线的前进方向,边长是任意的).通过p1并在扇形内的所有直线都具有这种性质,即p1p2上各点到这些直线的垂距都不大于d/2.

那么若p3点在扇形内,则舍去p2点.然后连接p1和p3,过p3作p1p1的垂线,该垂线与前面定义的扇形边交于c1和c2.在垂线上找到b1和b2点,使p3b1=p3b2=d/2,若b1或b2点落在原扇形外面,则用c1或c2取代,此时用p1b1和p1c2定义一个新的扇形,这当然是口径(b1c2)缩小了的“光栏”.

实现

了解完整个逻辑之后,我们就可以开始规划实现内容了,简单总结就是: 我们需要通过一个口径对绘制的图形创建无数个扇形区域,然后舍去扇形区域内相邻坐标的点位,以此循环操作;

举个"栗子":

设口径为: caliber
绘制点位为: points

1.点是否在光栏内;
2.叉积判断
3.获取上下向量
4.点在光栏内处理
5.缩小口径
6.将有效点相连

现在有了光栏法的概念,有了需求,也有了逻辑走向,接下来就可以规划我们的代码块了.

  • 设置光栏:

通过两点之间的距离和之前设定的口径(caliber),可以计算出光栏的角度(angle),用角度就能过得到上下两条射线的夹角,实际上这种就是通过角度和距离还获取向量;

let getLightBarVector = ( p1, p2, caliber ) => {
    let len = this.pGetDistance( p1, p2 );
    let angle = Math.atan2( caliber * 0.5, len );
    let up = this.pRotate( p2.sub( p1 ), this.pForAngle( angle ) );
    let down = this.pRotate( p2.sub( p1 ), this.pForAngle( -angle ) );
    return { up, down };
}
  • 判断点是否在光栏内,通过计算两点的叉积,可以的出点前AB点位是否处在我们设定好的光栏内;
let isInLightBar = ( up, down, p1, p ) => {
    let line = p.sub(p1);
    if ( line.cross( up ) >= 0 && line.cross( down ) <= 0 ) {
        return true
    }
    return false
}

  • 逐步缩小光栏区域:
let acosVector = ( v1, v2 ) => {
    return Math.acos( v1.dot( v2 ) / ( v1.mag() * v2.mag() ) )
}

有了这三组核心算法之后,剩下的工作其实就是些遍历点位,持续判断的过程了.只要是 if(isInLightBar) 就证明连线的下一个点位是在光栏内的.可以被舍弃;

完整代码

simplifyLightBar ( points, caliber ) {
    if ( caliber <= 0 ) {
        return points;
    }

    if ( points.length < 2 ) {
        return points
    }

    // 点是否在光栏内
    // @param p1 光栏起始点
    // @param p

    let isInLightBar = ( up, down, p1, p ) => {
        let line = p.sub(p1);
        if ( line.cross( up ) >= 0 && line.cross( down ) <= 0 ) {
            return true
        }

        return false
    }

    // 获取光栏上下向量
    let getLightBarVector = ( p1, p2, caliber ) => {
        let len = this.pGetDistance( p1, p2 );
        let angle = Math.atan2( caliber * 0.5, len );
        let up = this.pRotate( p2.sub( p1 ), this.pForAngle( angle ) );
        let down = this.pRotate( p2.sub( p1 ), this.pForAngle( -angle ) );
        return { up, down };
    }

    let acosVector = ( v1, v2 ) => {
        return Math.acos( v1.dot( v2 ) / ( v1.mag() * v2.mag() ) )
    }

    let pointList = [];
    for ( let p of points ) {
        p.enable = true;
        pointList.push( p );
    }

    let p1 = pointList[ 0 ];
    let p2 = pointList[ 1 ];
    let light = getLightBarVector( p1, p2, caliber );
    let up = light.up;
    let down = light.down;
    let lastIndex = 1;

    for ( let i = 2; i < pointList.length; i++ ) {
        let p = pointList[ i ];
        if ( isInLightBar( up, down, p1, p ) ) {
            // 如果下一个点在光栏内,则删除上一个点,当前点为新p2
            points[ i - 1 ].enable = false;
            p2 = points[ i ];
            let light = getLightBarVector( p1, p2, caliber );
            let newUp = light.up, newDown = light.down;

            // 缩小光栏口径
            let p1p2 = p2.sub(p1);

            // 缩小光栏口径
            if ( acosVector( p1p2, newDown ) < acosVector( p1p2, down ) ) {
                down = newDown;
            }

            if ( acosVector( p1p2, newUp ) < acosVector( p1p2, up ) ) {
                up = newUp;
            }
        } else {
            // 如果不在,则保留上一个点,以上一个点为新p1
            points[ i - 1 ].enable = true;
            p1 = points[ i - 1 ];
            p2 = points[ i ];
            let light = getLightBarVector( p1, p2, caliber );
            up = light.up;
            down = light.down;

            // 上一个有效点到现在有效点直线, 之间如果有回折,那最末端的点应该保留
            let lastPoint = points[ lastIndex ];
            let maxDis = 0;
            let maxDisIndex = 0;

            for ( let i = lastIndex; i > 0; i-- ) {
                let dis = this.pGetDistance( lastPoint, points[ i ] );
                if ( dis > maxDis ) {
                    maxDis = dis;
                    maxDisIndex = i;
                }
            }

            if ( maxDis - this.pGetDistance( lastPoint, p1 ) > caliber ) {
                points[ maxDisIndex ].enable = true;
            }
            lastIndex = i - 1;
        }
    }

    let newPoints = [];

    for ( let p of points ) {
        if ( p.enable ) {
            newPoints.push( cc.v2( p.x, p.y ) );
        }
    }

    return newPoints
}

代码部分已经完成了,接下来我们来看看效果;

caliber = 10;

现在我们看到非常明显的变化.但是弧线看起来还不太圆润,因为优化后的点位很少.所有我们还有把口径调小的空间;

caliber = 3;

那么现在的这个效果看上去就很平滑了,而优化的程度达到了**80%**以上.效果还是很不错的;

总结

到此,整个光栏法的核心部分就已经全部介绍完毕了,后续会更大家分享一些关于绘图的高级用法和优化;感谢大伙抽空看完此篇文章,如有纰漏或者其他想法的同学,欢迎评论区一起探讨!

13赞

很不错 就是理解着有点吃力

牛, mark

请问,有可以体验光栏法这个功能的demo网址吗?

mark~

mark~~