使用 C# 为 Paint.NET 2.1 编写特效插件






4.92/5 (16投票s)
2005年1月2日
12分钟阅读

95681

1825
本文介绍如何使用 C# 为 Paint.NET 2.1 创建自己的特效插件。
引言
Paint.NET 2.1 上周发布了。它被设计为 Windows 自带的画图程序的免费替代品,对广大普通用户来说非常有吸引力。但对于开发者来说,它更有吸引力,原因有两个。第一,它是开源的。如果你喜欢研究几兆字节的 C# 代码,或者想了解一些架构问题是如何解决的,那就去下载它吧。第二,该应用程序提供了一个简单而吸引人的接口,用于创建自己的特效插件。这正是本文的主题(如果你在寻找一些花哨的特效算法,请到别处去,因为本文中使用的特效相当简单)。
入门
您需要做的第一件事是获取 Paint.NET 源代码。除了源代码本身,它还兼作其文档和 Paint.NET SDK。该解决方案包含多个项目。但是,在开发 Paint.NET 特效插件时,唯一相关的项目是 PdnLib
库,它包含了我们将用于渲染特效的类,以及 Effects
库,它包含了用于派生您自己的特效实现的基类。
项目基础
要创建新的特效插件,我们首先创建一个新的 C# 类库,并添加对 PdnLib
(PdnLib.dll) 和 PaintDotNet.Effects
库 (PaintDotNet.Effects.dll) 正式发布版本的引用。我们项目的根命名空间应该设置为 PaintDotNet.Effects
,因为我们正在创建一个旨在无缝集成的插件。当然,这不仅仅限于命名空间,更是一个通用规则:在为 Paint.NET 编写软件时,要像 Paint.NET 的开发者那样做。实际的实现需要派生三个类。
Effect
是所有 Paint.NET 特效实现的基本类,它也是 Paint.NET 用于非参数化特效的接口。它包含Render(RenderArgs, RenderArgs, Rectangle)
方法,派生的非参数化特效会重写此方法。- 大多数特效都是参数化的。
EffectConfigToken
类是所有特定特效参数类的基类。 - 最后,由于参数化特效很可能需要一个用户界面,因此有一个用于特效对话框的基类:
EffectConfigDialog
。
实现基础设施
现在,我们将以噪点特效(顾名思义,它只是给图像添加噪点)为例,看看实现细节。顺便说一句,在使用本文提供的源代码时,您很可能需要更新 Paint.NET 库的引用。
特效参数
正如我之前所说,我们需要派生一个类来继承 EffectConfigToken
,以便能够传递我们的特效参数。鉴于我们的特效名为噪点特效,并且我们希望与现有源代码保持一致,我们的参数类必须命名为 NoiseEffectConfigToken
。
public class NoiseEffectConfigToken : EffectConfigToken
关于构造函数应该是什么样子,没有硬性规定。您可以使用简单的默认构造函数,或者带有参数的构造函数。从 Paint.NET 的角度来看,这无关紧要,因为(正如您稍后将看到的)负责创建 EffectConfigToken
实例的是(派生自)EffectConfigDialog
的类。所以,您不必做任何额外的事情,只需要有一个非私有构造函数。
public NoiseEffectConfigToken() : base()
{
}
但是,我们的基类实现了 ICloneable
接口,并定义了一个如何处理克隆的模式。因此,我们需要创建一个 protected
构造函数,它接受一个该类自身类型的对象,并使用它来复制所有值。然后,我们需要重写 Clone()
方法,并使用 protected
构造函数来进行实际的克隆。这也意味着构造函数应该调用基类构造函数,但 Clone()
不能调用其基类实现。
protected NoiseEffectConfigToken(NoiseEffectConfigToken copyMe) : base(copyMe)
{
this.frequency = copyMe.frequency;
this.amplitude = copyMe.amplitude;
this.brightnessOnly = copyMe.brightnessOnly;
}
public override object Clone()
{
return new NoiseEffectConfigToken(this);
}
其余的实现细节再次完全取决于您。最有可能的是,您将定义一些私有字段和相应的公共属性(如果适用,可能还包含一些合理性检查)。
设置特效参数的用户界面
现在我们有了一个参数容器,我们需要一个用户界面来设置它们。如前所述,我们将从 EffectConfigDialog
派生 UI 对话框。这一点很重要,因为它有助于确保整个 UI 的一致性。例如,在 Paint.NET 2.0 中,特效对话框默认以 0.9 的不透明度显示(终端服务会话除外)。如果我不使用 Paint.NET 的基类,而开发人员决定不透明度为 0.6 更酷,我的对话框就会突然看起来“不对劲”。由于我们仍然试图与原始代码保持一致,所以我们的 UI 类名为 NoiseEffectConfigDialog
。
同样,在设计对话框方面,您有很多自由度,所以我会再次专注于强制性的实现细节。特效对话框完全负责创建和维护特效参数对象。因此,有三个虚拟基方法您必须重写。而且,可能出乎意料的是,不要调用它们的基类实现(似乎基类实现的一些早期版本在被调用时甚至会抛出异常)。第一个是 InitialInitToken()
,它负责创建一个新的具体 EffectConfigToken
,并将引用存储在 protected
字段 theEffectToken
中(它将隐式地将引用转换为 EffectConfigToken
引用)。
protected override void InitialInitToken()
{
theEffectToken = new NoiseEffectConfigToken();
}
其次,我们需要一个方法来根据对话框的状态更新特效令牌。因此,我们需要重写 InitTokenFromDialog()
方法。
protected override void InitTokenFromDialog()
{
NoiseEffectConfigToken token = (NoiseEffectConfigToken)theEffectToken;
token.Frequency = (double)FrequencyTrackBar.Value / 100.0;
token.Amplitude = (double)AmplitudeTrackBar.Value / 100.0;
token.BrightnessOnly = BrightnessOnlyCheckBox.Checked;
}
最后,我们需要能够做反向操作。也就是说,根据令牌的值更新 UI。这就是 InitDialogFromToken()
的作用。与其他两个方法不同,这个方法需要一个令牌的引用来处理。
protected override void InitDialogFromToken(EffectConfigToken effectToken)
{
NoiseEffectConfigToken token = (NoiseEffectConfigToken)effectToken;
if ((int)(token.Frequency * 100.0) > FrequencyTrackBar.Maximum)
FrequencyTrackBar.Value = FrequencyTrackBar.Maximum;
else if ((int)(token.Frequency * 100.0) < FrequencyTrackBar.Minimum)
FrequencyTrackBar.Value = FrequencyTrackBar.Minimum;
else
FrequencyTrackBar.Value = (int)(token.Frequency * 100.0);
if ((int)(token.Amplitude * 100.0) > AmplitudeTrackBar.Maximum)
AmplitudeTrackBar.Value = AmplitudeTrackBar.Maximum;
else if ((int)(token.Amplitude * 100.0) < AmplitudeTrackBar.Minimum)
AmplitudeTrackBar.Value = AmplitudeTrackBar.Minimum;
else
AmplitudeTrackBar.Value = (int)(token.Amplitude * 100.0);
FrequencyValueLabel.Text = FrequencyTrackBar.Value.ToString("D") + "%";
AmplitudeValueLabel.Text = AmplitudeTrackBar.Value.ToString("D") + "%";
BrightnessOnlyCheckBox.Checked = token.BrightnessOnly;
}
我们几乎完成了。还有什么没完成的呢?我们需要通知应用程序值何时被更改,以及用户最终决定是应用更改到图像还是取消操作。因此,每当用户更改某个值时,就调用 UpdateToken()
来让应用程序知道需要更新预览。另外,离开对话框时调用 Close()
并设置适当的 DialogResult
。例如:
private void AmplitudeTrackBar_Scroll(object sender, System.EventArgs e)
{
AmplitudeValueLabel.Text = AmplitudeTrackBar.Value.ToString("D") + "%";
UpdateToken();
}
private void OkButton_Click(object sender, System.EventArgs e)
{
DialogResult = DialogResult.OK;
Close();
}
private void EscButton_Click(object sender, System.EventArgs e)
{
DialogResult = DialogResult.Cancel;
Close();
}
实现特效
现在一切就绪,可以开始实现特效了。如前所述,有一个非参数化特效的基类。噪点特效是参数化的,但这并不能阻止我们从 Effect
派生。但是,为了让 Paint.NET 知道这是一个参数化特效,我们还需要实现 IConfigurableEffect
接口,它会添加另一个重载的 Render()
方法。它还引入了 CreateConfigDialog()
方法,允许应用程序创建特效对话框。
public class NoiseEffect : Effect, IConfigurableEffect
那么,我们如何构造一个 Effect
对象,或者在这种情况下,一个 NoiseEffect
对象呢?这次,我们必须遵循应用程序的模式,这意味着我们使用一个 public
默认构造函数,它调用两个基类构造函数之一。第一个构造函数接受特效的名称、描述和要在“特效”菜单中显示的图标。第二个构造函数额外需要特效的快捷键。然而,快捷键仅适用于被归类为“调整”的特效。对于普通特效,它将被忽略(有关特效和调整的详细信息,请参见“特效属性”一章)。结合一些资源管理,这可能看起来像这样:
public NoiseEffect() : base(NoiseEffect.resources.GetString("Text.EffectName"),
NoiseEffect.resources.GetString("Text.EffectDescription"),
(Image)NoiseEffect.resources.GetObject("Icons.NoiseEffect.bmp"))
{
}
我们唯一必须实现的,是实现 IConfigurableEffect
接口所带来的部分。实现 CreateConfigDialog()
非常简单,它只是创建一个对话框对象并返回它的引用。
public EffectConfigDialog CreateConfigDialog()
{
return new NoiseEffectConfigDialog();
}
应用特效更有趣,但我们将遇到一些我们可能从未听说过的奇怪类。所以,让我们先看看 Render()
方法的签名:
public void Render(EffectConfigToken properties,
PaintDotNet.RenderArgs dstArgs,
PaintDotNet.RenderArgs srcArgs,
PaintDotNet.PdnRegion roi)
RenderArgs
类包含了我们操作图像所需的一切;最重要的是,它为我们提供了 Surface
对象,这些对象实际允许读取和写入像素。但是,请注意不要混淆 dstArgs
和 srcArgs
。srcArgs
对象(当然包括其 Surface
)处理的是原始图像。因此,您绝对不应该对这些对象执行任何写操作。但您将不断地从源 Surface
读取,因为一旦您更改了目标 Surface
,没有人会重置它们。目标(或目标)Surface
可通过 dstArgs
对象访问。使用需要 x 和 y 坐标的索引器可以轻松地定位到某个点的像素。例如,下面的代码片段从原始图像获取一个像素,执行一个操作,然后将更改后的像素分配到目标 Surface
中的相同位置。
point = srcArgs.Surface[x, y];
VaryBrightness(ref point, token.Amplitude);
dstArgs.Surface[x, y] = point;
但这还不是全部。应用程序要求我们处理的区域,由第四个对象 roi
表示,可以有任何形状。因此,我们需要调用一个方法,如 GetRegionScansReadOnlyInt()
,来获取近似绘制区域的矩形集合。此外,我们应该从顶部开始逐行处理图像。这些规则导致了如下模式:
public void Render(EffectConfigToken properties, RenderArgs dstArgs,
RenderArgs srcArgs, PdnRegion roi)
{
/* Loop through all the rectangles that approximate the region */
foreach (Rectangle rect in roi.GetRegionScansReadOnlyInt())
{
for (int y = rect.Top; y < rect.Bottom; y++)
{
/* Do something to process every line in the current rectangle */
for (int x = rect.Left; x < rect.Right; x++)
{
/* Do something to process every point in the current line */
}
}
}
}
最后一个值得一提的有趣事实是,Surface
类通常使用 32 位格式,带有四个通道(红、绿、蓝和 alpha),每个通道 8 位,每个像素由一个 ColorBgra
对象表示。请记住,ColorBgra
实际上是一个 struct
,所以为了按引用传递该类型的对象,您必须使用 ref
关键字。此外,该 struct
允许通过公共字段访问每个通道。
private void VaryBrightness(ref ColorBgra c, double amplitude)
{
short newOffset = (short)(random.NextDouble() * 127.0 * amplitude);
if (random.NextDouble() > 0.5)
newOffset *= -1;
if (c.R + newOffset < byte.MinValue)
c.R = byte.MinValue;
else if (c.R + newOffset > byte.MaxValue)
c.R = byte.MaxValue;
else
c.R = (byte)(c.R + newOffset);
if (c.G + newOffset < byte.MinValue)
c.G = byte.MinValue;
else if (c.G + newOffset > byte.MaxValue)
c.G = byte.MaxValue;
else
c.G = (byte)(c.G + newOffset);
if (c.B + newOffset < byte.MinValue)
c.B = byte.MinValue;
else if (c.B + newOffset > byte.MaxValue)
c.B = byte.MaxValue;
else
c.B = (byte)(c.B + newOffset);
}
特效属性
现在我们的特效已经上线并正常运行了。我们还需要做其他事情吗?嗯,在这种情况下,一切都很好。但是,由于每个特效都不同,您可能希望应用 PaintDotNet.Effects
命名空间中提供的三个属性之一。首先是 EffectCategoryAttribute
属性,它用于告知 Paint.NET 该特效是特效还是调整。这两者之间的区别在于,特效旨在对图像进行实质性更改,并在“特效”菜单中列出;而调整只对图像进行小的更正,并在“图层”菜单下的“调整”子菜单中列出。只需查看 Paint.NET 中集成的特效和调整,就能对如何对某个插件进行分类有所了解。EffectCategoryAttribute
通过使用传递给属性构造函数的 EffectCategory
值来明确设置特效的类别。默认情况下,任何没有 EffectCategoryAttribute
的特效插件都被视为特效(因此出现在“特效”菜单中),这等同于应用如下属性:
[EffectCategoryAttribute(EffectCategory.Effect)]
当然,EffectCategory
枚举有两个值,第二个值 EffectCategory.Adjustment
用于将特效分类为调整,以便它出现在 Paint.NET 的“调整”子菜单中。
[EffectCategoryAttribute(EffectCategory.Adjustment)]
除了能够对特效进行分类之外,您还可以通过应用 EffectSubMenu
属性来定义自己的子菜单。想象一下,您创建了十个超级酷的效果,现在想在 Paint.NET 的“特效”菜单中将它们分组,以显示它们构成了一个工具箱。现在,为了将所有这些插件放入“特效”菜单下的“我的超级酷工具箱”子菜单中,您所要做的就是在您的工具箱的每个插件上应用 EffectSubMenu
属性。当然,这也可以用于调整插件,以在“调整”子菜单中创建子菜单。但是,有一个重要的限制:由于 Paint.NET 管理特效的方式,特效名称必须是唯一的。这意味着您不能有一个名为 Foo 的特效直接出现在“特效”菜单中,第二个名为 Foo 的特效出现在“我的超级酷工具箱”子菜单中。如果您尝试这样做,Paint.NET 将只调用这两个特效中的一个,无论您使用的是“特效”菜单中的命令还是子菜单中的命令。
[EffectSubMenu("My Ultra-Cool Toolbox")]
最后但同样重要的是 SingleThreadedEffect
属性。现在,让我们先谈谈多线程。通常,Paint.NET 是一个多线程应用程序。这意味着,例如,当它需要渲染一个特效时,它会利用工作线程来执行实际的渲染。这确保了 UI 保持响应,并且如果渲染是由至少两个线程完成的,并且 Paint.NET 运行在多核 CPU 或多处理器系统上,它还可以显著减少渲染时间。默认情况下,Paint.NET 将使用与系统中逻辑处理器数量相同数量的线程来渲染特效,最少线程数为两个。
处理器(s) |
物理 CPU |
逻辑 CPU |
线程 |
Intel Pentium III |
1 |
1 |
2 |
带超线程的 Intel Pentium 4 |
1 |
2 |
2 |
不带超线程的双 Intel Xeon |
2 |
2 |
2 |
带超线程的双 Intel Xeon |
2 |
4 |
4 |
但是,如果应用了 SingleThreadedEffect
属性,Paint.NET 将只使用一个线程,而不管逻辑处理器的数量。如果渲染是由多个线程完成的,您必须确保 Render()
方法中任何对象的用法都是线程安全的。特效配置令牌通常不是问题(只要您不更改其值,这通常也不推荐),因为渲染线程会获取 UI 使用的令牌实例的副本。Paint.NET 自带的 PdnRegion
类也是线程安全的,因此您不必担心这些对象。然而,GDI+ 对象,如 RenderArgs.Graphics
或 RenderArgs.Bitmap
,不是线程安全的,因此每当您想使用这些对象来渲染您的特效时,都必须应用 SingleThreadedEffect
属性。如果您不确定您的实现是否真正线程安全,或者您只是不想纠结于多线程,您也可以应用该属性。尽管这样做会在多处理器系统和多核 CPU 上降低性能,但至少您会处于字面意义上的安全一边。
[SingleThreadedEffect]
结论
创建 Paint.NET 的特效插件毕竟并不难。您需要使用的对象模型部分并不复杂(尝试一下是一个绝佳的周末活动),而且它看起来相当健壮。当然,本文并未涵盖关于 Paint.NET 特效插件的所有知识,但它应该足以让您创建第一个自己的插件。
致谢
我衷心感谢 Rick Brewster 和 Craig Taylor 提供的反馈和校对本文。
更改历史
- 2005-05-08:添加了关于快捷键仅适用于调整的说明以及关于属性的章节。
- 2005-01-06:修正了
NoiseEffectConfigDialog.InitDialogFromToken(EffectConfigToken)
中的一个重大错误。旧实现使用了EffectConfigDialog.EffectToken
属性而不是effectToken
参数。 - 2005-01-03:初始发布。