Dec 18

尝试在Web上用SDF替代IconFont

Lrdcq , 2020/12/18 15:36 , 程序 , 閱讀(1699) , Via 本站原創
在图形学特别是游戏技术领域,通过SDF【Signed Distance Field】以及其衍生技术进行文字渲染已经是通用技术了,当然玩Webgl的人也都知道,我们知道这是在相关领域最常见的可缩放文本(但是不是矢量文本)方案。SDF定义上严格来说包含了字体储存和渲染方案二合一,因此既然我们可以用IconFont来解决图标问题,也可以用SDF来解决图标问题。

另外乍看起来,SDF还有IconFont没有的优势,包括:a. 资源分发灵活性,毕竟图片解决问题。b. 图形可延展,便于做描边,宽窄字体用同一资源。c. web兼容性,反正是自己写render,有canvas就行,计算复杂度远低于一般的canvas做其他图片格式兼容方案或者特殊渲染方案。d. 易使用,输出结果为canvas或者继续导出imageurl,icon即图片更合理。

因此,在web上如果为icon渲染单独开canvas进行sdf渲染,是否可行,性能如何?

代码落地

用图片+canvas进行sdf渲染,唯一的区别是软件图片渲染的采样算法与硬件图片采样算法是有区别的,因此可能效果不一样。因此优先输出demo:

【普通sdf】demo:https://lrdcq.com/test/sdf-canvas/sdf.html

SDF的动态图像生产非常简单:
        const d = 50;//边缘检测距离
        const sw = image_source.width / 4, sh = image_source.height / 4;
        const w = sw + d * 2, h = sh + d * 2;
        //原始图片canvas
        let canvas_source = document.createElement('canvas');
        canvas_source.width = w;
        canvas_source.height = h;
        canvas_source.style.width = '256px'
        canvas_source.style.height = '256px'
        document.body.appendChild(canvas_source);

        let canvas_source_ctx = canvas_source.getContext('2d');
        canvas_source_ctx.drawImage(image_source, d, d, sw, sh);
        let canvas_source_data = canvas_source_ctx.getImageData(0, 0, w, h);

        //输出sdf的canvas
        let canvas_sdf = document.createElement('canvas');
        canvas_sdf.width = w;
        canvas_sdf.height = h;
        canvas_sdf.style.width = '256px'
        canvas_sdf.style.height = '256px'
        document.body.appendChild(canvas_sdf);

        let canvas_sdf_ctx = canvas_sdf.getContext('2d');
        let canvas_sdf_data = canvas_sdf_ctx.getImageData(0, 0, w, h);

        //遍历所有像素点,找到每个像素点最近边缘
        for (let y = 0; y < h; y++) {
            for (let x = 0; x < w; x++) {

                let scan_left = Math.max(0, x - d), scan_top = Math.max(0, y - d), scan_right = Math.min(w - 1, x + d), scan_bottom = Math.min(h - 1, y + d);
                let this_is_1 = !!canvas_source_data.data[(y * h + x) * 4 + 3];
                let min_dis = Number.MAX_SAFE_INTEGER;

                for (let scan_x = scan_left; scan_x <= scan_right; scan_x++) {
                    for (let scan_y = scan_top; scan_y <= scan_bottom; scan_y++) {
                        let that_is_1 = !!canvas_source_data.data[(scan_y * h + scan_x) * 4 + 3];

                        if (this_is_1 ^ that_is_1) {
                            let dis = Math.hypot(scan_x - x, scan_y - y);
                            min_dis = Math.min (min_dis, dis);
                        }
                    }
                }

                min_dis = Math.min(min_dis, d);

                canvas_sdf_data.data[(y * h + x) * 4 + 0] = 0;
                canvas_sdf_data.data[(y * h + x) * 4 + 1] = 0;
                canvas_sdf_data.data[(y * h + x) * 4 + 2] = 0;
                canvas_sdf_data.data[(y * h + x) * 4 + 3] = 128 + min_dis / d * 128 * (this_is_1 ? 1 : -1);
            }
        }

SDF的生成原理即找到每个像素点距离矢量图像边缘的距离,生产灰度图。只是如果粗暴的写的话复杂度较高。假设输出sdf长宽w/h,边缘检测距离d,那么一张sdf生产的时间复杂度是O(w * h * d * d):

