aardio 文档

GDI+ 绘图与性能优化

gdip 是 aardio 的 GDI+ 绘图库。canvas 动效画布、plus 高级控件的所有 onDraw.... 自绘事件传给我们的画板(gdip.graphics 对象),用的都是它。

本文讲两件事:

  1. 合成质量(compositingQuality)——它决定半透明与细线画出来好不好看,以及要不要为它付出性能代价。
  2. 性能优化——测量与实测数据,以及画得快、画得好、又不失真的写法。

本文数据都是在 200% DPI、1520×1000 像素的画布上实测的。文中的毫秒数只是相对量级,具体数值随机器与画布大小变化。

一. 合成质量:GDI+ 与 plus 观感不同的根源

1. 现象

同一段绘图代码,用 plus 控件画出来是精细好看的界面,换成 canvas 画出来"几乎看不到"那些半透明的细线。比如浅色细线网格叠在深色渐变背景上:

var gridPen = gdip.pen(0x10FFFFFF,1); //alpha 只有 0x10(16/255,约 6%)
for(x=0;width;80) graphics.drawLine(gridPen,x,0,x,height);

实测同一点的像素:

画布 像素值 红色分量
PARGB 位图 + 默认合成 0xFF191729 25
ARGB 位图 + 默认合成 0xFF191729 25
32bppRGB 位图 + 默认合成 0xFF191729 25
PARGB 位图 + compositingQuality = 2 0xFF48484C 72
plus 控件(内部设了 = 2) 0xFF48484C 72

三种像素格式结果完全相同,差异只来自 compositingQuality。 这不是 PARGB 的特性,也不是 canvas 的 bug。

~/lib/win/ui/ctrl/plus.aardio 第 253 行写了 graphics.compositingQuality = 2,canvas 没有设(默认 0)。这就是两者观感不同的全部原因。

2. 原理:线性混合 vs 伽马混合

compositingQuality 控制两个像素做 alpha 混合时在哪个"空间"里插值:

sRGB 是一条上凸曲线,线性空间里"一半的光"编码回 sRGB 大约是数值 188 而不是 128。因此低 alpha 的半透明色在暗背景上会被严重压低,伽马混合则明显更亮。

白色叠在深色背景 #0A081B 上,两种合成的红色分量:

alpha 线性混合(默认) 伽马混合(=2)
0x10 (6%) 25 72
0x20 (13%) 41 100
0x40 (25%) 71 138
0x80 (50%) 133 188
0xC0 (75%) 194 225
0xFF (100%) 255 255

alpha 越低差距越大(0x10 时差 近 3 倍),完全不透明时两种模式逐位相同。

方向很关键——伽马混合会让叠加结果"远离"背景色:

叠加内容 背景 alpha 线性混合 伽马混合
白 深色 0x40 71 138(更亮)
黑 白色 0x40 191 224(更淡)
红 蓝 0x40 0x4000BF 0x8900E0(红多蓝也多)

所以深色细线画在白底上时,伽马混合反而会让它更淡(更接近背景的直觉上是"更薄"),这与"伽马混合一定更清晰"的直觉相反。

3. 什么时候看得出来

伽马混合主要影响下面这些像素,只要不涉及它们,两种模式完全看不出差别:

看不出来的情况:大面积不透明填充、大色块、高 alpha 的贴图。这就是为什么"很多炫酷的动画在 canvas 上也很好看"——粒子、光晕、大面积不透明图形本来就不敏感;但这个棋盘恰好是低 alpha 细线叠深色底,正好踩在最敏感的位置上。

二. 要不要开伽马混合:代价正比于"混合流程覆盖的像素数"

这是本文最重要的一条规则。GDI+ 在 compositingQuality = 2 时,每一像素都走完整的混合运算,不再有"不透明就跳过"的快速路径。所以代价不取决于"半透明面积",而取决于"被强制走混合流程的像素数"。

实测 1520×1000 画布(次数:min of 5 轮×10 次):

场景 默认合成 伽马混合 倍数
10 个不透明矩形(关抗锯齿) 0.11 ms 0.11 ms 1.0×
120 个不透明圆(关抗锯齿) 0.81 ms 0.80 ms 1.0×
整屏 74% 遮罩(fillRect) 3.5 ms 49 ms 14×
33 条 1 像素低 alpha 细线 1.9 ms 3.7 ms 2.0×
10 个不透明矩形(开抗锯齿) 0.59 ms 19.4 ms 33×
120 个不透明圆(开抗锯齿) 2.0 ms 22.2 ms 11×
10 张贴图 drawImageRect(不透明 PARGB 图) 0.51 ms 19.5 ms 39×

几个反直觉但很重要的结论:

