gdip 是 aardio 的 GDI+ 绘图库。canvas 动效画布、plus 高级控件的所有 onDraw.... 自绘事件传给我们的画板(gdip.graphics 对象),用的都是它。
本文讲两件事:
compositingQuality)——它决定半透明与细线画出来好不好看,以及要不要为它付出性能代价。本文数据都是在 200% DPI、1520×1000 像素的画布上实测的。文中的毫秒数只是相对量级,具体数值随机器与画布大小变化。
同一段绘图代码,用 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)。这就是两者观感不同的全部原因。
compositingQuality 控制两个像素做 alpha 混合时在哪个"空间"里插值:
结果 = 前景 × alpha + 背景 × (1 - 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(红多蓝也多) |
所以深色细线画在白底上时,伽马混合反而会让它更淡(更接近背景的直觉上是"更薄"),这与"伽马混合一定更清晰"的直觉相反。
伽马混合主要影响下面这些像素,只要不涉及它们,两种模式完全看不出差别:
看不出来的情况:大面积不透明填充、大色块、高 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× |
几个反直觉但很重要的结论:
smoothingMode = 4),所以这个才是常态。drawImageRect 贴图在伽马混合下也走混合(慢 39 倍),即使源图完全不透明。clear() 与 compositingMode = SourceCopy 的贴图完全不受影响(0.46 ms → 0.45 ms),因为它们是覆盖写入,根本不做混合。
~/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后会回到保存时的值)。这是局部切换质量设置的推荐写法,比手动改回去可靠。
0xE200B)画布与缓存位图都用 _GdipPixelFormat32bppPARGB(0xE200B):
var bmp = gdip.bitmap(w,h,0xE200B/*_GdipPixelFormat32bppPARGB*/);
| 格式 | 常量 | 用途 |
|---|---|---|
| PARGB | 0xE200B |
预乘 alpha,GDI+ 内部合成最快的格式 |
| ARGB | 0x26200A |
普通带 alpha 位图 |
| 32bppRGB | 0x22009 |
不带 alpha |
PARGB 的关键作用:
UpdateLayeredWindow 需要的预乘 BGRA,也与 sys.mediaFoundation 视频帧的 RGB32 布局一致同一个图形用不同像素格式画出来的结果是一样的(本文开头的表格已证明),所以选 PARGB 只是为了快,不是为了好看。
// ✅ 建一次,反复用
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()。
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 倍。
注意:
compositingQuality 影响,所以缓存方案里贴图那一步永远是快的。0,否则后面的半透明绘图会变成覆盖写入。clear()clear() 实测 0.25 ms(1520×1000),而且不受合成模式与合成质量影响(默认混合 0.247 ms,SourceCopy 模式 0.242 ms),属于完全走覆盖写入的操作。
真正要注意的是:如果一次 clear() 紧接着就被一次贴满全屏的 drawImageRect 覆盖,那次 clear() 就是白干的——贴图已经重写了每个像素。canvas 每帧会先用 bgcolor 自动铺底,所有像素必被写一遍,所以绘图回调里通常没有清屏的需要(用 bgcolor 指定底色即可,不要写 bgcolor=0 再在回调里手动清屏)。
rc 与画板默认坐标都是设备像素。 在高分屏上,1 设备像素只是很细的线(200% DPI 下 1 像素 ≈ 0.5 逻辑像素),这正是细线看起来"几乎看不见"的一个加剧因素。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 才能与逻辑坐标对齐。
缓存位图也可以按逻辑尺寸建立,贴图时按设备像素尺寸缩放,但要接受一次插值重采样。静态背景通常没必要,直接按设备像素建立缓存更清晰。
canvas 的画布本身就是常驻的内存位图,onPaint 与 plus 的绘图事件也已经有内部双缓冲。不要再自建整帧缓冲(额外的 gdip.bitmap + 每帧两次拷贝),实测 createCachedBitmap 对需要频繁更新的场景反而是负优化。
大量、重复、半透明的小特效(粒子、光晕)适合预绘成小位图精灵(PARGB),逐帧只做 drawImageRect。这样每帧的绘图调用数从"每个图元一次"降到"每个精灵一次",而且半透明内容在精灵内部已经烘焙好,不必每帧重算渐变。
| 坑 | 说明 |
|---|---|
用 fillRectangle / drawRectangle |
这两个是已弃用的别名,请用 fillRect / drawRect |
| 用颜色数值代替对象作参数 | gdip 的绘图函数都需要画刷/画笔对象,不能直接传颜色值(如 graphics.fillRect(0xFF0000,...)) |
忘了 delete() |
常驻对象在窗口销毁时释放,临时对象当帧释放。虽然对象会随 GC 回收,但 GDI+ 资源是原生资源,及时释放才是好习惯 |
只在 onDrawForeground 里开 q2 |
背景与内容回调共用同一个画板对象,同一帧内设置会互相影响。用 save()/restore() 隔开 |
| 以为像素格式决定观感 | 决定观感的是 compositingQuality;像素格式只影响速度 |
| 以为伽马混合"总是更好" | 深色叠浅色时伽马混合会让它更淡。观感是否符合预期要用像素断言验证 |
| 逐帧创建对象 | 画笔、画刷、字体、路径都建一次复用 |
对 compositingQuality = 2 一刀切禁用 |
细线只贵 2 倍;而"缓存位图里开一次"是完全免费的。见第二节 |
如果一时改不动结构,把 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++;
}
}
compositingQuality = 2,这是它与 canvas 观感不同的原因~/examples/canvas/ —— 游戏与动画范例