# GDI+ 绘图与性能优化

`gdip` 是 aardio 的 GDI+ 绘图库。canvas 动效画布、plus 高级控件的所有 `onDraw....` 自绘事件传给我们的画板（`gdip.graphics` 对象），用的都是它。

本文讲两件事：

1. **合成质量**（`compositingQuality`）——它决定半透明与细线画出来好不好看，以及要不要为它付出性能代价。
2. **性能优化**——测量与实测数据，以及画得快、画得好、又不失真的写法。

> 本文数据都是在 200% DPI、1520×1000 像素的画布上实测的。文中的毫秒数只是相对量级，具体数值随机器与画布大小变化。

## 一. 合成质量：GDI+ 与 plus 观感不同的根源

### 1. 现象

同一段绘图代码，用 plus 控件画出来是精细好看的界面，换成 canvas 画出来"几乎看不到"那些半透明的细线。比如浅色细线网格叠在深色渐变背景上：

```aardio
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 混合时在哪个"空间"里插值：

- **0（默认，线性混合）**：直接在 sRGB 数值上插值。`结果 = 前景 × alpha + 背景 × (1 - alpha)`
- **2（HighQuality，伽马混合）**：先把 sRGB 反伽马到线性光空间，混合，再编码回 sRGB

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 的半透明色**（0x10~0x60 最明显）
- **抗锯齿的边缘像素**（实测同一位置线性混合 R=232，伽马混合 R=243）
- **细线**（1 像素宽的抗锯齿线条大部分像素都处于边缘混合状态）

看不出来的情况：大面积不透明填充、大色块、高 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×** |

几个反直觉但很重要的结论：

- **关抗锯齿的时候，不透明图元在伽马混合下是零成本的**（0.11 ms → 0.11 ms）。GDI+ 仍然走了快速路径。
- **一开抗锯齿，连完全不透明的矩形也变慢 33 倍。** 因为抗锯齿会强制走混合流程。canvas 与 plus 都默认开抗锯齿（`smoothingMode = 4`），所以这个才是常态。
- **`drawImageRect` 贴图在伽马混合下也走混合（慢 39 倍）**，即使源图完全不透明。
- **`clear()` 与 `compositingMode = SourceCopy` 的贴图完全不受影响**（0.46 ms → 0.45 ms），因为它们是覆盖写入，根本不做混合。
- **细线只贵 2 倍**，因为它覆盖的像素少。

> `~/lib/win/ui/ctrl/canvas.aardio` 文档里原来那条"不要把 `compositingQuality` 设为 2，在 PARGB 画布上会慢几十倍"只对了一半：慢几十倍的是"整屏半透明遮罩"和"开抗锯齿的不透明填充"，而细线只贵 2 倍。把这一句理解成"q2 一律不能用"会错过下面这个最优解。

### 成本速查

| 操作 | 伽马混合下的代价 |
|---|---|
| `clear()`、`SourceCopy` 贴图 | 无 |
| 低 alpha 细线、小面积半透明图元 | 2~3 倍，通常可接受 |
| 整屏半透明遮罩、大面积渐变叠加 | 10~15 倍，避免 |
| 开抗锯齿的不透明填充、`drawImageRect` 贴图 | 10~40 倍，避免 |

## 三. 首选方案：静态内容缓存成位图，只在建立时开伽马混合

如果棋盘、星空、渐变背景这类内容是静态的，却在每帧重画，那是双输：既付出了混合代价，又得到了不满意的观感。正确做法是**建一次缓存位图**（建的时候放心开伽马混合），**之后每帧只贴图**。

### 改造对比

原来的写法（每帧重画，观感不对）：

```aardio
// 固定的一批星星位置（示例用固定分布，实际可用 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 逐位相同）：

```aardio
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 图元是动态的、不能缓存，那就只在调用的前后临时切换：

```aardio
// 动态的低 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`）：

```aardio
var bmp = gdip.bitmap(w,h,0xE200B/*_GdipPixelFormat32bppPARGB*/);
```

| 格式 | 常量 | 用途 |
|---|---|---|
| PARGB | `0xE200B` | 预乘 alpha，GDI+ 内部合成最快的格式 |
| ARGB | `0x26200A` | 普通带 alpha 位图 |
| 32bppRGB | `0x22009` | 不带 alpha |

PARGB 的关键作用：

- **GDI+ 合成时最直接**——贴图像时不用每次转换；实测 12 张 ARGB 贴图画在 PARGB 上要 0.70 ms，预转成 PARGB 后只要 0.33 ms
- **alpha 通道可信**——用 HDC 画到 32 位 DIB 上时 alpha 是垃圾值，PARGB 位图不是
- **正是 `UpdateLayeredWindow` 需要的预乘 BGRA，也与 `sys.mediaFoundation` 视频帧的 RGB32 布局一致**

同一个图形用不同像素格式画出来的结果是一样的（本文开头的表格已证明），所以选 PARGB 只是为了快，不是为了好看。

### 2. 画刷、画笔、字体、路径都建一次反复用

```aardio
// ✅ 建一次，反复用
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

```aardio
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 倍。

注意：

- SourceCopy 是**覆盖**写入，会丢掉目标原有的内容。只适合与画布同尺寸、覆盖整个画布的缓存背景。
- SourceCopy 贴图**完全不受 `compositingQuality` 影响**，所以缓存方案里贴图那一步永远是快的。
- 贴完记得改回默认值和 `0`，否则后面的半透明绘图会变成覆盖写入。

### 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：设备像素与逻辑坐标

- **画布给的 `rc` 与画板默认坐标都是设备像素。** 在高分屏上，1 设备像素只是很细的线（200% DPI 下 1 像素 ≈ 0.5 逻辑像素），这正是细线看起来"几乎看不见"的一个加剧因素。
- 想让绘图与 DPI 无关，在绘图回调开头切到逻辑坐标：

```aardio
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` 做确定性断言最可靠，也快（不需要截图，不需要窗口显示）：

```aardio
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 开始）：

```aardio
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++;
	}
}
```

## 相关链接

- [canvas 动效画布](https://www.aardio.com/zh-cn/docs/library-guide/std/win/ui/ctrl/canvas.html) —— 游戏与动画的常用控件，其中的绘图缓存建议与本文第三节一致
- [plus 高级控件](https://www.aardio.com/zh-cn/docs/library-guide/std/win/ui/ctrl/plus.html) —— 内部使用 `compositingQuality = 2`，这是它与 canvas 观感不同的原因
- [窗口绘图基础](https://www.aardio.com/zh-cn/docs/library-guide/std/win/ui/drawing.html) —— GDI / GDI+ / com.picture / gdip.bitmap 与颜色格式的基础概念
- [aardio 范例 `~/examples/canvas/`](https://www.aardio.com/zh-cn/docs/examples/canvas/) —— 游戏与动画范例
- [GDI+ 参考手册（微软）](https://learn.microsoft.com/zh-cn/windows/win32/gdiplus/-gdiplus-gdi-start)