~/lib/win/ui/ctrl/canvas.aardio 文档里原来那条"不要把 compositingQuality 设为 2,在 PARGB 画布上会慢几十倍"只对了一半:慢几十倍的是"整屏半透明遮罩"和"开抗锯齿的不透明填充",而细线只贵 2 倍。把这一句理解成"q2 一律不能用"会错过下面这个最优解。

成本速查

操作 伽马混合下的代价
clear()、SourceCopy 贴图 无
低 alpha 细线、小面积半透明图元 2~3 倍,通常可接受
整屏半透明遮罩、大面积渐变叠加 10~15 倍,避免
开抗锯齿的不透明填充、drawImageRect 贴图 10~40 倍,避免

三. 首选方案:静态内容缓存成位图,只在建立时开伽马混合

如果棋盘、星空、渐变背景这类内容是静态的,却在每帧重画,那是双输:既付出了混合代价,又得到了不满意的观感。正确做法是建一次缓存位图(建的时候放心开伽马混合),之后每帧只贴图。

改造对比

原来的写法(每帧重画,观感不对):

// 固定的一批星星位置(示例用固定分布,实际可用 math.random 生成一次)
var stars = {};
for(i=1;120){
    table.push(stars,{
        x = (i * 97) % 480;
        y = (i * 61) % 440;
        size = i > 100 ? 3 : 1.5;
        alpha = i > 100 ? 200 : 60;
        typ = i > 100 ? 2 : 1;
    });
}

winform.gameBox.onDrawBackground = function(graphics,rc){
    var width,height = rc.width,rc.height;

    //每帧都要重画渐变——又慢,观感也不对
    var bgBrush = gdip.lineBrush(::POINTF(0,0),::POINTF(width,height),0xFF080818,0xFF1A0A2E,0);
    graphics.fillRect(bgBrush,::RECT(0,0,width,height));
    bgBrush.delete();

    //每帧都要重画星星——这里只画 2 颗示意
    var starBrush = gdip.solidBrush(0x3CFFFFFF);
    graphics.fillEllipse(starBrush,40,60,2,2);
    starBrush.color = 0xC8E0E8FF;
    graphics.fillEllipse(starBrush,180,120,3,3);
    starBrush.delete();

    //每帧都要重画网格——半透明细线画在深色底上,几乎看不见
    var gridPen = gdip.pen(0x10FFFFFF,1);
    for(x=0;width;80) graphics.drawLine(gridPen,x,0,x,height);
    for(y=0;height;80) graphics.drawLine(gridPen,0,y,width,y);
    gridPen.delete();
};

改造后的写法(缓存 + 贴图,与 plus 逐位相同):

var bgCache;

var buildBackground = function(w,h){
    if(bgCache) bgCache.delete();
    bgCache = gdip.bitmap(w,h,0xE200B/*_GdipPixelFormat32bppPARGB*/);

    var g = bgCache.getGraphics();
    g.smoothingMode = 4/*_GdipSmoothingModeAntiAlias*/;
    g.compositingQuality = 2/*_GdipCompositingQualityHighQuality*/; //只画这一次,放心开

    var bgBrush = gdip.lineBrush(::POINTF(0,0),::POINTF(w,h),0xFF080818,0xFF1A0A2E,0);
    g.fillRect(bgBrush,::RECT(0,0,w,h));
    bgBrush.delete();

    var starBrush = gdip.solidBrush(0xFFFFFFFF);
    for(i=1;#stars){
        var s = stars[i];
        starBrush.color = (s.alpha << 24) | (s.typ == 1 ? 0xFFFFFF : 0xE0E8FF);
        g.fillEllipse(starBrush,s.x,s.y,s.size,s.size);
    }
    starBrush.delete();

    var gridPen = gdip.pen(0x10FFFFFF,1);
    for(x=0;w;80) g.drawLine(gridPen,x,0,x,h);
    for(y=0;h;80) g.drawLine(gridPen,0,y,w,y);
    gridPen.delete();

    g.delete();
};

winform.gameBox.onDrawBackground = function(graphics,rc){
    //尺寸变了(窗口缩放)才重建缓存
    if(!bgCache || bgCache.width != rc.width || bgCache.height != rc.height){
        buildBackground(rc.width,rc.height);
    }

    //与画布同尺寸的不透明缓存,用 SourceCopy 覆盖写入最快
    graphics.compositingMode = 1/*_GdipCompositingModeSourceCopy*/;
    graphics.drawImageRect(bgCache,0,0,rc.width,rc.height);
    graphics.compositingMode = 0/*_GdipCompositingModeSourceOver*/;
};

winform.onDestroy = function(){
    if(bgCache) bgCache.delete();
};

上面的 buildBackground 里用的就是同一段绘图代码(渐变、星星、网格),只在开头多了三行:建 PARGB 位图、getGraphics()、开伽马混合。之后每帧只贴一次图。