- 其实一般sdf生产的图像都会较小,如32x32或者64x64,边缘检测的d考虑到图像拓展和缩放的需求,一般也不会很大,往往8-16即可,上面的代码为了demo演示用了50。
- 另外时间复杂度还有优化空间,毕竟本质是二维图形碰撞测试,用四叉树优化可以裁剪掉50%以上的运算分支,当然这个和图形复杂度相关。
- 精度问题,算出来的灰度的精度,即算出来的距离值约准确约好,因此有多种提升计算精度的方式。a. 这里采用最简单的,扩大计算面积,算一张256x256的sdf再缩小到64x64,精度相当于x4。b. 边缘测量到一个像素内,通过原始的图像像素信息或者矢量信息对边缘部分精确采样,也能更准确的描述边缘。

因此能生成sdf图:
點擊在新視窗中瀏覽此圖片

渲染部分就很简单了,sdf渲染本质就是图像二值化,阈值在50%即是正常图形,代码如下:
        let render = function() {
            if (!need_render) {
                return;
            }
            need_render = false;
            console.log('Run Render');

            canvas_source_ctx.clearRect(0, 0, w, h);
            canvas_source_ctx.drawImage(image_source, 0, 0, w, h);
            let canvas_source_data = canvas_source_ctx.getImageData(0, 0, w, h);
            for (let y = 0; y < h; y++) {
                for (let x = 0; x < w; x++) {
                    canvas_source_data.data[(y * h + x) * 4 + 0] = 0;
                    canvas_source_data.data[(y * h + x) * 4 + 1] = 0;
                    canvas_source_data.data[(y * h + x) * 4 + 2] = canvas_source_data.data[(y * h + x) * 4 + 3] >= input2.value ? 255 : 0;
                    canvas_source_data.data[(y * h + x) * 4 + 3] = canvas_source_data.data[(y * h + x) * 4 + 3] >= input1.value ? 255 : 0;
                }
            }
            canvas_source_ctx.putImageData(canvas_source_data, 0, 0);
        }

入上demo地址里的二值化算法如上,通过canvas_source_data即sdf图片信息的alpha通道数据进行二值化,但是二值化阈值反应到了blue和alpha两个通道上,阈值来源与两个input,这样从而实现demo上的内外圈。

- 注意到只要不是50%的原始位置,有放大或者缩小是图形斜向边缘均会产生波浪形的噪声,这是因为采样缩放算法的误差导致的,并没有很好的办法解决。
- 在图像极其细小的时候图像边缘的粘连现象会十分严重,并且某些区域的缩放情况不符合预期,这里涉及到下一个话题msdf了。

多通道SDF——MSDF

以上描述的SDF是最原始的sdf做法,但是有些细节不尽人意,主要在边缘处理上:

- 对于有缩放的情况,在图像极小或者缩放极的时候,sdf的图像总是圆滑的,即本边缘应该是尖锐的情况也是如此。这个问题的原因是一个尖锐的灰度边缘,在放大后,肯定会变圆,何况我们sdf图的精度往往不够。

因此产生了多通道SDF——MSDF。msdf的原理是rgba图像多个通道中储存了多个sdf图,绘制的时候用多个sdf图中作二值化再叠加。每个二值化的图像肯定都是圆圆的边缘,但是如果把多个跌在一起,就能产生实际上尖锐的边缘了。

我们的demo【msdf】:https://lrdcq.com/test/sdf-canvas/msdf.html

这里要注意的是,一个图像怎么从一个通道拆成三个通道,是要进行专门设计的,即哪些地方是边缘,需要进行设计。这里demo里设计的是苹果的icon:
點擊在新視窗中瀏覽此圖片

其中叶子的两个角和苹果被吭的两个角需要保持尖锐,因此这里设计为了多个通道的交接部分,叠加起来这个边缘就尖锐了。
點擊在新視窗中瀏覽此圖片

当然,也有比较智能的方法,利用边缘查找,识别技术,能快速自动化生成确定边角的msdf图。在网上能搜索到一些游戏引擎的的msdf font生成器,即利用相关技术制作,如:https://msdf-bmfont.donmccurdy.com/

