简介:开源图像编辑软件Paint.NET的完整C#工程源码及配套解析,面向C#/.NET开发者、图像处理爱好者,也适合想借鉴桌面端图形架构的中高级程序员,用于学习如何用托管代码实现类似Photoshop的图层、滤镜、画笔、插件等核心功能。压缩包体积36.1MB,共1590个文件,其中C#源码文件约690个(.cs),另有批处理构建脚本、ico/png图标资源、resx资源文件、DLL/Pdb编译产物,以及sln/csproj等工程文件,目录结构完整,便于按模块浏览编译与二次开发。截至目前已有904人学习下载。从源码中可重点研究WPF界面布局与数据绑定、Layer图层类及混合模式算法、基于像素操作的滤镜链条、可扩展插件系统设计,以及线程调度、颜色管理等实践细节;工程内还含bzip2/lzma/zlib等压缩算法相关模块与多种工程配置,能直观了解图像处理软件从底层算法到上层交互的完整分层。对理解图像处理软件架构和提升C#工程能力都很有参考价值。
1. 开源仿 Photoshop 的 C# 项目:Paint.NET 到底能给你什么
想给团队做一款内部修图工具,却发现连最基础的像素缩放都绕不开大量底层知识。开源仿 Photoshop 的 C# 项目 Paint.NET 是少数能让你直接拿到整套源码的参考实现:它用 C# 写成,在交互思路和分层文档上明显对标 Photoshop,同时代码量控制在一个人能读完全部核心类的规模。它解决的核心问题是——当你不想从零写一个图像编辑器时,有一份开源、可编译、可二次开发的骨架放在那里。适合三类人:想研究托管代码图像算法的、想快速搭建仿 PS 工具的、以及想给图像处理项目找一份规范工程参考的开发者。
2. 先看懂 Paint.NET 的源码骨架:从解决方案到核心层
2.1 解决方案里先分清三类项目:壳、核心库、插件接口
拿到源码第一件事不是读代码,是打开解决方案文件看结构。Paint.NET 的.sln打开后项目看着多,按编译产物归成三类就清晰了:一个可执行程序(程序入口、主窗体、菜单、工具栏)、一个核心类库(Surface、渲染上下文、文档模型、历史记录)、一个 Effect 接口库(给插件引用的程序集)。这个分法本身就是一套架构模板,很多商业图像软件也是这么切的。
这样分的最直接好处是依赖方向非常干净:可执行程序依赖核心库,核心库不依赖 UI;插件接口只依赖核心库的少数公开类型。这意味着第三方插件不会绑死 UI 层,以后你换菜单布局、改工具栏样式,都不会破坏已编译插件的兼容性。读代码时也可以按这个边界定位——菜单命令在可执行程序里找,像素算法在核心库里找,新滤镜入口在 Effects 项目里找。
我处理不熟悉的代码仓库时有个固定动作:先打开项目文件,把每个项目的<OutputType>列出来,区分出 exe、类库 dll、插件扩展三种产物。这一步花不了十分钟,但能让你后面找编译产物、找启动项目时不至于乱翻。Paint.NET 的工程结构在同类项目里算相当标准的分解,照着这个边界去读,比从入口函数一路跟下去要省力得多。
2.2 Surface 与 ColorBgra:托管代码里怎么高效摸像素
Surface 是 Paint.NET 渲染管线的核心,它封装了底层位图的内存布局,对外提供[x, y]索引访问单个像素的ColorBgra。ColorBgra是四字节结构,按蓝、绿、红、Alpha 排列。理解这个顺序很重要——很多从其他框架转过来的开发者默认内存里是 RGBA,直接按字节偏移去读就会得到左右颠倒的通道值。
Surface 还有一个绕不开的概念是 stride(行跨度)。位图内存按行排列,但为了保证对齐,一行的字节数不一定等于Width * 4,行尾可能有多余的填充字节。如果你拿着BitmapData.Stride按扫描行遍历,必须用 stride 而不是Width * 4计算下一行的起始地址;而走 Surface 的索引器则完全不需要关心这个细节。我见过不止一个人在这里翻车——按Width * 4手动算地址读出来的图像,每一行都向右斜切了一个偏移量。
在托管代码里高效遍历 Surface 的常规做法,是让循环顺序和内存排列一致:外层按行(y),内层按列(x),这样缓存命中率最高。Paint.NET 内部大量使用了不安全指针来加速批量像素操作,但写插件时优先用索引器就够了。索引器已经帮你处理了边界检查和 stride 换算,先保证正确,等性能分析确认是热点再考虑往指针层走。
2.3 撤销栈和选区掩码:仿 PS 交互的两个地基
「仿 Photoshop」最核心的交互不是滤镜效果,而是可撤销、有选区、分层文档。Paint.NET 的撤销模型是标准的命令模式:每个绘图操作或 Effect 在执行时会生成一条历史记录(HistoryMemento),入栈时保存操作前的 Surface 快照或恢复所需的增量数据。这个设计的聪明之处在于快照不一定是整张位图——对只影响小区域的操作,它只备份那个区域的像素,内存开销远小于整图拷贝。
选区的实现则是一张与 Surface 等宽高的二值掩码。很多 Effect 在对某个像素做处理前需要判断「这个像素在不在选区内」,正确方式是查掩码对应位,而不是去问 UI 层的选区几何对象。把选区数据绑定到位图上而不是绑定到形状上,是仿 PS 工具绕不开的设计决策。这种掩码方案在实现上最稳,也最容易和撤销机制配合——选区改变本身也是一条可撤销的历史记录。
如果你之后要自己从零搭一个类似的图像编辑器,建议直接抄这两块设计:命令模式的历史栈,加上与位图绑定的选区掩码。这两件事看起来简单,但一旦做成几何选区链表,后续所有基于像素的滤镜处理、蚂蚁线绘制、羽化边缘都会变得非常别扭。Paint.NET 的这两处实现属于被大量用户验证过的稳定方案,值得花时间读明白。
3. 把 Paint.NET 源码跑起来:环境配置与最小构建命令
3.1 目标框架匹配:先看仓库要求,再动本机工具链
把 Paint.NET 源码编译成能打开的运行程序,常规三步:装对应版本的 .NET SDK、装 Visual Studio 或直接用 dotnet 命令行工具、把仓库分支切到与 SDK 匹配的状态。这里最容易出问题的是目标框架匹配。Paint.NET 的不同历史分支跨度很大,有的基于 .NET Core 3.1,有的已经迁移到 .NET 6 或更高版本,不同分支依赖的 SDK 版本不一样。
我建议不要在自己机器上硬配一个特定版本,而是先看仓库根目录有没有global.json或Directory.Build.props,里面通常写明了 TargetFramework。然后对照本机装的 SDK 版本,用dotnet --list-sdks确认是否匹配。最常见的编译失败原因就是版本错位——本机装了最新 SDK,但代码里的 API 是老框架的;或者反过来,项目文件引用了本机没有的运行时版本。
我的固定习惯是装一个 LTS 版本 SDK,然后凡是编译报错说目标框架不匹配,就优先改项目文件里的TargetFramework去适配本机 SDK,而不是升级整个工具链。另外注意别把源码放在带中文或空格的目录路径里,某些原生依赖的构建步骤在这种路径下会出莫名奇妙的错误,这是 .NET 原生互操作层面一个很玄学的坑,避开就好。
3.2 用 CLI 跑通最小构建
不打开 Visual Studio,直接用 dotnet CLI 也能完成整个构建流程。在仓库根目录执行:
dotnet restore PaintDotNet.sln dotnet build PaintDotNet.sln -c Debug --no-restore第一条命令restore负责从 NuGet 拉取所有依赖包并生成项目间引用关系,第二条build执行实际编译。-c Debug指定配置为调试版本,--no-restore告诉构建过程跳过还原步骤,因为上一条命令已经做过了。Debug 配置适合你准备调试代码的场景,发布时再用-c Release。
如果你只改插件、只想验证某个 Effects 项目能否编译,可以缩小构建范围,只构建那个项目而不用动整个解决方案:
dotnet build src/Effects/PaintDotNet.Effects.csproj -c Debug命令行构建的好处是快且可脚本化,坏处是第一次还原依赖时如果网络不稳定,错误信息里会出现一大片 NuGet 报错。遇到这种问题先检查本机能不能正常访问 NuGet 源,再检查是不是有代理拦了请求。我还习惯在反复还原失败时执行dotnet nuget locals all --clear清掉本地包缓存——有些损坏的缓存包会一直导致还原失败,清了反而一次过。
3.3 构建成功之后:启动、断点与自检
构建完成后,可执行文件通常在bin/Debug或你指定的输出目录下。直接用命令行运行,或者在 IDE 里打开解决方案,把主程序项目设为启动项再按 F5。第一次跑起来后,先确认启动的是你刚编出来的版本——看标题栏或帮助里的版本信息,别拿成了安装目录里的旧版本。
调试时最值得下断点的位置是 Effect 的渲染方法。那里是像素循环的入口,你可以直接看到 Surface 的宽高、选区掩码的内容、以及每个像素当前的ColorBgra值。Visual Studio 的调试器在托管代码里看这些值非常直观,比靠日志猜状态高效得多。
验证源码是不是真的跑通,有个很实用的自检动作:新建文档画一个矩形,按 Ctrl+Z 撤销再重做。撤销栈正常说明文档模型和快照机制在工作;再用选区工具选一块区域然后填充,确认掩码同步正常。这两步过了,说明你手上的源码基本处于健康可改状态,可以放心动代码了。
4. Effect 插件才是仿 PS 的主战场:从写一个高斯模糊开始
4.1 理解 Effect 生命期:预览和最终执行不是一回事
Effect 是 Paint.NET 插件体系里的核心抽象。一个自定义滤镜实质上就是写一个继承自 Effect 的类,重写渲染方法,再用参数控件把可调项暴露给用户。源码自带的一批内置滤镜是最佳模板,写插件前先挑一个最接近你目标的读一遍。
Effect 的渲染方法签名里最关键的是三个参数:目标 Surface、源 Surface、感兴趣区域(roi)。方法的作用是把源 Surface 上 roi 区域内的像素处理之后写入目标 Surface。注意这个方法会被调用两次:一次是预览阶段,你拖动参数滑块时它会反复执行,而且 roi 往往只是当前视图里的一小块区域;另一次是最终执行阶段,点确定后 roi 才是完整的选区。
新手最容易写错的地方就是把渲染方法当成处理整图的方法——每次都遍历整张位图。预览阶段 slider 动一次就调一次,整图遍历直接让界面卡成幻灯片。正确做法是严格以 roi 为循环边界,只处理传入的那块区域。这个习惯不仅在预览时能救命,在多核分块渲染时也对,因为框架会把不同的 roi 分给不同线程并行执行,你在里面写整图逻辑会立刻产生线程安全问题。
4.2 最小可编译 Effect:亮度调整的完整骨架
下面这段代码是一个最小可编译的亮度调整 Effect,可以作为写第一个插件的基本骨架。它读一个参数值,把它加到每个像素的 RGB 三通道上:
public class BrightnessEffect : Effect { public BrightnessEffect() : base("Brightness Adjust", null, null) { } public override void Render(Surface dst, Surface src, RectFloat roi) { BrightnessToken token = (BrightnessToken)EffectConfigToken; int offset = token.Amount; int left = (int)roi.Left; int top = (int)roi.Top; int right = (int)roi.Right; int bottom = (int)roi.Bottom; for (int y = top; y < bottom; y++) { for (int x = left; x < right; x++) { ColorBgra pixel = src[x, y]; pixel.R = ClampToByte(pixel.R + offset); pixel.G = ClampToByte(pixel.G + offset); pixel.B = ClampToByte(pixel.B + offset); dst[x, y] = pixel; } } } private static byte ClampToByte(int value) { return (byte)(value < 0 ? 0 : (value > 255 ? 255 : value)); } } public class BrightnessToken : EffectConfigToken { public int Amount { get; set; } public BrightnessToken(int amount) { Amount = amount; } public override object Clone() { return new BrightnessToken(Amount); } }逻辑说明:渲染方法里双层循环只遍历 roi 内的坐标,src是输入的原始像素,dst是输出目标。每个像素取出后给 RGB 三通道加上同一个偏移量,然后写回dst。注意没有动 Alpha 通道——亮度调整不应该改变透明度。EffectConfigToken在这里负责携带参数,它的Clone方法每次预览时都会生成一个独立副本,避免多文档之间参数互相干扰。
参数说明:Amount是亮度的增量值,单位是 0 到 255 的色阶偏移量,正值提亮、负值变暗。ClampToByte把越界的值截断到 0 到 255,这是所有像素级算法都必须做的边界处理,不做的话字节溢出会出现诡异的色彩回绕。要让它出现在滤镜菜单里,把这个类编译成 dll 放进程序的 Effects 目录即可;如果想做成内置滤镜,则需要把它挂到菜单初始化代码里,这属于 UI 层的改动,插件开发一般不碰。
4.3 高斯模糊:从朴素卷积到分离卷积
亮度调整不依赖邻居像素,所以看不出性能陷阱。真正仿 PS 的场景是模糊、锐化这类基于邻域卷积的算法。高斯模糊的核心是对每个像素取周围窗口内加权平均,窗口越大越慢。第一次写非常容易写成朴素版本:
public override void Render(Surface dst, Surface src, RectFloat roi) { int radius = 2; int left = (int)roi.Left, top = (int)roi.Top; int right = (int)roi.Right, bottom = (int)roi.Bottom; for (int y = top; y < bottom; y++) { for (int x = left; x < right; x++) { int sumR = 0, sumG = 0, sumB = 0, count = 0; for (int dy = -radius; dy <= radius; dy++) { for (int dx = -radius; dx <= radius; dx++) { int nx = x + dx, ny = y + dy; if (nx < 0 || ny < 0 || nx >= src.Width || ny >= src.Height) continue; ColorBgra p = src[nx, ny]; sumR += p.R; sumG += p.G; sumB += p.B; count++; } } ColorBgra o = src[x, y]; o.R = (byte)(sumR / count); o.G = (byte)(sumG / count); o.B = (byte)(sumB / count); dst[x, y] = o; } } }这个版本的复杂度是宽乘高乘半径的平方。半径 2 的 5×5 窗口还算能跑,半径一旦上到 10,整图处理会卡得没法做预览。问题不在算法本身,而在做了很多重复读取——相邻像素的窗口有大量重叠区域被反复求和。
常规优化是分离卷积:把二维高斯核拆成水平一维和垂直一维两次卷积。因为高斯核是可分离的,先对每一行做横向模糊,再对每一列做纵向模糊,结果和二维卷积完全等价,但每像素访问次数从(2r+1)^2降到2(2r+1)。半径 10 时两者差一个数量级。我一般先写朴素版本保证逻辑正确,跑通后再替换成分离版本,用像素 diff 验证两版输出一致——先正确再优化,别一上来就上 SIMD 或并行。
5. 避坑指南:Paint.NET 二次开发的 5 个常见翻车现场
5.1 插件 dll 放对目录了,却依然加载不出来
现象:编译生成了插件 dll,复制到了程序目录下,重启之后滤镜菜单里就是找不到新插件。
原因:Paint.NET 只在特定的 Effects 子目录扫描插件程序集,把 dll 放在根目录不会被加载。而且插件列表有缓存,新增的 dll 不会立即被识别。
解决:先确认 dll 复制到了正确位置——程序目录下的 Effects 子目录里。然后清除插件缓存,常见位置在用户配置目录下,把缓存文件删掉再重启程序。我用这个流程解决了几乎所有「插件不出现」的问题,很少真有代码层面的加载失败。
5.2 拖动参数滑块时预览卡成幻灯片
现象:点击 Effect 弹出参数面板,拖动滑块调节数值,图像预览区域的响应速度跟慢放一样,松手之后很久才刷新。
现象背后的逻辑:预览阶段每动一次滑块就会重新执行一次渲染方法。如果渲染方法里没有按 roi 做局部处理,而是每次遍历整张位图,复杂度会高几十倍。假设一次全图处理要 200 毫秒,你每拖一格滑块就卡一次,体验自然极差。
解决:以传入的 roi 作为循环边界,只处理需要刷新的区域。更进一步的优化是加快速路径——当 roi 面积很小(比如小于整图 10%)时用降采样处理,预览时牺牲一点精度换流畅度,最终执行时再用完整精度跑一遍。这个方案在仿 PS 场景里几乎是标准做法。
5.3 撤销一次后图像左右错位
现象:执行滤镜后按 Ctrl+Z 撤销,图像内容每一行都向左或向右偏移了一段距离,看起来像被斜切了。
原因:这是对位图行跨度的误解。Surface 底层位图为了保证内存对齐,行的字节数不一定是Width * 4,行尾存在填充字节。如果你在实现自定义历史记录或手动拷贝像素时用了Width * 4来算每行起始地址,填充字节会被当成下一行像素读进来,导致逐行错位。
解决:所有像素读写一律通过 Surface 提供的[x, y]索引器或行指针获取方法,不要自己拿跨度算地址。这是 C# 图像处理里最有名的一个坑,连很多做了多年的人都还在踩。遇到错位图像先检查有没有任何地方手动算了行偏移。
5.4 多开文档时参数串台
现象:打开两个文档,给 A 文档执行滤镜时把滑块调到了 80,切到 B 文档再打开同一个滤镜,参数直接变成了 80,而不是默认值。
原因:参数被放在了静态字段里。Effect 实例会被程序反复复用,而静态字段在多个文档之间是共享的。A 文档对静态字段的赋值立刻影响到了 B 文档的参数读取。
解决:参数一律放在EffectConfigToken实例的私有字段里,渲染方法从 token 读参数,绝不用静态变量。token 是每次执行时新建的,天然做到了文档间隔离。这条规则对所有可能被并发执行或复用实例的托管图像框架都适用。
5.5 升级运行时后,旧 API 集体亮黄灯
现象:把项目迁移到新版 .NET 运行时后,编译输出里冒出一大片过时 API 警告,有的甚至直接报错。
原因:Paint.NET 某些历史版本的代码依赖旧框架的行为,比如特定 GDI+ 调用的容错处理。这些行为在新版运行时里要么被标记过时,要么签名变了。直接升级会让大量原本稳定代码突然失去支持。
解决:别急着升 TargetFramework,先保持仓库自带的版本跑通。如果你的业务必须依赖新运行时,优先看项目当前主分支是否已经完成迁移,而不是自己手动改一堆过时 API 调用。我一般先建一个基线——在完全不改代码的情况下确认工程能编译能运行,之后再动自己的改动。这样出了问题能分清楚是环境迁移的影响,还是业务代码本身的问题。
6. 验证你的改动:用像素级 diff 脚本做回归
写滤镜最怕的不是编译不过,而是看着差不多但像素值差了一点。这类像素级算法的问题靠眼睛很难发现,尤其当差异只出现在某个通道或某个小区域时。我现在给所有自定义 Effect 都配了一对基准图和 diff 脚本,任何改动跑一遍就知道有没有破坏输出。
diff 脚本的核心逻辑很简单:逐像素比较三通道的绝对差,超过阈值就把差异点标记出来。下面是一个最小实现:
public static void ComputeDiff(Surface baseline, Surface current, string outputPath) { int width = Math.Min(baseline.Width, current.Width); int height = Math.Min(baseline.Height, current.Height); using Surface diff = new Surface(width, height); for (int y = 0; y < height; y++) { for (int x = 0; x < width; x++) { ColorBgra b = baseline[x, y]; ColorBgra c = current[x, y]; int delta = Math.Abs(b.R - c.R) + Math.Abs(b.G - c.G) + Math.Abs(b.B - c.B); diff[x, y] = delta > 12 ? ColorBgra.Red : ColorBgra.Black; } } diff.Save(outputPath); }逻辑说明:脚本把两张图逐像素对比,baseline是改动前的标准输出,current是改动后的新输出。三通道差异之和超过阈值的像素被标记成红色,其余保持黑色。这张 diff 图能一眼看出哪些区域的输出被改动影响到了。
参数说明:阈值 12 是经验值,不是随便拍的。浮点运算的舍入误差、压缩编码的轻微失真,通常会产生 ±2 左右的通道差异;超过 12 基本可以确定是逻辑错误而非精度误差。如果你对比的是 PNG 重编码前后的结果,可以放宽到 8;如果对比的是 JPEG 转码后的图像,建议调到 30 以上,否则全是噪点没法看。
我现在的固定流程是:每个滤镜建一张有代表性的测试图,保存旧版本输出;改代码后重新跑一遍输出,执行 diff 脚本,确认差异像素数为零或限定在已知的精度阈值内。有一回改了一个通道读取顺序的错误,红绿通道被对调,肉眼完全没看出来,diff 图上一大片红色直接定位到了问题行。从那以后,像素级对比就成了我所有图像算法改动的最后一道关。这个习惯救过我很多次,希望帮到你。
本文还有配套的精品资源,点击获取