实测结果(同一段绘图代码,只差合成质量):

指标 原写法 缓存方案 plus
网格线像素 0xFF191729 0xFF48484C 0xFF48484C
背景像素 0xFF090819 0xFF090819 0xFF090819
每帧重绘 2.8 ms 1.2 ms 6.0 ms

缓存方案的网格线与 plus 逐位完全相同,而且比每帧重画还快 2 倍多、比 plus 快 5 倍。

这个模式的适用条件很宽:只要背景不变(或很少变),就应该缓存。窗口尺寸改变时重建即可。

只对局部开伽马混合

如果低 alpha 图元是动态的、不能缓存,那就只在调用的前后临时切换:

// 动态的低 alpha 细线、发光、描边
var state = graphics.save();
graphics.compositingQuality = 2;

graphics.drawLine(finePen,x1,y1,x2,y2);
//...仅需要精细观感的那几笔...

graphics.restore(state); //自动恢复 compositingQuality、变换、剪辑等

// 之后的大面积填充、贴图用默认合成,不会变慢
graphics.fillRect(panelBrush,rc);
graphics.drawImageRect(sprite,x,y,w,h);

实测:局部切换本身不额外收费("线用 q2 + 不透明矩形用 q0"共 4.5 ms),比整帧开着 q2 再贴图(19.4 ms)快得多。

save() / restore() 会同时保存与恢复变换、剪辑区域与绘图质量设置(实测:保存后改 compositingQuality,restore 后会回到保存时的值)。这是局部切换质量设置的推荐写法,比手动改回去可靠。

四. 性能优化要点

1. 像素格式:用 PARGB(0xE200B)

画布与缓存位图都用 _GdipPixelFormat32bppPARGB(0xE200B):

var bmp = gdip.bitmap(w,h,0xE200B/*_GdipPixelFormat32bppPARGB*/);
格式 常量 用途
PARGB 0xE200B 预乘 alpha,GDI+ 内部合成最快的格式
ARGB 0x26200A 普通带 alpha 位图
32bppRGB 0x22009 不带 alpha

PARGB 的关键作用:

同一个图形用不同像素格式画出来的结果是一样的(本文开头的表格已证明),所以选 PARGB 只是为了快,不是为了好看。

2. 画刷、画笔、字体、路径都建一次反复用

// ✅ 建一次,反复用
var okBrush = gdip.solidBrush(0xFF4CC9F0);
var linePen = gdip.pen(0x40FFFFFF,1);

winform.canvas.onDrawForeground = function(graphics,rc){
    graphics.fillCircle(okBrush,x,y,20);
    graphics.drawLine(linePen,0,y,rc.width,y);
};

winform.onDestroy = function(){
    okBrush.delete();
    linePen.delete();
};

gdip.solidBrush、gdip.pen、gdip.font、gdip.path 每次构造都是一次原生对象分配,逐帧创建会明显拖慢。需要不同颜色时改建一个改 color 属性(solidBrush.color、pen.color 可运行时修改),不要重建对象。

生命周期:与窗口同寿命的建成常驻,窗口销毁时 delete();只在某一帧用到的临时对象(如一条临时路径)当帧 delete()。

3. 贴同尺寸缓存图用 SourceCopy

graphics.compositingMode = 1/*_CompositingModeSourceCopy*/;
graphics.drawImageRect(bgCache,0,0,w,h);
graphics.compositingMode = 0/*_CompositingModeSourceOver*/;

1520×1000 全屏贴图:SourceCopy 0.42 ms,默认混合 1.25 ms。约快 3 倍。

注意:

4. 大位图不要逐帧 clear()

clear() 实测 0.25 ms(1520×1000),而且不受合成模式与合成质量影响(默认混合 0.247 ms,SourceCopy 模式 0.242 ms),属于完全走覆盖写入的操作。

真正要注意的是:如果一次 clear() 紧接着就被一次贴满全屏的 drawImageRect 覆盖,那次 clear() 就是白干的——贴图已经重写了每个像素。canvas 每帧会先用 bgcolor 自动铺底,所有像素必被写一遍,所以绘图回调里通常没有清屏的需要(用 bgcolor 指定底色即可,不要写 bgcolor=0 再在回调里手动清屏)。

5. DPI:设备像素与逻辑坐标

winform.canvas.onDrawForeground = function(graphics,rc){
    var dpiX,dpiY = winform.dpiScale(1,1);
    graphics.scale(dpiX,dpiY); //之后按 100% DPI 的逻辑坐标绘图
    rc.scale(1/dpiX,1/dpiY);   //rc 也换成逻辑坐标

    //...按逻辑坐标绘图...
};

