Vash - 一个 GDI+ Flash 式矢量动画程序





5.00/5 (28投票s)
有些人认为我疯了,而这可能就是证据。
下载 Vash0.9.zip - 源代码和二进制文件 - 不包含 FFMPEG!
点击此处观看Catch,这是我用 Vash 在 YouTube 上创建的示例视频
引言
Vash 是一个使用 VB.NET 和 GDI+ 编写的类似 Adobe Flash 的应用程序,它允许您创建矢量图形并为其制作动画。它还具备声音播放、光栅图像倾斜、SVG 导出和 AVI 导出(当您单独下载 ffmpeg 时)等功能。
背景
我当时正想着为 YouTube 创建一个带有简单矢量动画的短视频。多年前我曾使用过 Flash,我沮丧地发现 Flash 已不复存在。我下载了几款现有的免费矢量动画程序,但却弄不明白它们是如何工作的。沮丧之余,我问自己“编程化地在关键帧之间对对象点进行补间动画有多难?”两天之内,我就有了一个可用的算法原型。然后我开始了一个新项目,看看我能把它做得多远。经过数千行代码和 80 多个 .vb 文件,Vash 就是这项工作的成果。我在工作之余和忙碌的个人生活之间花了四周时间编写了它,大部分时间花在了调试和添加一些小功能上,一旦主引擎完成。然后我花了几个星期写这篇文章并调整了代码。
这个名字有点像是“Vector”(矢量)和“Flash”的混成词。或者你可以说我是 Trigun 的粉丝。或者你可以说我喜欢牛(法语中的“牛”是vache,发音为“vash”)。无论哪种说法都能尽量避免法律纠纷。
我用 VB.Net 编写 Vash,因为这是我最熟悉的语言。对于你们这些 C# 的爱好者来说,很容易将其移植到 C#。
尽管我希望一切都自己从头开始,但有些事情我就是不理解,或者使用预先编写的库会更简单。因此 Vash 使用了以下库:
- Clipper - 用于处理多边形操作,如联合。唯一的缺点是它要求所有矢量对象都转换为多边形才能工作(在此过程中丢失了贝塞尔曲线)。
- NAudio - 用于播放音频文件,并将所有音频混合以生成单个 .wav 文件以导出为视频。NAudio 可通过 NuGet 获取。
- ffmpeg - 用于创建单个场景的 .avi 文件。我意识到有几个 .Net 包装器和 ffmpeg 的实现,但对我来说,最简单的方法是直接使用原始二进制文件并通过导出窗口的
Process.Start
来调用。重要! 如果您下载 Vash,您必须单独下载 ffmpeg,并将 ffmpeg.exe 放在 Vash.exe 的同一文件夹中,以便导出 avi 有效!
工作原理
Vash 后面的代码由一堆小型类组成,它们相互构建,最终形成一个庞大而复杂的系统。我已经尽力对我的代码进行了详尽的注释,但我将重点介绍那些(对我而言)重要的部分,这些部分应该能让您对它的工作原理有所了解。如果您需要任何方面的澄清,请随时提问。
我的代码结构
当您深入我的代码以弄清楚它是如何工作的时,以下是一些提示,希望能帮助您:
- 我喜欢在所有地方使用区域(尽管 Visual Studio 本身就具备代码折叠功能),所以我的代码里到处都是区域。如果您讨厌区域,我提前向您道歉。
- 在类中,我将所有事件声明和私有成员放在类的顶部,然后是属性,然后是构造函数/析构函数(如果存在),然后按字母顺序排列所有方法。
- 我尽我所能为所有变量和方法取有意义的名字,并尽我所能地注释我的代码。对于颜色选择器和光栅图像倾斜等功能,我真的不知道我在做什么,所以注释不多。
- Wherever possible I tried to cite sources for code that I, er, "borrowed".
为了让您知道如何阅读它,让我们开始深入了解它是如何构建的。
属性,它们在变化!
任何编辑器风格的应用程序要知道何时更新和重绘,就必须知道其设计时对象的属性何时发生变化。在研究如何做到这一点最好的方法时,我发现了 INotifyPropertyChanged
接口,它为 .Net Framework 中触发属性更改事件提供了一个标准框架。所以作为开始,我创建了一个名为 PropertyChanger
的抽象基类。
Public MustInherit Class PropertyChanger
Implements INotifyPropertyChanged
' Declare the event
Public Event PropertyChanged As PropertyChangedEventHandler Implements INotifyPropertyChanged.PropertyChanged
' Create the OnPropertyChanged method to raise the event
Protected Friend Sub OnPropertyChanged(ByVal name As String)
RaiseEvent PropertyChanged(Me, New PropertyChangedEventArgs(name))
End Sub
'Handles bubbling up from child classes
Protected Friend Sub OnPropertyChanged(sender As PropertyChanger, e As PropertyChangedEventArgs)
RaiseEvent PropertyChanged(sender, e)
End Sub
End Class
在派生类中,您将像这样使用 OnPropertyChanged
方法:
Private MyName As String
Public Property Name As String
Get
Return MyName
End Get
Set(value As String)
Dim changed As Boolean = MyName <> value.Trim()
MyName = value.Trim()
If changed Then OnPropertyChanged("Name")
End Set
End Property
我在网上找到的示例只包含 OnPropertyChanged(ByVal name As String)
方法,我添加了重载方法,以便事件能够通过 Vash DOM 冒泡(稍后会详细介绍)。
Vash 应用程序中的几乎每个类都是 PropertyChanger
的祖先。它非常有用,我肯定会在未来的 Windows 项目中使用这个模型。
Vash DOM
对于像动画构建器这样的复杂事物,我们需要一个好的文档对象模型 (DOM) 来轻松地构建、遍历、存储和检索我们动画的各个方面。最好的方法是创建一个所有 DOM 对象都将继承的基类,我称之为 VashObject
。
VashObject
继承自 PropertyChanger
并实现了 ICloneable
(稍后会详细介绍)。它维护了结构的父子关系,并便于将结构序列化和反序列化为 XML。
有很多类继承自 VashObject
,但大多数时候我们会处理抽象基类。因此,您可以想象我们会调用大量的 CType()
并使用 GetType()
和 IsSubClassOf()
来测试类型相等性。我发现反复编写这些代码很麻烦,所以 VashObject
提供了一些泛型函数来加快过程,并使代码更易于阅读。
''' <summary>
''' Returns this instance CType'd to the given type
''' </summary>
''' <typeparam name="T">The type (derived from VashObject) to cast it to</typeparam>
''' <returns></returns>
''' <remarks></remarks>
Public Function [As](Of T As VashObject)() As T
Return CType(Me, T)
End Function
和
''' <summary>
''' Returns True if this instance is of type T (or is class derived from T).
''' </summary>
''' <typeparam name="T">The type (derived from VashObject) to test for</typeparam>
''' <returns></returns>
''' <remarks></remarks>
Public Function [Is](Of T As VashObject)() As Boolean
Return Me.GetType() Is GetType(T) OrElse Me.GetType().IsSubclassOf(GetType(T))
End Function
这两个函数允许我编写通常会像这样写的代码:
Dim vo As VashObject = SomeMethodThatReturnsADOMItem()
If vo.GetType() Is GetType(VectorObject) OrElse vo.GetType().IsSubClassOf(GetType(VectorObject)) Then
CType(vo, VectorObject).SomeVectorObjectMethod()
End If
变成这样:
Dim vo As VashObject = SomeMethodThatReturnsADOMItem()
If vo.Is(Of VectorObject)() Then
vo.As(Of VectorObject)().SomeVectorObjectMethod()
End If
更容易阅读,是吧?[诚然,As
相比 CType
并没有太大的改进,但我想要看起来一致的代码。]
VashObject
还包含处理大部分 XML 序列化/反序列化、处理动画事件以及其他几个支持函数的代码,其中大多数可以被祖先类覆盖以执行它们自己的实现。例如,导致 VashObject
自身绘制的方法是 Render
。在内部,Render
调用 OnBeforeRender
、OnRender
和 OnAfterRender
,以允许派生类执行它们为了渲染而需要的任何工作(并非所有类都会实际渲染任何内容)。
从 VashObject
我创建了三个抽象类,它们将驱动其余的 DOM 类:
- VashLayerBase - 层和层组(文件夹)的基类。它添加了
Locked
和Visible
属性。 - VashMoveable - 所有视觉对象的基础类。它添加了
Opacity
和X
、Y
坐标属性。 - VashTransformable - 可以缩放和/或旋转的视觉对象的基础类,继承自
VashMoveable
。
定义了 VashObject
及其派生的抽象类之后,我就可以创建构成 Vash DOM 的其余类了。这是层次结构:
- Project - DOM 中的根对象。定义场景的舞台大小。还跟踪内部 ID 号计数器。每个 Vash 项目只能有一个 Project 节点。
- Scene - 代表单个动画的容器。一个 Project 中可以有多个 Scenes。
- LayerGroup (Folder) - 其他 LayerGroup 对象和 Layer 的容器。用于在 Scene 中直观地组织您的图层。
- Layer - 类似于 Photoshop 或 Flash 中的图层,每个图层包含关键帧,关键帧又包含场景中的所有视觉对象。
- KeyFrame - 代表图层上对象状态的变化。两个关键帧之间的帧用于在线性插值(LERP,见下文)对象值。
- Group - 就像大多数绘图程序一样,组是任何视觉对象的集合,它们被组合在一起。
- RasterImage - 光栅图像(位图、jpeg、png 等),即非矢量艺术。
- Sound - 一个不可见(播放/导出时)的对象,用于播放声音。
- Subscene - 一个容器,允许您在一个
Scene
中播放另一个Scene
。 - Text - 一个渲染文本的对象。文本对象可以通过右键单击将其转换为
VectorObject
,从而允许您操纵字符的形状。 - VectorObject - 包含定义矢量图形的点的一个对象。
- VectorPoint -
VectorObject
路径的一个点。
- VectorPoint -
- KeyFrame - 代表图层上对象状态的变化。两个关键帧之间的帧用于在线性插值(LERP,见下文)对象值。
- Scene - 代表单个动画的容器。一个 Project 中可以有多个 Scenes。
现在,由于每个类都从 VashObject
获取其子列表,而它们只是一个抽象的 List(Of VashObject)
;软件负责强制执行此结构的表示。
Lerp-da-Derp
Vash 中的所有补间动画都是通过线性插值(或 LERP-ing,如我在玩过的游戏开发环境中称为的)完成的。Lerping 是一个过程,它接受一个起始值、一个结束值、您在两者之间的当前位置(以百分比表示),并计算该位置在两者之间的当前值。对于数值,公式是这样的:
currentValue = start + (end - start) * position
因此,position 为 0% 时,您将获得 start,position 为 100%(1.0)时,您将获得 end。任何其他值都介于两者之间。简单易行。
当涉及到矢量对象的点这样的事物时,实际实现 lerping 并不像动画那样简单。如何跟踪当前(lerped)值而不丢失原始开始或结束位置?我想您可以保留数组来跟踪这些事情,但对我来说,创建一个通用的类来跟踪每个实际可 lerp 的值更有意义。因此,这个恰当命名的Lerpable泛型类应运而生。
Public MustInherit Class Lerpable(Of T)
Inherits PropertyChanger
该类内部有对象的实际值属性,以及“delta”值(即 lerp 操作的结果)。当当前选定场景的帧发生变化时,所有 lerpables 的 delta 值都会被计算。还有一个附加属性 Lerpable
,它是一个 Boolean
值,指示在动画期间是否应该 lerp。
有了这个基类,我创建了三个派生类来处理 Vash 中可以 lerp 的三种值类型:
LerpableSingle
- 处理浮点数(Single)值的插值。LerpableInteger
- 处理整数值的插值。LerpableColor
- 处理VashColor
对象的插值。这涉及到对颜色每个分量(A、R、G、B)进行插值。
关于泛型的说明:当处理实际类型在运行时未知的对象时,泛型类的工作非常困难。与非泛型类型不同,您可以使用抽象基类的变量来执行基类操作,您不能创建 Lerpable(Of T)
的变量并直接调用 Save
或任何其他方法。因此,在将我的脸拍在墙上一两天后,我决定采取胆怯的方式,在每次必须执行此操作时都硬编码这三种情况。
If pi.PropertyType Is GetType(LerpableSingle) Then
Dim ls As LerpableSingle = CType(pi.GetValue(vo), LerpableSingle)
ls.Value = Single.Parse(el.Attribute(XName.Get(pi.Name)).Value)
ls.Lerpable = Boolean.Parse(el.Attribute(XName.Get(pi.Name & ".Lerpable")).Value)
ElseIf pi.PropertyType Is GetType(LerpableInteger) Then
Dim li As LerpableInteger = CType(pi.GetValue(vo), LerpableInteger)
li.Value = Integer.Parse(el.Attribute(XName.Get(pi.Name)).Value)
li.Lerpable = Boolean.Parse(el.Attribute(XName.Get(pi.Name & ".Lerpable")).Value)
ElseIf pi.PropertyType Is GetType(LerpableColor) Then
Dim lc As LerpableColor = CType(pi.GetValue(vo), LerpableColor)
lc.Value = VashColor.Parse(el.Attribute(XName.Get(pi.Name)).Value)
lc.Lerpable = Boolean.Parse(el.Attribute(XName.Get(pi.Name & ".Lerpable")).Value)
End If
立即开始制作动画!
现在我们可以开始处理动画了。在 Vash 中,Scenes 是动画化的。Scenes 包含 Layers,Layers 包含 KeyFrames,KeyFrames 包含实际渲染的视觉对象。在动画方面,KeyFrames 是最重要的对象。
当您首次将一个对象(在我的例子中是一个绿色的圆)添加到图层时,如果没有关键帧,系统会自动创建一个关键帧并将圆添加到其中。
如果我们然后使用时间线单击帧 24,您会看到球仍然在那里。这是因为在渲染时,我们从最接近当前帧的关键帧进行绘制(在本例中,是从第 1 帧绘制关键帧)。按 F5 将在帧 24 创建一个新关键帧,但它不是一个空关键帧:新关键帧将包含前一个关键帧所有子项的精确副本(包括 ID)(通过克隆,因为 VashObject
实现 ICloneable
)。
现在我们处理的是克隆,我们可以在帧 24 随意移动圆,而帧 1 中的圆不会受到影响。
现在,如果我们转到另一个帧(比如第 12 帧),将会发生一些新的事情:在 Scene
类中,当 Frame
属性更改时,场景对象会调用 Animate
方法。
Public Property Frame As Integer
Get
Return MyFrame
End Get
Set(value As Integer)
Dim changed As Boolean = value <> MyFrame
MyFrame = value
If changed Then
'If the frame has changed, let's animate:
Dim ac As New AnimationContext(value)
Designer.Animating = True
Animate(ac)
Designer.Animating = False
OnPropertyChanged("Frame")
End If
End Set
End Property
Animate
方法(在 VashObject
中)会做很多事情,并且是递归的,它会对对象的每个子对象调用自身。因此,当一个场景调用 Animate
时,它会循环遍历其图层并调用 Animate
。图层则会做一些特殊的事情:它们不是直接对所有子对象(它们是关键帧)调用 Animate
,而是找到最接近被动画化帧的关键帧,然后**仅**对该关键帧调用 Animate
。关键帧将自身设置为 AnimationContext
实例中当前作用域的关键帧对象,然后对其子对象调用 Animate
。当我们最终到达具有可 lerp 属性的对象时,事情就变得有趣了。
'Are we in the scope of a Keyframe (i.e. a VashMovable)?
If context.KeyFrame IsNot Nothing Then
Dim doppelganger As VashObject = Nothing
If context.KeyFrame.Next IsNot Nothing Then
'Next find out if this object exists in the next keyframe (i.e. it has a clone with the same id):
doppelganger = context.KeyFrame.Next.GetChildById(Me.Id)
End If
For Each pi As PropertyInfo In Me.GetType().GetProperties()
If pi.PropertyType.Name.StartsWith("Lerpable") Then
'Reset our delta value to our default value:
Dim v = pi.GetValue(Me)
'Are we allowed to lerp this property?
If v.Lerpable Then
'Reset delta to the original value:
v.Reset()
'If we have a clone, lerp this property between its value and its clone's equivalent:
If doppelganger IsNot Nothing Then
v.Lerp(pi.GetValue(doppelganger).Value, context.LerpAmount)
End If
End If
End If
Next
End If
简而言之:每个对象都会在其下一个关键帧中查找其克隆(假设存在)。它会循环遍历其所有可 lerp 属性,如果 Lerpable
成员设置为 True
,它将在其原始值和其克隆上相同属性的值之间 lerp,并将结果存储在 lerpable 的 Delta
属性中(这样我们就不会丢失其原始值)。
然后,在渲染时,对象将使用其属性的 Delta
值而不是实际值进行绘制。
现在,第 12 帧显示圆位于其在第 1 帧的原始位置与其在第 24 帧的克隆的新位置之间的一半。所有动画都是这样完成的,即使是像我的Catch示例那样繁忙的场景,每帧的动画也只需要大约 40-60 毫秒。
渲染
由于 Vash DOM 是一个层次结构,当每个对象被渲染时,我们会应用它的变换(平移、缩放、旋转),然后再渲染它的子对象。为了帮助跟踪所有这些,我创建了一个名为 RenderContext
的类,该类由每个 VashObject
传递,并包含正确渲染场景所需的所有信息。
RenderContext
使用帧号和 System.Drawing.Graphics
对象进行初始化。Graphics
对象可以是 SceneSurface
控件的绘图表面,也可以是用于图像导出的 System.Drawing.Image
对象的包装器,或者任何其他有效的 Graphics
对象,因此它相当灵活。
RenderContext
类有两个属性,在实例通过 DOM 进行渲染事件时会被修改:Effects
和 OpacityStack
。Effects
是所有需要应用的特殊效果的累积列表(请参阅下面的效果部分),而 OpacityStack
是一个 Single
值堆栈,表示穿过树的乘法不透明度(例如,如果我的父级不透明度为 0.5,而我的不透明度为 0.5,那么我的子级应以 0.25 的不透明度渲染)。
除了构造函数,RenderContext
只有一个方法:PushGraphicsState
和 PopGraphicsState
。它们的作用只是分别调用 Graphics.Save
和 Graphics.Restore
,并将 Graphics.Save
返回的 GraphicsState
对象保存在一个堆栈中。
注意:由于我们正在为 Vash DOM 中(可能)的每个节点转换图形状态,因此每个视觉对象都使用 (0, 0) 作为绘图的原点进行绘制。例如,如果一个对象位于 (100, 50),我们首先将图形状态转换为 (100, 50),然后从 (0, 0) 开始绘制。这允许我们在不重新计算每个点位置的情况下移动 VectorObject
实例。
VashObject.Render
方法会调用传递给它的 RenderContext
实例上的那些方法,这些方法会处理所有变换和重置,并允许派生类在过程的每个步骤中执行它们需要的任何功能。
Public Sub Render(rc As RenderContext)
'Here we push our graphics state so that any transformations can be done at this level in the tree and not effect other branches
rc.PushGraphicsState()
Dim effectsStartIndex As Integer = rc.Effects.Count()
'Add my effects to the context from this point:
rc.Effects.AddRange(Me.Effects)
'Let my derived classes do any pre-rendering stuff:
OnBeforeRender(rc)
'Let my effects do any pre-rendering stuff:
For Each e As EffectBase In rc.Effects
e.OnBeforeRender(rc, Me)
Next
'Get my derived classes to actually render themselves:
OnRender(rc)
'Let my effects do any post-rendering stuff:
For Each e As EffectBase In rc.Effects
e.OnAfterRender(rc, Me)
Next
'Let my derived classes do any post-rendering stuff
OnAfterRender(rc)
'Remove my effects from the context:
If Effects.Count > 0 Then
rc.Effects.RemoveRange(effectsStartIndex, Effects.Count - 1)
End If
'Pop that graphics state and return it to the previous!
rc.PopGraphicsState()
End Sub
在所有从 VashObject
派生的类中,只有 RasterImage
、Text
和 VectorObject
实际绘制到 Graphics
对象上。所有其他类仅应用它们的变换(如果存在)并将渲染传递给它们的子对象。三个例外是 Sound
类,它在渲染时使用 NAudio 播放声音;Subscene
,它将渲染传递给另一个 Scene
实例;以及 Group
,如果它是当前的 Designer.SelectedContainer
(参见下面的设计器部分),它会将 OpacityStack
重置为 1.0(这样它的子对象看起来像是“活动的”)。
OnBeforeRender
方法被 VashMoveable
和 VashTransformable
覆盖,如您所见,它们在对象实际绘制自身之前,在 Graphics
对象的变换矩阵中设置当前对象的位置、缩放和旋转。
' In VashMoveable
Protected Overrides Sub OnBeforeRender(rc As RenderContext)
MyBase.OnBeforeRender(rc)
'Restrict our opacity to the range 0.0 to 1.0:
Dim transformedOpacity As Single = Math.Min(1.0, Math.Max(0.0, MyOpacity.Delta))
'If an ancestor had set an opacity, alter ours by theirs:
If rc.OpacityStack.Count > 0 Then
transformedOpacity *= rc.OpacityStack.Peek
End If
'Push our opacity onto the stack:
rc.OpacityStack.Push(transformedOpacity)
'Translate our origion to the position of our object:
rc.Graphics.TranslateTransform(X.Delta, Y.Delta)
End Sub
' In VashTransformable
Protected Overrides Sub OnBeforeRender(rc As RenderContext)
MyBase.OnBeforeRender(rc)
'Translate the graphics object by our scale and rotation values:
rc.Graphics.ScaleTransform(IIf(ScaleX.Delta = 0, 0.000001, ScaleX.Delta), IIf(ScaleY.Delta = 0, 0.000001, ScaleY.Delta))
rc.Graphics.RotateTransform(Rotation.Delta)
End Sub
那么,绘图实际上是如何进行的呢?嗯,当 Vash DOM 中的所有父节点应用它们的变换(所有这些都由 VashObject
、VashMoveable
和 VashTransformable
处理)之后,每个类只需要覆盖 OnRender
并进行绘制。
VectorObject 渲染
VectorObjects
非常简单。它们设置它们的线条和填充颜色(当然,基于 delta 值,因为我们可能在 lerp 它们),根据 RenderContext
实例中 OpacityStack
的当前值调整颜色,然后调用 Graphics.FillPath
和 Graphics.DrawPath
进行渲染。
Protected Overrides Sub OnRender(rc As RenderContext)
If MyPath Is Nothing Then Return
'Adjust the opacity of our colours based on the current opacity stack:
Dim oldLineColorAlpha As Integer = LineColor.Delta.Alpha
Dim oldFillColorAlpha As Integer = FillColor.Delta.Alpha
LineColor.Delta.Alpha = LineColor.Delta.Alpha * rc.OpacityStack.Peek
FillColor.Delta.Alpha = FillColor.Delta.Alpha * rc.OpacityStack.Peek
Dim p As New Pen(LineColor.Delta.Color, LineWidth.Delta)
Dim br As New SolidBrush(FillColor.Delta.Color)
rc.Graphics.FillPath(br, MyPath)
rc.Graphics.DrawPath(p, MyPath)
br.Dispose()
p.Dispose()
LineColor.Delta.Alpha = oldLineColorAlpha
FillColor.Delta.Alpha = oldFillColorAlpha
MyBase.OnRender(rc)
End Sub
文本渲染
您可能会认为渲染文本非常复杂,但幸运的是 GDI+ 能够将字符串添加到 GraphicsPath
对象。每当 Vash Text
对象中的文本、样式或大小发生更改时,就会调用 RecreatePath
方法,该方法会初始化一个内部 GraphicsPath
对象。
''' <summary>
''' Internally creates the path object using the current text and values
''' </summary>
''' <remarks></remarks>
Private Sub RecreatePath()
Dim sf As New StringFormat()
sf.Alignment = StringAlignment.Center
sf.LineAlignment = StringAlignment.Center
If MyPath IsNot Nothing Then MyPath.Dispose()
MyPath = New GraphicsPath()
MyPath.AddString(Text, FontFamily, CInt(Style), Size.Delta, New Point(0, 0), sf)
End Sub
然后,在渲染时,我们只需要几行代码:
Protected Overrides Sub OnRender(rc As RenderContext)
If MyPath Is Nothing Then Return
'Adjust the opacity of our colours based on the current opacity stack:
LineColor.Delta.Alpha = LineColor.Delta.Alpha * rc.OpacityStack.Peek
FillColor.Delta.Alpha = FillColor.Delta.Alpha * rc.OpacityStack.Peek
Dim p As New Pen(LineColor.Delta.Color, LineWidth.Delta)
Dim br As New SolidBrush(FillColor.Delta.Color)
rc.Graphics.FillPath(br, MyPath)
rc.Graphics.DrawPath(p, MyPath)
br.Dispose()
p.Dispose()
End Sub
RasterImage 渲染
GDI+ 包含许多用于快速绘制光栅图像的便捷方法,甚至可以处理缩放。因此,我本可以使用 Graphics.DrawImage
(或类似的函数)来完成工作。但我很疯狂,所以我自己弄清楚了如何允许您移动图像的四个锚点之一并相应地倾斜图像。例如,假设我想要这样的效果:
不幸的是,GDI+ 没有内置的功能来处理这种图像操作。那么我们如何倾斜光栅图像呢?好吧,在 阅读了大量关于 GDI+ 中的高级图像绘制技术 的 在线资料后,我大致了解了如何解决这个问题。我基本上必须编写一个纹理映射算法。
我不知道我实现的这个方法是否是专业人士真正使用的,但这是我使用的方法:
- 获取锚点的四个坐标。称它们为 A、B、C、D。
- 计算线段 AB、AC、CD 和 BD 的长度。
- 创建一个新点 E,初始化为 A,创建一个点 F,初始化为 B。
- 对于 EF 线段上的每个点,确定您相对于 EF 长度的百分比。称之为u。
- 使用u来计算 AB 上的相应位置(例如,如果您在 EF 上走了 50%,则获取 AB 上 50% 的坐标),称之为 G。对 CD 上的相应位置执行相同的操作,称之为 H。确定 GH 的长度,以及您当前坐标在该长度上的百分比。称之为v。因此 (u, v) 是 EF 和 GH 的交点。
- 从原始 Image 对象中,获取坐标为
(Width * u, Height * v)
的像素。将目标位图的像素设置为此值,并根据该对象的当前不透明度值调整 alpha 值。 - 由于此过程中的浮点舍入,最终图像可能会出现间隙。我通过添加一个简单的缝合启发式方法解决了这个问题:如果我们的当前坐标不是右边缘,则用相同的值填充右侧像素。它并不完美,但它填补了间隙。
- 使用 AC 和 BD 的斜率(rise/run)值,在左侧和右侧边缘(分别)向下移动 E 和 F,并重复此过程,直到 E 到达 C,F 到达 D。
速度是完成此类工作的关键,因此我使用了 LockBits
和 System.Runtime.InteropServices.Marshal.Copy
来将位图数据复制到字节数组(每个 A、R、G 和 B 值一个字节),而不是使用非常昂贵的 GetPixel
和 SetPixel
。然后,我可以使用快速内存操作将像素从源图像复制到目标图像中的适当位置,并根据对象当前的不透明度值调整 alpha 值。
由于我的代码是实验性的,它不如它本可以那么高效(也不像它本可以那么注释好)。例如,内存中的图像在每次调用 OnRender
时都会被绘制。实际上,它应该被缓冲,并且只有在对象的透明度值或四个锚点之一发生更改时才会被重新绘制。
声音渲染
如上所述,Sound
类在 OnRender
中执行一些略有不同的操作。它实际上播放声音,而不是绘制!它通过 NAudio 库来实现,这是一个非常复杂的用于处理音频的 .Net 库。我并不理解它的大部分内容,但我通过摸索,足以发出声音。乐趣的一部分是知道如果场景从音频所属的关键帧之后的帧开始播放,应该在何时开始播放音频。
If WaveOut.PlaybackState <> PlaybackState.Playing AndAlso Action = SoundAction.Play Then
'Advance to the current time index of this frame:
AudioReader.CurrentTime = TimeSpan.FromSeconds((rc.Frame - Me.GetAncestor(Of Layer)().GetKeyFrame(rc.Frame).Frame) / Designer.Project.FramesPerSecond)
WaveOut.Play()
ElseIf WaveOut.PlaybackState = PlaybackState.Playing AndAlso Action = SoundAction.Stop Then
WaveOut.Stop()
End If
我发现的另一个巧妙之处是,如果您处于设计模式,您不希望每次单击一个帧时声音都播放完整(尤其是对于 4 分钟的 mp3),而只需要播放那个瞬间的声音,持续一帧所占的那个瞬间。
'Advance to the current index of the frame:
AudioReader.CurrentTime = TimeSpan.FromSeconds((rc.Frame - Me.GetAncestor(Of Layer)().GetKeyFrame(rc.Frame).Frame) / Designer.Project.FramesPerSecond)
Try
WaveOut.Play()
'We're in design mode so just play one frame's worth of sound:
Threading.Thread.Sleep(1000 / Designer.Project.FramesPerSecond)
WaveOut.Stop()
Catch ex As Exception
WaveOutBuffers.Remove(Me.Id)
End Try
您可以将 Vash 用于 lerp Sound
类的 Volume
属性,但我无法弄清楚在导出为视频时混合音频时如何实现可变音量,因此只有初始音量级别会被导出。我猜我需要研究 NAudio 的交叉淡入淡出混合或其他东西。也许有 NAudio 专家能告诉我,因为它真的超出了我的能力范围。
注意:我撒了个小谎。当设计器没有积极播放场景时,Sound
会在其坐标处绘制一个音频图标,以便在设计时给用户一些视觉上可见和可点击的东西。
效果
Vash 的灵活方面之一是能够在层次结构的任何级别添加不同类型的效果。效果派生自
EffectBase
(它又派生自 VashObject
),但它们的处理方式略有不同,并且存储在每个 VashObject
的单独集合中(而不是 Children
集合)。我处理它们的方式不同是因为效果在它们的 DOM 分支中是累积的;即,分配给对象的某个效果将通过其子对象向下传播。这使得您可以采用“投影阴影”这样的效果并将其添加到图层,从而在整个动画过程中使该图层中的每个视觉对象都出现投影阴影。
到目前为止,我只创建了两种效果:
- 投影阴影 - 一个效果,用于渲染应用于它的每个对象的阴影。阴影颜色和偏移属性是可 lerp 的。
- 点摆动 - 一个效果,每帧都会使
VectorPoint
对象的位置振荡,从而使对象呈现波浪状外观(在我Catch示例中父亲和儿子的身体和头部就是如此)。
效果值是可 lerp 的,因此您可以将投影阴影的偏移量在几个帧之间 lerp,使其看起来像是光源在移动(从而导致阴影移动)。
Vash 设计器
所有这些动画和渲染功能都很好,但如果没有视觉操纵能力,这个项目会相当无趣。所以,这是 Vash 的设计器部分的一些有趣功能的概述。
设计器
Vash 中有一个恰如其分的类叫做 Designer
。它是一个单例类,是所有控件和 Vash DOM 之间通信的交换台。
Public Class Designer
Inherits PropertyChanger
...
Private Shared MySingleton As Designer = Nothing
Public Shared ReadOnly Property Singleton As Designer
Get
If MySingleton Is Nothing Then
MySingleton = New Designer()
'MySingleton.GenerateTestProject()
End If
Return MySingleton
End Get
End Property
...
' Prevent anything from creating an instance of this class:
Private Sub New()
End Sub
...
' Bubble any events from the currently-loaded project up through the singleton:
Private Sub MyProject_PropertyChanged(sender As Object, e As System.ComponentModel.PropertyChangedEventArgs) Handles MyProject.PropertyChanged
If sender.GetType() Is GetType(Scene) AndAlso e.PropertyName = "Frame" AndAlso sender Is SelectedScene AndAlso SelectedLayer IsNot Nothing AndAlso SelectedLayer.Is(Of Layer)() Then
SelectedKeyFrame = SelectedLayer.As(Of Layer)().GetKeyFrame(SelectedScene.Frame)
End If
OnPropertyChanged(sender, e)
End Sub
End Class
Designer
类关键属性如下:
- Exporting - 确定 Vash 是否正在导出当前场景(到 png 或 avi)。DOM 对象和控件根据此值改变行为。
- FillColor - 应用于新对象的当前填充颜色。
- LineColor - 应用于新对象的当前线条颜色。
- LineWidth - 应用于新对象的当前线宽。
- Playing - 确定 Vash 是否正在设计器中播放当前场景。DOM 对象和控件根据此值改变行为。
- PointEditMode - 切换设计器是否允许您操纵矢量对象的各个点,而不是它们的缩放/旋转。
- Project - 编辑器中当前加载的项目。
- SelectedContainer - 设计器当前正在添加对象的容器。通常是
Keyframe
,如果您双击一个Group
,它将成为选定的容器,并用不透明表面覆盖所有其他内容。 - SelectedLayer -
Timeline
控件中当前选定的图层(见下文)。 - SelectedObjects - 设计器中当前选定的对象集合。
- SelectedObject -
SelectedObjects
中第一个(或唯一一个)选定的对象。如果没有对象被选中,则返回Nothing
。 - SelectedScene - 设计器中当前选定的场景。
- SelectedTool - 设计器中当前选定的工具。
由于 Designer
继承自 PropertyChanger
,因此如果上述任何属性发生更改,都会触发 OnPropertyChanged
事件。它还绑定到当前项目的 OnPropertyChanged
事件,并通过自身进行冒泡。由于 Designer
是一个单例类,所有控件只需要绑定到该事件即可知道设计器或 Vash DOM 中的任何属性何时发生更改。
Designer
还包含撤销/重做列表(有关详细信息,请参阅下文)。
MainWindow
MainWindow
是我为我编写的每个 Windows 应用程序的主窗体类名(我认为这是 90 年代我自学 C++ 时期的 Borland Visual C++ 的习惯)。它包含用于操纵 Vash DOM 的所有控件,并且是迄今为止最繁忙的类,尽管其大部分代码都是菜单和工具栏项的事件处理程序。这里有很多内容,所以我将重点介绍其中一些(对我而言)最重要的方面:
DOM 树控件
左上角的这个 TreeView
控件显示了您在 DOM 中的当前位置到项目本身的路径。我曾考虑过将整个树加载到这里,但所需的持续更新使其有点混乱和难以理解。
单击 DOM 树中的节点将使该节点成为设计器中当前选定的对象,这意味着效果列表(见下文)和属性网格将显示与选定节点相关的项。
节点显示为其名称(如果 Name
属性为空,则显示类名)以及方括号中的内部 ID。ID 现在有点无用,但对我调试应用程序很有用,尤其是在处理关键帧之间的克隆时。
效果列表
效果列表(位于 DOM 树正下方)显示应用于当前选定对象的效果。值得注意的是,在动画/渲染时,效果会累积地应用于树的节点。因此,如果一个图层应用了“投影阴影”效果,那么该图层上的每个 VectorObject
都将具有投影阴影。
场景选择器
隐藏在 SceneSurface
和 Timeline
控件之间的是一个带有场景选择器的工具栏容器。场景选择器是一个下拉控件,其项目是项目中的当前场景。右侧的图标将向项目中添加一个新场景。当您更改所选场景时,您将更改设计器中正在编辑/导出/播放的场景。
您还可以通过菜单栏下的“场景”菜单来创建(或删除)场景。
SceneSurface 控件
俗话说“外观决定一切”,所以我们在屏幕上渲染动画时需要一个相当强大的控件。由于几乎所有的渲染逻辑都由 Vash DOM 处理,我们实际上要做的就是跟踪一些有用的属性,并在适当的时候告诉 DOM 在我们的控件上绘图。
SceneSurface
控件只有三个属性:
- AutoFit - 一个
Boolean
值,当为 true 时,设置缩放属性以确保整个舞台适合控件,无论尺寸如何。 - PanOffset - 用户平移表面时,距中心的距离(当缩放大于控件本身时)。
- Zoom - 渲染场景时的缩放量。当
Autofit
为True
时,此值会自动确定,以提供控件尺寸的最佳匹配。
当这个项目刚开始时,SceneSurface
除了将鼠标事件传递给当前选定的工具(Designer.SelectedTool
)之外,不做任何事情。然后我弄明白了如何制作顶层表面的尺寸和旋转手柄以及选择框,并且我需要更改它来处理与手柄的交互。如果鼠标不在手柄上,则事件会传递给工具。
SceneSurface
是为了渲染场景而构建的,并且为了无闪烁地渲染,我们在 SceneSurface
的构造函数中为控件设置了 OptimizedDoubleBuffer
样式。
SetStyle(ControlStyles.AllPaintingInWmPaint, True)
SetStyle(ControlStyles.OptimizedDoubleBuffer, True)
SetStyle(ControlStyles.UserPaint, True)
SetStyle(ControlStyles.ResizeRedraw, True)
SetStyle(ControlStyles.UserMouse, True)
SceneSurface
还公开了两个公共方法:OnBeforeRender
和 OnAfterRender
,它们与 VashObject
一样,在渲染前执行任何图形对象的变换。
Timeline 控件
将视觉对象添加到当前关键帧很棒,但如果您无法更改您正在查看的帧,那将是无用的。我们还需要一种方法来查看、组织和选择场景中的图层。这使得 Timeline
控件成为整个项目中最为复杂的自定义控件。与上面的 SceneSurface
控件一样,Timeline
的 OptimizedDoubleBuffer
样式设置为 True
,从而实现无闪烁渲染。
Timeline
控件是我们可视化查看所选场景中图层的地方,它可能有点复杂,因为图层可能是嵌套的(文件夹内的图层,文件夹内的文件夹),需要缩进,并且文件夹可以折叠或展开。每次我们绘制控件时,我们可以递归地遍历图层并绘制它们,这也是我最初做的,但很快就变得混乱了,当我编写命中测试算法(如下)时,我意识到我必须再次递归遍历整个集合。更不用说我希望用户能够单击圆圈图标来切换可见性,单击锁定图标来切换锁定状态,并且这使得确定这些位置变得几乎不可能。
我最终决定创建一个名为 LayerListItem
的新类,其中包含对实际图层的引用,以及图层标题的每个交互部分的边界(即,一个用于绿色圆圈、锁定图标、图标和文本的矩形)。然后,我递归地遍历图层,只添加父项未折叠的项,并将层次结构展平到列表中。这使得绘图变得更加容易(我只需要迭代列表),并且使命中测试变得更加容易(同样,只需迭代列表,无需递归)。唯一的缺点是,每次 LayerGroup
上的 Expanded
属性发生更改,或者图层的排序发生更改,或者添加或删除了图层时,列表都必须重建,但由于每个场景中的图层数量相对较少,因此根本不明显。
来打我吧,宝贝,再来一次
我试图让一些非程序员理解的关于现代计算机的最困难的事情之一是,他们在屏幕上看到的物体不是真实的;屏幕上没有实际的按钮,或者他们的文字处理器中的文字等等,而是他们看到的一切都是“绘制”出来的,复杂的算法决定了你实际点击了什么,或者你将鼠标悬停在哪里。即使是 Windows Forms 程序员也几乎意识不到这一点,因为他们依赖文本框和网格视图来执行各种自动魔术。
但是,任何编写过自定义控件并窥见过其内部运作的人,都会赞赏编写自己的命中测试算法的乐趣和奇妙。以上面的 Timeline
控件为例:有图层、文件夹、图标、控制可见性和锁定的图标、帧号、关键帧指示器和两个滚动条。它看起来很棒,但实际上操作系统(甚至 .Net Framework)对此一无所知。我只得到一个可以绘制的矩形区域,并接收鼠标和键盘事件。所以,我绘制了时间线的每一部分,然后当鼠标移动或被点击时,我必须弄清楚我实际上正在与时间线的哪个“部分”进行交互。
对于从未写过命中测试算法的人来说,命中测试算法接受一个 x, y 坐标,并根据控件的当前状态返回该点所在的控件部分。听起来很简单,但请记住,.Net 对时间线的视觉表示一无所知,所以我们必须自己找出答案。
我们需要的第一件事是区分控件区域的列表。就我们而言,一个 Enum
就足够了:
Public Enum TimelineArea
BlankSpace
HorizontalScroll
VerticalScroll
LayerName
LockToggle
LayerIcon
LayerHeader
GridHeader
Grid
VisibleToggle
End Enum
希望这些值都很容易理解,除了 BlankSpace
;这只是一个通用的术语,用于表示控件中没有交互式元素的任何区域(例如,时间线的左上角)。
现在我们有了报告一个点属于哪种区域的方法,但还有更多需要返回的内容。我们可能单击了一个 GridHeader
(即控件顶部显示帧号的区域),但我们单击的是哪一帧?如果我们单击一个图层标题或图标,它是哪个图层?仅靠区域是不够的。我们的命中测试算法必须返回更复杂的内容。这时我们创建了另一个类,TimelineHitTestResults
。
Public Class TimelineHitTestResults
Private MyArea As TimelineArea = TimelineArea.BlankSpace
Public ReadOnly Property Area As TimelineArea
Get
Return MyArea
End Get
End Property
Private MyFrame As Integer = 0
Public ReadOnly Property Frame As Integer
Get
Return MyFrame
End Get
End Property
Private MyLayer As VashLayerBase = Nothing
Public ReadOnly Property Layer As VashLayerBase
Get
Return MyLayer
End Get
End Property
Private MyListItem As LayerListItem = Nothing
Public ReadOnly Property ListItem As LayerListItem
Get
Return MyListItem
End Get
End Property
Protected Friend Sub New(frame As Integer, listItem As LayerListItem, layer As VashLayerBase, area As TimelineArea)
MyFrame = frame
MyListItem = listItem
MyLayer = layer
MyArea = area
End Sub
End Class
这个类除了公开构造函数参数的只读值之外,什么也不做,但这没关系,因为所有确定我们在哪里的工作都由相应命名的 HitTest
方法处理。
Public Function HitTest(x As Integer, y As Integer) As TimelineHitTestResults
正如我上面所说,命中测试算法以坐标作为参数,所以这个方法声明应该不会令人惊讶。现在是乐趣所在。在时间线控件的顶层(z 索引层)是两个“滚动条”。它们不是 Windows Forms 滚动条,而只是我绘制到控件上的矩形,当内容大于控件的客户区时。所以我们首先查看我们的滚动条是否可见,如果可见,则查看传入的坐标是否在其中一个矩形内。如果坐标在其中一个矩形内,我们就返回命中测试结果,说明我们位于哪个滚动条上(并且既然我们在滚动条上,其他参数都不重要)。
If HScrollVisible AndAlso HScrollRect.Contains(x, y) Then Return New TimelineHitTestResults(0, Nothing, Nothing, TimelineArea.HorizontalScroll) End If If VScrollVisible AndAlso VScrollRect.Contains(x, y) Then Return New TimelineHitTestResults(0, Nothing, Nothing, TimelineArea.VerticalScroll) End If
如果我们不在滚动条上,我们还有更多工作要做。我们的 x 和 y 值是相对于控件顶部的控件坐标,但如果用户垂直滚动了内容,我们需要调整我们的 y 值以反映这一点。更复杂的是,网格标题的位置不会因为您的垂直滚动而改变(用 Excel 的话说就是“冻结”的),所以我们应该只根据滚动位置调整我们的 y 坐标,前提是它低于网格标题。
Dim originalY As Integer = y
If VScrollVisible Then
y += (VScrollRect.Top - MyLayerHeaderHeight) * MyLayerHeaderHeight
End If
注意:我不以类似方式调整 x 坐标的原因是,Timeline
有一个单独的内部成员 FrameStart
,它决定了在从左到右渲染网格时我们从哪一帧开始,并受水平滚动控制,所以它完成了这种调整应有的工作。
如果我们不在滚动条上,我们将返回 TimelineHitTestResults
其他成员更有意义的值。所以我们先创建一些变量来保存默认值。
Dim frame As Integer = 0
Dim layer As VashLayerBase = Nothing
Dim area As TimelineArea = TimelineArea.BlankSpace
Dim layerIndex As Integer = (y - MyLayerHeaderHeight) \ MyLayerHeaderHeight
Dim listItem As LayerListItem = Nothing
然后我们检查我们的 x 和 y 坐标相对于标题,并确定我们覆盖了控件的哪个特定部分。
'Are we on the left side of the control (where the layer headers are)?
If x < MyLayerHeaderWidth Then
area = TimelineArea.LayerHeader
'Are we actually in the space where the layers are?
If layerIndex >= 0 AndAlso layerIndex < MyLayerList.Count() Then
listItem = MyLayerList(layerIndex)
layer = listItem.Layer
If MyLayerList(layerIndex).VisibleBounds.Contains(x, y) Then area = TimelineArea.VisibleToggle
If MyLayerList(layerIndex).LockBounds.Contains(x, y) Then area = TimelineArea.LockToggle
If MyLayerList(layerIndex).IconBounds.Contains(x, y) Then area = TimelineArea.LayerIcon
If MyLayerList(layerIndex).TextBounds.Contains(x, y) Then area = TimelineArea.LayerName
End If
ElseIf originalY < MyLayerHeaderHeight Then
area = TimelineArea.GridHeader
'What frame are we clicking on?
frame = ((x - MyLayerHeaderWidth) \ MyFrameWidth) + FrameStart
Else
area = TimelineArea.Grid
'What frame are we clicking on?
frame = ((x - MyLayerHeaderWidth) \ MyFrameWidth) + FrameStart
'Are we actually in the space where the layers are?
If layerIndex >= 0 AndAlso layerIndex < MyLayerList.Count() Then
listItem = MyLayerList(layerIndex)
layer = listItem.Layer
End If
End If
最后,我们要做的就是返回一个包含我们已设置的所有值的 TimelineHitTestResults
新实例。
Return New TimelineHitTestResults(frame, listItem, layer, area)
End Function
这就是命中测试的概览。那么我们如何处理这些结果呢?所有事情!时间线控件的鼠标事件都调用 HitTest
来确定光标在时间线控件中的位置,行为根据您单击的区域而变化。显然,在滚动条内单击并拖动会导致滚动条跟随鼠标移动。单击并拖动关键帧允许您将其移动到其前一个和下一个同级元素之间的任何位置。拖动图层可以让您重新排序它们,或将它们移入或移出文件夹。单击切换开关会翻转它们的值,双击文件夹图标会切换 Expanded
属性,双击图层名称会使图层开始内联重命名过程。
名字的含义?
因为您可以从 DOM 树中选择图层,所以您可以通过 PropertyGrid
控件更改其 Name
属性。然而,大多数用户会期望能够双击文本,或选择图层并按 F2,然后在控件上直接输入图层的新名称。并且在使用 Vash 创建我的演示视频 Catch 时,我意识到这是一个有用的功能,并添加了它。现在您可能会说:“克莱顿,你肯定没有在 Timeline 中为图层重命名编写文本编辑器控件的逻辑吧?”,您就说对了。对于像 Timeline
这样复杂的控件,利用现有的东西是有意义的。所以我使用一个文本框。
Private WithEvents Renamer As New TextBox 'The textbox used when renaming layers
Renamer
是 Timeline
控件内部一个隐藏的 TextBox 控件。为了不那么显眼,我们在 Timeline
构造函数中调整了它的默认外观。
Renamer.Visible = False
Renamer.BorderStyle = BorderStyle.None
Renamer.BackColor = SystemColors.ButtonFace
Controls.Add(Renamer)
通过关闭边框并将 BackColor
属性设置为与我用于图层标题背景的系统颜色匹配,我们可以在任何地方显示我们的文本框,它看起来就像是我们控件的一部分。然后我们有两个方法来显示和隐藏 Renamer
:
Public Sub StartLayerRename(l As VashLayerBase)
For Each lli As LayerListItem In MyLayerList
If lli.Layer Is l Then
Renamer.Top = lli.TextBounds.Top + ((MyLayerHeaderHeight - Renamer.Height) / 2) - IIf(VScrollVisible, (VScrollRect.Top - MyLayerHeaderHeight) * MyLayerHeaderHeight, 0)
Renamer.Left = lli.TextBounds.Left
Renamer.Width = lli.TextBounds.Width
Renamer.Text = l.Name
Renamer.Tag = l
Renamer.Show()
Renamer.SelectAll()
Renamer.Focus()
Exit For
End If
Next
End Sub
...这会在给定图层的适当位置显示文本框,以及
Public Sub StopLayerRename(cancel As Boolean)
If Renamer.Visible Then
If Not cancel AndAlso Renamer.Tag IsNot Nothing Then CType(Renamer.Tag, VashLayerBase).Name = Renamer.Text.Trim()
Renamer.Tag = Nothing
Renamer.Visible = False
End If
End Sub
...这会隐藏文本框,并提交值给选定的图层,或者简单地忽略它。您何时会取消图层重命名?当然是当用户在编辑时按 Escape 键!
Private Sub Renamer_KeyDown(sender As Object, e As KeyEventArgs) Handles Renamer.KeyDown
Select Case e.KeyCode
Case Keys.Escape
'If the user hits Escape while editing a layer name, don't commit the changes:
StopLayerRename(True)
e.Handled = True
Case Keys.Enter
StopLayerRename(False)
e.Handled = True
End Select
End Sub
颜色选择器
我接下来要说的可能会让您震惊,但 .Net 自带的默认颜色选择器控件(我猜是 Windows 的原生控件)简直太糟糕了。在对其感到沮丧之后,我决定创建一个更像 Adobe Photoshop 的颜色选择器。最终结果是我的 AdvancedColorPicker
对话框。
Adobe 的颜色选择器控件(以及在有限程度上,Microsoft 的)通过将颜色的三个分量(RGB 或 HSV)分解成两个控件来工作;细长的垂直控件用于调整当前选定分量的值(在上图中,是H),较大的颜色表面是其他两个分量(在上图中,是S 和V)的二维映射。在我的控件中,我还包含了一个位于二维地图下方的水平滑块,用于控制颜色的 alpha(不透明度)值。我还保留了一个最近使用的颜色列表(用于当前应用程序实例),以及一个自定义的色板列表,这些列表保存在应用程序设置中。
在 阅读了 MSDN 上关于 ColorPicker.Net 的文章后,我知道可以复制 Adobe 的选择器,所以我开始尝试理解 HSV(或 HSB 或 HSL,取决于您的偏好)是如何工作的,以及它与 RGB 的关系,而 RGB 我理解得很好。然而,在我对这个的研究过程中,我发现我并不真正理解色相是如何计算的,但多亏了互联网的魔力,我不必这样做,并且很快找到了 将 RGB 和 HSV 之间转换的 VB.NET 代码。
绘制垂直滑块很容易。您从底部开始,计算您所在控件高度的百分比,并使用该百分比,计算所选分量值的范围(H 为 0-360,S 和 V 为 0-100,R、G、B 为 0-255)。然后,您根据其他两个分量的值,计算出该值应是什么颜色,绘制一条具有该颜色的水平线,然后向上移动到下一条线。规则的一个例外是色相分量;在计算颜色条当前行的颜色时,您忽略 S 和 V 的值,只使用 100(这样您就能得到这些色相最亮、饱和度最高的形式)。
二维颜色表面图稍微复杂一些。我没有研究过如何做它的策略,但我创建了一个内存中的位图,并使用 LockBits
命令直接快速操作像素(而不是使用出了名的慢的 SetPixel
)。然后我遍历每个 x 和 y 坐标,计算这些值代表控件宽度的百分比和高度的百分比,然后计算这些百分比对应的其他两个分量的值,并用它来计算该像素的颜色。
Dim surfaceData As System.Drawing.Imaging.BitmapData = Nothing
Dim surfaceBytes((MySurface.Width * MySurface.Height * 3) - 1) As Byte '3 bytes per pixel, RGB
surfaceData = MySurface.LockBits(New Rectangle(0, 0, MySurface.Width, MySurface.Height), Imaging.ImageLockMode.ReadWrite, Imaging.PixelFormat.Format24bppRgb)
System.Runtime.InteropServices.Marshal.Copy(surfaceData.Scan0, surfaceBytes, 0, surfaceBytes.Length)
Dim c As Color
Dim prevA, prevB As Integer
For rangeY As Integer = 0 To MySurface.Height - 1
Dim y As Integer = MySurface.Height - 1 - rangeY
For x As Integer = 0 To MySurface.Width - 1
Dim i As Integer = (rangeY * MySurface.Width * 3) + (x * 3)
Select Case ZProperty
Case "H"
Dim s As Integer = CInt(x * 100.0 / MySurface.Width)
Dim v As Integer = CInt(y * 100.0 / MySurface.Height)
If prevA <> s OrElse prevB <> v Then c = AdvancedColor.FromHSV(Color.H, s, v).Color
prevA = s
prevB = v
Case "S"
Dim h As Integer = CInt(x * 360.0 / MySurface.Width)
Dim v As Integer = CInt(y * 100.0 / MySurface.Height)
If prevA <> h OrElse prevB <> v Then c = AdvancedColor.FromHSV(h, Color.S, v).Color
prevA = h
prevB = v
Case "V"
Dim h As Integer = CInt(x * 360.0 / MySurface.Width)
Dim s As Integer = CInt(y * 100.0 / MySurface.Height)
If prevA <> h OrElse prevB <> s Then c = AdvancedColor.FromHSV(h, s, Color.V).Color
prevA = h
prevB = s
Case "R"
c = System.Drawing.Color.FromArgb(Me.Color.R, x * 255 / MySurface.Width, y * 255 / MySurface.Height)
Case "G"
c = System.Drawing.Color.FromArgb(x * 255 / MySurface.Width, Me.Color.G, y * 255 / MySurface.Height)
Case "B"
c = System.Drawing.Color.FromArgb(x * 255 / MySurface.Width, y * 255 / MySurface.Height, Me.Color.B)
End Select
surfaceBytes(i) = c.B
surfaceBytes(i + 1) = c.G
surfaceBytes(i + 2) = c.R
Next
Next
System.Runtime.InteropServices.Marshal.Copy(surfaceBytes, 0, surfaceData.Scan0, surfaceBytes.Length)
MySurface.UnlockBits(surfaceData)
尽管它相当快(并且我添加了启发式方法来尝试减少对内部 HSVtoRGB
转换函数的调用次数),但它仍然比 Adobe 的慢得多,并且在拖动垂直滑块时二维地图重绘有明显的延迟。我不知道如何做得更好。
我将不详细介绍其余控件的编写方式;阅读代码并弄清楚并不难。当然,如果您有任何问题,我会尽力回答。
您如何撤销您所做的事情
撤销和重做功能在所有应用程序中都得到了如此广泛的支持,以至于它给人一种简单的感觉。然而,一旦您开始思考,它并不那么容易。如何“撤销”添加一个对象?或者删除一个对象?或者仅仅是更改矢量对象的单个点?您如何重做某事?
我很快就意识到存在许多不同类型的“撤销”,每种都需要执行不同的操作。因此,像这个项目中的所有其他事情一样,我需要一个基类。
Public MustInherit Class UndoBase
Protected MustOverride Sub OnRedo()
Protected MustOverride Sub OnUndo()
Public Sub Redo()
OnRedo()
End Sub
Public Sub Undo()
OnUndo()
End Sub
End Class
从 UndoBase
,我创建了类来处理对象被删除或添加、属性更改等。源代码中有一个名为 Undo 的文件夹,您可以查看其中的实现;它们实际上在代码量上非常小。
现在我们能够存储撤销/重做操作,我们需要跟踪它们。Designer
类有一个 UndoBase
对象内部列表和一个指向列表当前位置的索引。当您进行更改时,新的撤销对象会被添加到列表中,并且索引被设置为列表的末尾。但是,当您撤销时,每次撤销时索引都会向后移动。重做时,它向前移动。如果您撤销然后执行一个导致新撤销对象添加到列表的操作,则索引之上方的所有旧撤销对象都会被删除。
有一个特殊的撤销类称为 UndoBatch
。这个类包含一个撤销对象的列表,由设计器用于将多个更改组合成一个“撤销”语句。例如,假设您一次选中 10 个对象并移动它们。您不希望在返回到先前状态时必须撤销 10 次,但所有这些对象的值都需要被跟踪。调用 Designer
类的 StartUndoBatch
会创建一个新的 UndoBatch
并将其放在撤销列表的顶部。当批处理打开时,对 AddUndo
的新调用会将撤销对象添加到批处理而不是内部列表中。调用 EndUndoBatch
会关闭批处理,撤销处理将正常继续。
绘图工具
工具,就像 Vash 中几乎所有其他对象一样,派生自一个抽象(即 MustInherit
)基类 ToolBase
。ToolBase
是一个相当空的类,主要由空的可重写方法组成,用于响应 SceneSurface
重定向给它的键盘和鼠标事件。由派生类负责重写这些方法并以最恰当的方式响应事件。
我个人讨厌为我创建的每个工具添加一个按钮(尤其是当我不知道项目将创建多少个工具时),所以我在 MainWindow
的 OnLoad
事件期间利用反射,找到 ToolBase
的每个子类,并将它们动态添加到 ToolStripContainer
。
'Get the list of tools:
Dim toolsToAdd As New List(Of ToolBase)
For Each t As Type In Me.GetType().Assembly.GetTypes()
If t.IsSubclassOf(GetType(ToolBase)) Then
Dim tool As ToolBase = Activator.CreateInstance(t)
toolsToAdd.Add(tool)
End If
Next
'Sort our list by index:
toolsToAdd.Sort(Function(x, y) CType(x, ToolBase).Index.CompareTo(CType(y, ToolBase).Index))
'Add our tools (in sorted order) to our toolstrip:
Dim toolbarPosition As Integer = 0
For Each tool As ToolBase In toolsToAdd
Dim tsb As New ToolStripButton()
tsb.Image = tool.Icon
tsb.ToolTipText = tool.Description & " (Hotkey " & (toolbarPosition + 1) & ")"
tsb.Text = tool.Name
tsb.Tag = tool.GetType()
tsb.DisplayStyle = ToolStripItemDisplayStyle.Image
'Use a lambda funciton (yay!) to assign the global designer object's
'"SelectedTool" property to the tool associated with this button:
AddHandler tsb.Click, Sub(sender As Object, e2 As EventArgs)
Designer.SelectedTool = Activator.CreateInstance(tsb.Tag)
StatusInformation.Text = Designer.SelectedTool.Description
End Sub
'Add it to the list:
DesignerTools.Items.Insert(toolbarPosition, tsb)
'Make sure we start with a tool selected:
If Designer.SelectedTool Is Nothing Then Designer.SelectedTool = tool
toolbarPosition += 1
Next
箭头工具
箭头工具是最通用的工具,因为它不仅用于选择和移动对象,还用于在双击对象时执行不同操作。例如,双击一个组会深入到该组并使其成为 Designer
对象上的新 SelectedContainer
。双击一个子场景会使其 Scene
对象成为当前选定的场景。双击一个矢量对象会切换 PointEditMode
属性。它是一个很忙碌的工具。
矩形和椭圆工具
矩形和椭圆都通过跟踪用户拖动的矩形来工作,创建一个内部 GraphicsPath
,将它们的形状添加到路径,然后调用新 VectorObject
上的 SetPath
来使用新路径重新创建对象,并触发 Refresh
调用 SceneSurface
来更新用户在拖动过程中的显示。
这两个工具如此相似,我本应该为矢量图元创建一个基类,并在实际将矩形/椭圆添加到构建的内部 GraphicsPath
对象时进行覆盖。也许在下一个迭代中我会这么做。
声音工具
声音工具非常简单。当您单击任何位置时,它会提示您选择一个媒体文件,如果您选择了一个,它会创建一个 Sound
类的实例并将其添加到选定的容器中。
铅笔工具
铅笔工具经历了一些非常复杂的版本,直到我发现我可以简单地跟踪鼠标拖动经过的所有点,并使用 GraphicsPath.AddCurve
来获得我想要的确切效果。如果您想让您的大脑受伤,可以查看我被注释掉的代码,看看我是如何尝试组合线段然后将它们“平滑”成最少数量的贝塞尔曲线的。那不是一个很好的算法,但对于一个不知道自己在做什么的人来说,它的效果还算可以。
多边形工具
多边形工具允许您单击添加点,然后按 Shift+单击关闭图形。如果我只使用线段,您将无法调整它们的曲线度,所以代替调用 DrawingPath.AddLine 或 DrawingPath.AddPolygon 来连接前一个点到当前点,我做了这个:
'Rather than use the AddPolygon function I'd like to allow the user to
'change the curviness of the line segments. Instead I'll add a bezier curve
'that works out to be a straight line.
'I do this by making the control points each 25% the length
'of the new line segement, and colinear.
DrawingPath.AddBezier(PreviousPoint, _
New Point(PreviousPoint.X + (thisPoint.X - PreviousPoint.X) * 0.25, PreviousPoint.Y + (thisPoint.Y - PreviousPoint.Y) * 0.25), _
New Point(thisPoint.X + (PreviousPoint.X - thisPoint.X) * 0.25, thisPoint.Y + (PreviousPoint.Y - thisPoint.Y) * 0.25), _
thisPoint)
现在当用户绘制多边形时,他们会得到预期的直边形状,但增加了将每个线段视为曲线的功能。
文本工具
文本工具只是创建一个 Vash 文本对象的实例,带有默认文本,并将其放置在鼠标所在位置的容器上。没什么可说的了。
子场景工具
子场景工具会在鼠标位置创建一个舞台大小 25% 的空子场景对象。我本可以默认 100% 的,但我希望用户看到子场景,而不会被它覆盖他们正在编辑的场景。
图像工具
与声音工具一样,图像工具会提示您选择一个外部图像文件,然后将其放置在屏幕上的那个位置。
导出图像
导出到 PNG 实际上非常简单。您只需创建一个与舞台大小相同的内存位图,从图像创建 Graphics 对象,然后调用当前场景的 Render 并传入该 Graphics 对象。然后您就(差不多)简单地保存您的图像了。
'Prompt for filename:
If ImageExportFilename.ShowDialog(Me) <> Windows.Forms.DialogResult.OK Then Return
Designer.Exporting = True
Dim b As New Bitmap(Designer.Project.StageWidth, Designer.Project.StageHeight)
Dim g As Graphics = Graphics.FromImage(b)
Try
'Clear the image to transparent:
g.Clear(Color.Transparent)
'Set our origin to the center of the image:
g.TranslateTransform(b.Width / 2, b.Height / 2)
'We're rendering so let's produce the highest-quality image we can:
g.CompositingQuality = Drawing2D.CompositingQuality.HighQuality
g.InterpolationMode = Drawing2D.InterpolationMode.HighQualityBicubic
g.SmoothingMode = Drawing2D.SmoothingMode.HighQuality
g.TextRenderingHint = Drawing.Text.TextRenderingHint.ClearTypeGridFit
Dim rc As New RenderContext(Designer.SelectedScene.Frame, g)
Designer.SelectedScene.Render(rc)
'PNG's have to be written to a MemoryStream first, then you can dump the stream to a file:
Dim ms As New IO.MemoryStream
b.Save(ms, Imaging.ImageFormat.Png)
Dim bytes() As Byte = ms.ToArray()
Dim fs As New IO.FileStream(ImageExportFilename.FileName, IO.FileMode.OpenOrCreate)
fs.Write(bytes, 0, bytes.Length)
fs.Close()
MsgBox("Export complete.", MsgBoxStyle.Information Or MsgBoxStyle.OkOnly, "Vash Image Export")
Catch ex As Exception
MsgBox(ex.Message, MsgBoxStyle.Critical Or MsgBoxStyle.OkOnly, "Vash")
Finally
'Dispose our graphics object so that there's no memory leaks
g.Dispose()
End Try
Designer.Exporting = False
导出视频
为了导出为视频文件,我使用了 ffmpeg。而不是做任何过于复杂的智力工作,我发现可以调用 ffmpeg 来接收一个图像目录并将它们组合成一个视频。所以导出解决方案实际上非常简单:
- 创建一个临时目录。
- 将设计器置于导出模式(
Designer.Exporting = True
)。 - 循环遍历当前场景的每一帧,将其渲染到内存中的图像,并将该图像导出为 PNG 到临时文件夹,格式为 f0000.png(其中 0000 针对每帧递增)。也许四位数字不够?
- 运行音频导出算法,这涉及到使用 NAudio.WaveMixerStream32 来在适当的时间混合所有音频文件。最终结果是一个 .wav 文件,其中包含所有声音在时间线上应在的位置。
- 使用正确的魔法参数运行 ffmpeg 来创建 avi(感谢 这个在线 ffmpeg 秘籍,我不必弄清楚所有令人困惑的参数是什么)。
- 删除临时文件夹。
- 播放视频,并为之的美丽而落泪。
缺失的功能
尽管我尽力使其尽可能完整,但仍有一些功能缺失:
- 渐变 - 我真的很想提供指定渐变填充和线条颜色的能力,但我无法完全理解如何从具有 X 个颜色停点的渐变 lerp 到具有 Y 个颜色停点的渐变(或到纯色,或从径向渐变到线性渐变等)。我认为我必须重新考虑
VashColor
对象整个结构和序列化/反序列化方式。更不用说要大力修改我的高级AdvancedColorPicker
表单了。 - 尺寸/旋转手柄 - 我在尝试允许尺寸手柄只拉伸一侧,另一侧保持固定方面遇到了困难,最终放弃了。因此,尺寸手柄围绕中心拉伸,这与那些事物通常的工作方式不同。
- SVG 导入 - 显然,我足够了解 SVG 可以导出到它,我也应该能够允许人们从 Inkscape 等其他编辑器导入他们的艺术作品。这只是需要花时间去做。
- Javascript 导出 - 我一直在认真考虑将整个动画导出为 Javascript 的想法,该 Javascript 连同一些支持代码,可以在 HTML5 canvas 对象中渲染和播放动画。那会很酷,对吧?
- 线宽 - 虽然工具栏中的默认填充和线条颜色可以通过单击来更改,但您实际上无法更改默认线宽(3.0 单位)。您始终可以在事后按每个对象进行更改,但这有点糟糕。解决方案可能需要一个自定义
ToolStripContainer
控件,因为现有的控件似乎都不合适。 - 可配置选项 - 有一些硬编码的内容,比如选择框和尺寸手柄的颜色可以配置,以及各种 UI 状态(如分隔线位置)可以更改并存储在用户设置中。
- 色板 - 我认为您无法删除它们。我不记得我写过那段代码。
- 旋转点 - 当您最初创建矢量对象时,其位置充当其旋转点;即对象旋转的点。在大多数图形程序中,您可以调整旋转点。在 Vash 中,一旦创建了旋转点,它就会保留在那里,即使您调整了对象的点,使得旋转点不再位于中心位置。
- 外部资源管理 - 图像和音频文件只是引用其在文件系统中的位置。大多数编辑程序会在项目文件旁边创建一个子目录,并将外部资源复制到其中,从而使所有引用都成为相对引用,并提高项目的便携性。Vash 不这样做,但只需稍作改动就可以使其按此方式工作。
- 撤销/重做 - 我确信有一两个(或更多)地方我未能记录撤销/重做操作,因此有些事情无法撤销。只是我工作粗心。
- PropertyGrid 问题 - 当您选择多个对象并在 PropertyGrid 控件中使用共享属性进行更改时,
PropertyValueChanged
事件不包含有关多个对象先前值的信息(当只有一个对象被选中时是有的),使得撤销跟踪不可能。
也许如果有人使用 Vash 并认为它值得增强,我会看看我能为这些功能做些什么。
关注点
这个项目最困难的部分是之后遍历所有文件以清理代码并添加注释,以便人们能够尝试理解它是如何工作的。唉,那太累人了。有 80 多个 .vb 文件要看,其中许多都有数百(或数千)行代码!不客气。
历史
- 2016-04-27 - 初始版本 0.9 部署到 CodeProject
结束
作为一个动画程序,Vash 绝对不是 Flash,作为一个矢量图形编辑器,它肯定也不是 Inkscape,但我仍然认为它对于一个月的努力来说相当酷。如果您最终用它制作了一些很棒的东西,请告诉我,我会在下面链接。如果您喜欢我所做的工作,我可接受合同工作(*眨眼* *眨眼*)。如果您发现了一个错误或想到了一个真正很棒的功能缺失,请在下面的评论中告诉我。
如果这篇文章的某个方面您觉得我没有解释清楚,或者您希望我详细介绍 Vash 中我没有讨论的某个部分(尽管这篇文章很长,但还有很多我没有讨论的内容),请告诉我,我会尽力而为。
感谢阅读。:)
用 Vash 制作的很酷的东西
- Catch - 我上传到 YouTube 的 Vash 演示
您是否使用 Vash 制作了很酷的东西?请告诉我,我将在此处链接。