渲染的做法也很简单,二值化部分处理下即可:
        let render = function() {
            if (!need_render) {
                return;
            }
            need_render = false;
            console.log('Run Render');

            canvas_msdf_ctx.clearRect(0, 0, w, h);
            canvas_msdf_ctx.drawImage(image_source_m, 0, 0, w, h);
            let canvas_msdf_data = canvas_msdf_ctx.getImageData(0, 0, w, h);
            let r, g ,b;
            for (let y = 0; y < h; y++) {
                for (let x = 0; x < w; x++) {
                    r = canvas_msdf_data.data[(y * h + x) * 4 + 0];
                    g = canvas_msdf_data.data[(y * h + x) * 4 + 1];
                    b = canvas_msdf_data.data[(y * h + x) * 4 + 2];
                    //rgb三通道叠加两个
                    value = r + g + b - Math.max(r, g, b) - Math.min(r, g, b);

                    canvas_msdf_data.data[(y * h + x) * 4 + 0] = 0;
                    canvas_msdf_data.data[(y * h + x) * 4 + 1] = 0;
                    canvas_msdf_data.data[(y * h + x) * 4 + 2] = value >= input2.value ? 255 : 0;
                    canvas_msdf_data.data[(y * h + x) * 4 + 3] = value >= input1.value ? 255 : 0;
                }
            }
            canvas_msdf_ctx.putImageData(canvas_msdf_data, 0, 0);
        }

总的来说,msdf是sdf下非常理想的方案了,还包括的可用信息有:

- msdf利用多通道,rgba图,视实际情况当然分双通道,三通道,四通道,其中目前基本上三通道够用并且也是主流做法。
- 渲染部分其实也可以分双通道,三通道,四通道,这里是值得,三通道sdf图,如果一个像素在两个通道有值,则叠加有效,上面的demo就是这个做法;或者一个像素在三个通道均有值才有效,这是上面搜到的生成器的做法。者两个做法的区别不大,前者复杂图像边缘可能会有bug只适用于简单icon,只是后者设计于生成难度较大。
- 在msdf来保证精度的情况下,可以缩小sdf图看看最夸张icon图可以小到什么大小。因此demo【迷你msdf】:https://lrdcq.com/test/sdf-canvas/msdf_16.html,采用了几乎不可用的16x16的sdf与msdf。对比来看无论渲染大还是小,msdf的渲染效果均更好。当然整体还是很差,这个尺寸肯定不够用。

渲染成本

目前我们的sdf在web上的渲染方案和以前一些webp或apng的渲染兼容方案一样,是单独开canvas作渲染,因此肯定有较高的成本。因此在demo中对渲染成本作了度量:

【设备:mac pro 15款 chrome,测量方式:得到渲染时间平均值,10次无设备干扰导致的异常值】

测试A:相同图像面积下,不同图像数量的渲染:

- 32x32 10000次:34692 ms
- 64x64 2500次:9045 ms
- 128x128 625次:3010 ms
- 256x256 156.25次:1603 ms

可见:

- 渲染的消耗和遍历像素点基本上没啥关系,基本上是固定的,主要消耗在canvas上。也侧面说明纯js计算,就算是O(n*n)也完全不虚。
- 去除最小值,Canvas操作的成本大概是,每156.25次约0.5s左右。也就是说保持60fps的话,canvas同一tick里创建不要超过6个。

测试B:同时也作了相同canvas数量下,像素指数级增长带来的性能消耗:

- 32x32 1000次:3030 ms
- 64x64 1000次:3561 ms
- 128x128 1000次:4988ms
- 256x256 1000次:9964ms
- 512x512 1000次:30869ms

补充结论包括:

- 基本上在canvas面积极大的时候,耗时开始跟随面积的指数上升快速上涨了,在前几个值里,成本还是一canvas成本为主,像素计算成本忽略不计

结论

1. SDF/MSDF替代类似于iconfont的功能可行。
2. 相对于font储存,它确实能在更低空间占用的情况下保持矢量优势,并且可以支持较为准确的扩大缩小,可以做艺术字体。当然canvas渲染也可以导出图像。
3. 目前可行的canvas渲染成本较高,大面积使用需要忍受一定掉帧。
关键词:sdf , msdf , iconfont
logo