逻辑坐标下 1 单位的线宽在高分屏上自动变粗为 2 设备像素,观感才与低分屏一致。鼠标坐标要除以 dpi 才能与逻辑坐标对齐。

缓存位图也可以按逻辑尺寸建立,贴图时按设备像素尺寸缩放,但要接受一次插值重采样。静态背景通常没必要,直接按设备像素建立缓存更清晰。

6. 不要自己再建双缓冲

canvas 的画布本身就是常驻的内存位图,onPaint 与 plus 的绘图事件也已经有内部双缓冲。不要再自建整帧缓冲(额外的 gdip.bitmap + 每帧两次拷贝),实测 createCachedBitmap 对需要频繁更新的场景反而是负优化。

7. 用 ParGB 缓存位图做粒子精灵

大量、重复、半透明的小特效(粒子、光晕)适合预绘成小位图精灵(PARGB),逐帧只做 drawImageRect。这样每帧的绘图调用数从"每个图元一次"降到"每个精灵一次",而且半透明内容在精灵内部已经烘焙好,不必每帧重算渐变。

五. 常见坑

坑 说明
用 fillRectangle / drawRectangle 这两个是已弃用的别名,请用 fillRect / drawRect
用颜色数值代替对象作参数 gdip 的绘图函数都需要画刷/画笔对象,不能直接传颜色值(如 graphics.fillRect(0xFF0000,...))
忘了 delete() 常驻对象在窗口销毁时释放,临时对象当帧释放。虽然对象会随 GC 回收,但 GDI+ 资源是原生资源,及时释放才是好习惯
只在 onDrawForeground 里开 q2 背景与内容回调共用同一个画板对象,同一帧内设置会互相影响。用 save()/restore() 隔开
以为像素格式决定观感 决定观感的是 compositingQuality;像素格式只影响速度
以为伽马混合"总是更好" 深色叠浅色时伽马混合会让它更淡。观感是否符合预期要用像素断言验证
逐帧创建对象 画笔、画刷、字体、路径都建一次复用
对 compositingQuality = 2 一刀切禁用 细线只贵 2 倍;而"缓存位图里开一次"是完全免费的。见第二节

不推荐:靠"调大 alpha"来补偿

如果一时改不动结构,把 0x10 白线直接改成约 0x41,在 #0A081B 这样的固定底色上确实能得到接近伽马混合的效果。但这不是通用方案:

底色 q0 用 0x41 q2 用 0x10(目标) 偏差
#0A081B 0x48484C 0x48484C 一致
#1A0A2E 0x544863 0x4C4854 已明显偏色
#3A3A3A 0x6C6C6C 0x5A5A5A 偏亮

伽马补偿是非线性的、逐通道的,底色一变(尤其是渐变底色)就失准。只在完全固定的纯色底上可以当权宜之计,别当通用方案。

六. 验证:用像素断言代替肉眼判断

合成质量、像素格式这类东西不能靠眼睛猜。用 getPixel 做确定性断言最可靠,也快(不需要截图,不需要窗口显示):

import gdip;
import gdip.graphics;
import gdip.bitmap;
import gdip.solidBrush;
import util.testRunner;

var $ = util.testRunner("GDI+ 合成质量");

// 同一段绘图的像素结果
var probe = function(pixelFormat,q){
    var bmp = gdip.bitmap(64,64,pixelFormat);
    var g = bmp.getGraphics();
    g.clear(0xFF0A081B);
    g.compositingQuality = q;
    var pen = gdip.pen(0x10FFFFFF,1);
    g.drawLine(pen,32,0,32,64);
    pen.delete();
    var px = bmp.getPixel(32,32);
    g.delete(); bmp.delete();
    return px;
}

// 三种像素格式结果相同(与格式无关)
$.expect(probe(0xE200B,0),0xFF191729,"PARGB 默认合成");
$.expect(probe(0x26200A,0),0xFF191729,"ARGB 默认合成");
$.expect(probe(0x22009,0),0xFF191729,"32bppRGB 默认合成");

// 伽马混合才是 plus 的观感(与像素格式无关)
$.expect(probe(0xE200B,2),0xFF48484C,"PARGB 伽马混合");
$.expect(probe(0x22009,2),0xFF48484C,"32bppRGB 伽马混合");

return $.report();

要批量比对整张图,用 lockData32() 拿像素数组(注意是 bits.rows[y].pixels[x],行与列都从 1 开始):

var rows = bmp1.lockData32().bits.rows;
var rows2 = bmp2.lockData32().bits.rows;
var diff = 0;
for(y=1;bmp1.height){
    var a,b = rows[y].pixels, rows2[y].pixels;
    for(x=1;bmp1.width){
        if(a[x] != b[x]) diff++;
    }
}

相关链接

Markdown 格式