Download Vash0.9.zip - source code and binaries - DOES NOT INCLUDE FFMPEG!
Click here to watch Catch, an example video created by me using Vash on YouTube
Introduction
Vash is an Adobe Flash-like application written in VB.NET using GDI+ which allows you to create vector graphics and animate them. It also features sound playing, raster image skewing, SVG exporting, and AVI exporting (when you download ffmpeg separately).
Background
I was looking at creating a short video for YouTube with some simple vector animation in it. Having used Flash many years ago, I was dismayed to discover that Flash is no longer a thing. I downloaded a few of the free vector animation programs that are out there, but I couldn't figure out how they worked. Frustrated, I asked myself the question "how hard is it to programmatically tween points in an object between keyframes"? Within two days I had a working protoype of an algorithm. I then started a new project to see just how far I could take it. Thousands of lines of code and 80+ .vb files later, Vash is the result of that endeavour. I wrote it over four weeks between my day job and my demanding personal life, most of the time spent debugging and adding little features once the main engine was complete. I then spent a few weeks writing this article and tweaking the code.
The name is sort of a portmanteau of "Vector" and "Flash". Or you can say I'm a fan of Trigun. Or you can say I like cows (the French word for "cow" is vache, pronoucned "vash"). Whatever is least litigious.
I wrote Vash in VB.Net because that's the language I'm most comfortable with. It ought to be easily ported to C# for all you C# junkies out there.
Despite me wanting to do everything by myself from scratch there are some things I just don't understand or where it was just easier to use pre-written libraries. Thus Vash makes use of the following:
- Clipper - used to handle polygon operations like union. The one drawback is that it requires all vector objects to be converted to polygons in order to work (losing bezier curves in the process.)
- NAudio - used to play audio files, and mix all audio to produce a single .wav for export to video. NAudio is available via NuGet.
- ffmpeg - used to create .avi files of individual scenes. I realize that there are a few .Net wrappers and implementations of ffmpeg, but for my purposes it was easiest just to use the original binary and
Process.Start
in the export window. IMPORTANT! If you download Vash you will have to download ffmpeg separately and place ffmpeg.exe in the same folder as Vash.exe in order for the export to avi to work!
How it Works
The code behind Vash consists of a bunch of small classes that build upon each other to become something huge and complex. I've tried to comment my code as thoroughly as possible, but I'll go over the important (to me) stuff that should give you an idea of how it works. If you need clarification on anything, feel free to ask.
How I Structure my Code
When you delve into my code to try and figure out how stuff works, here are some tips to help you:
- I love using regions everywhere (depsite the inherent code-folding abilities of Visual Studio), so my code is riddled with them. If you hate regions then I apologize in advance.
- In classes, I put all my event declarations and private members at the top of the class, followed by the properties, followed by the constructor/destructor (if any exist), followed by all methods in alphabetical order.
- I tried my best to give all variables and methods meaningful names and to comment my code as best as possible. For things like the color picker and the raster image skewing, I didn't really know what I was doing so there isn't as much commenting.
- Wherever possible I tried to cite sources for code that I, er, "borrowed".
So you know how to read it, let's get on to the nitty-gritty of how this is put together.
The Properties, they are A-Changin'
For any editor-style application to know when to update and redraw, it needs to know when the properties of its design-time objects change. While looking into the best way to do this I found out about the INotifyPropertyChanged
interface, which provides a standard framework for firing property changed events in the .Net Framework. So to start I created an abstract base class called PropertyChanger
:
Public MustInherit Class PropertyChanger
Implements INotifyPropertyChanged
Public Event PropertyChanged As PropertyChangedEventHandler Implements INotifyPropertyChanged.PropertyChanged
Protected Friend Sub OnPropertyChanged(ByVal name As String)
RaiseEvent PropertyChanged(Me, New PropertyChangedEventArgs(name))
End Sub
Protected Friend Sub OnPropertyChanged(sender As PropertyChanger, e As PropertyChangedEventArgs)
RaiseEvent PropertyChanged(sender, e)
End Sub
End Class
In derived classes, you would make use of the OnPropertyChanged
method like this:
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
The example I found online only had the OnPropertyChanged(ByVal name As String)
method, and I added the overloaded one so that events would buble up through the Vash DOM (more on that below).
Just about every class in the Vash application is an ancestor of PropertyChanger
. It's come in very handy and I certainly plan to use this model in future Windows projects.
The Vash DOM
For something complex like an animation builder, we're going to need a good Document Object Model (DOM) to easily build, traverse, store and retrieve every aspect of our animation. The best thing to do is create a base class that everything in the DOM will inherit from, which I call VashObject
.
VashObject
inherits from PropertyChanger
and implements ICloneable
(more on that later). It maintains the parent-child relationship of the structure and facilitates serializing and deserializing the structure to XML.
There are a lot of classes that inherit from VashObject
, but most of the time we'll be dealing with the abstract base class. You can thus imagine that we'd be making a lot of calls to CType()
and testing for type equality using GetType()
and IsSubClassOf()
. I find it a pain to constantly write that code over and over so VashObject
has a few generic functions to speed up the process and it make the code a bit easier to read:
Public Function [As](Of T As VashObject)() As T
Return CType(Me, T)
End Function
and
Public Function [Is](Of T As VashObject)() As Boolean
Return Me.GetType() Is GetType(T) OrElse Me.GetType().IsSubclassOf(GetType(T))
End Function
These two functions allow me to write code that would normally look like this:
Dim vo As VashObject = SomeMethodThatReturnsADOMItem()
If vo.GetType() Is GetType(VectorObject) OrElse vo.GetType().IsSubClassOf(GetType(VectorObject)) Then
CType(vo, VectorObject).SomeVectorObjectMethod()
End If
as this:
Dim vo As VashObject = SomeMethodThatReturnsADOMItem()
If vo.Is(Of VectorObject)() Then
vo.As(Of VectorObject)().SomeVectorObjectMethod()
End If
Much easier to read, yes? [admittedly, As
isn't much of an improvement over CType
, but I wanted consistent-looking code.]
VashObject
also contains code to handle most of the serialization to and from XML, handling animation events, and several other supporting functions, most of which can be overridden by ancestor classes to perform their own implementation of them. For example, the method to cause a VashObject
to draw itself is Render
. Internally, Render
calls OnBeforeRender
, OnRender
, and OnAfterRender
to allow derived classes to do any work they need to do in order to render (not all classes actually render anything).
From VashObject
I created three abstract classes that will drive the rest of the DOM classes:
- VashLayerBase - The base class for layers and layer groups (folder). It adds the
Locked
and Visible
properties.
- VashMoveable - The base class for all visual objects. It adds the
Opacity
and X
and Y
coordinate properties.
- VashTransformable - The base class for visual objects that can be scaled and/or rotated, inherited from
VashMoveable
.
With VashObject
and its derivative abstract classes defined I could now create the rest of the classes that make up the Vash DOM. Here is the hierarchy:
- Project - The root object in the DOM. Defines the size of the stage that scenes are rendered on. Also keeps track of the internal id number counter. There can be only one Project node per Vash project.
- Scene - The container that represents a single animation. There can be several Scenes in a Project.
- LayerGroup (Folder) - A container of other LayerGroup objects and Layers. A way to visually group your layers in a Scene.
- Layer - Similar to layers in Photoshop or Flash, each layer contains the keyframes, which in turn contain all visual objects in the scene.
- KeyFrame - Represents a change of state of the objects on the layer. The frames between two keyFfames are used to linerally interpolate (LERP, see below) object values between the two.
- Group - As in most drawing programs, a group is any collection of visual objects, grouped together.
- RasterImage - A raster image (bitmap, jpeg, png, etc.), i.e. not vector art.
- Sound - An invisible (at play/export time) object that plays a sound.
- Subscene - A container that allows you to play a Scene within another
Scene
.
- Text - An object that renders text. Text objects can be converted to
VectorObject
s by right-clicking on them, allowing you to manipulate the shape of the characters.
- VectorObject - An object containing points that define a vector graphic
- VectorPoint - A single point of the path of a
VectorObject
Now since each class gets it's list of children from VashObject
, and they're just an abstract List(Of VashObject)
; it's up to the software to enforce that this structure is represented.
Lerp-da-Derp
All tweening in Vash is done by linear interpolation (or LERP-ing, as it's called in the gaming development environments I've dabbled in). Lerping is the act of taking a starting value, an end value, your current position between the two (as a percentage), and calculating the current value between the two for that position. For numeric values, the formula is this:
currentValue = start + (end - start) * position
So a position of 0% would give you start, and a position of 100% (1.0) would give you end. Any other value is somewhere between the two. Easy peasy.
Actually implementing lerping for things like points in a vector object isn't as straightfoward when it comes to animation. How do you keep track of your current (lerped) value without losing track of the original start or end position? I suppose you could keep arrays to keep track of these things, but to me it made more sense to create a generic class that kept track of all that for each value that was actually lerpable. Hence the aptly named Lerpable generic class:
Public MustInherit Class Lerpable(Of T)
Inherits PropertyChanger
Inside this class there are properties for the actual value of the object, as well as the "delta" value (i.e. the result of the lerp operation). The delta values of all lerpables are calculated whenever the frame of the currently-selected scene changes. There is one additional property, Lerpable
, which is a Boolean
value indicating where or not it should be lerped during animation.
With this base class I created three derivative classes to handle the three types of values that can be lerped in Vash:
LerpableSingle
- Handles interpolation of floating-point (Single) values
LerpableInteger
- Handles interpolation of integer values
LerpableColor
- Handles interpolation of VashColor
objects. This involves interpolating between each component (A, R, G, B) of the color.
A Note About Generics: when dealing with objects whose actual types aren't known at runtime, generic classes are quite difficult to work with. Unlike non-generic types where you can use a variable of the abstract base class to perform base-class operations, you can't create a variable of Lerpable(Of T)
and just call Save
or any other method. So after spending a day or so smashing my face against a wall I decided to take the coward's way out and just hard-code the three cases whenever I had to:
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
Make Animation Go Now!
Now we're at the point where we can start handling animation. In Vash, Scenes are animated. Scenes contain layers, layers contain keyframes, and keyframes contain the actual visual objects that get rendered. When it comes to animating, keyframes are the most important objects.
When you first add an object (in my example, a green circle) to a layer, if there is no keyframe one is automatically created and the circle is added to it:
If we then use the timeline to click on frame 24 you'll see that the ball is still there. That's because during rendering we draw from the keyframe closest to the current frame (in this case, it's drawing the keyframe from frame 1). Hitting F5 will create a new keyframe at frame 24, but it won't be an empty keyframe: the new keyframe will contain an exact copy (including ids) of all the children of the keyframe before it (via cloning, as VashObject
implements ICloneable
):
Now that we're dealing with a clone, we can move the circle at frame 24 wherever we like and the circle at frame 1 won't be affected:
No if we go to another frame (say frame 12) something new will happen: in the Scene
class when the Frame
property changes the scene object calls Animate
on itself:
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
Dim ac As New AnimationContext(value)
Designer.Animating = True
Animate(ac)
Designer.Animating = False
OnPropertyChanged("Frame")
End If
End Set
End Property
The Animate
method (in VashObject
) does a lot of things, and is recursive, calling itself against every child of the object. So when a scene calls Animate
on itself, it loops through its layers and calls Animate
. Layers in turn do something special: instead of calling Animate
on all their children (which are keyframes), they find the closest keyframe to the frame being animated and call Animate
on that keyframe only. Keyframes set themselves as the currently in-scope keyframe object in the AnimationContext
instance and then call Animate
on their children. When we finally reach objects with lerpable properties, things get interesting:
If context.KeyFrame IsNot Nothing Then
Dim doppelganger As VashObject = Nothing
If context.KeyFrame.Next IsNot Nothing Then
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
Dim v = pi.GetValue(Me)
If v.Lerpable Then
v.Reset()
If doppelganger IsNot Nothing Then
v.Lerp(pi.GetValue(doppelganger).Value, context.LerpAmount)
End If
End If
End If
Next
End If
In a nutshell: each object looks for its clone in the next keyframe (assuming there is one). It loops through all its lerpable properties and if the Lerpable
member is set to True
, it lerps the value between it's original value and the value of the same property on its clone, storing the result in the lerpable's Delta
property (so we don't lose its original value).
Then when we render, objects are drawn using the Delta
values of their properties, not the actual values:
Now frame 12 shows the circle as halfway between its original location in frame 1 and its clone's new location in frame 24. All animation is done this way, and for even busy scenes like my Catch example the animation takes about 40-60 milliseconds per frame.
Rendering
Since the Vash DOM is a hierarchy, as each object is rendered we apply its transformations (translation, scale, rotation) before rendering its children. To help keep track of it all, I have created a class called RenderContext
, which is passed along by each VashObject
and contains all the information needed to properly render a scene.
RenderContext
is initialized with a frame number and a System.Drawing.Graphics
object. The Graphics
object could be the drawing surface of a SceneSurface
control, a wrapper around a System.Drawing.Image
object for image exporting, or any other valid Graphics
object, so it's fairly flexible.
There are two properties on the RenderContext
class that are modified when an instance is passed through the DOM during a render event: Effects
and OpacityStack
. Effects
is a cumulative list of all special effects that need to be applied (see the section on effects below), and OpacityStack
is a stack of Single
values which equate to the multiplicative opacity down through the tree (e.g. if my parent has an opacity of 0.5 and I have an opacity of 0.5, then my children should be rendered with an opacity of 0.25).
Besides the constructor, RenderContext
has only two methods: PushGraphicsState
and PopGraphicsState
. All they do is call Graphics.Save
and Graphics.Restore
(respectively), keeping track of the GraphicsState
object returned by Graphics.Save
in a stack.
Note: Since we're transforming the graphics state for (potentially) each node in the Vash DOM, every visual object draws itself with (0, 0) as the origin for the drawing. For example, if an object is located at (100, 50), we first transform the graphics state to (100, 50) then draw from (0, 0). This allows us to move VectorObject
instances around in the hierarchy without having to recalculate the location of every point.
The method VashObject.Render
calls those methods on the RenderContext
instance passed to it, which handles all the transformations and resets, and also gives derived classes the ability to perform any functions they need to at each step in the process:
Public Sub Render(rc As RenderContext)
rc.PushGraphicsState()
Dim effectsStartIndex As Integer = rc.Effects.Count()
rc.Effects.AddRange(Me.Effects)
OnBeforeRender(rc)
For Each e As EffectBase In rc.Effects
e.OnBeforeRender(rc, Me)
Next
OnRender(rc)
For Each e As EffectBase In rc.Effects
e.OnAfterRender(rc, Me)
Next
OnAfterRender(rc)
If Effects.Count > 0 Then
rc.Effects.RemoveRange(effectsStartIndex, Effects.Count - 1)
End If
rc.PopGraphicsState()
End Sub
Out of all the classes derived from VashObject
, only RasterImage
, Text
, and VectorObject
actually do any drawing to the Graphics
object. All other classes merely apply their transforms (if any) and pass on the rendering to their children. The three exceptions are the Sound
class, which uses NAudio to play sound at render time, Subscene
, which passes the rendering to another instance of Scene
, and Group
, which, if it's the current Designer.SelectedContainer
(see the section on the designer below), resets the OpacityStack
to 1.0 (so its children appear to be "active").
The OnBeforeRender
method is overridden by VashMoveable
and VashTransformable
, and as you see they set up the current object's location, scale, and rotation in the Graphics
object's transformation matrix prior to the object actually rendering itself:
Protected Overrides Sub OnBeforeRender(rc As RenderContext)
MyBase.OnBeforeRender(rc)
Dim transformedOpacity As Single = Math.Min(1.0, Math.Max(0.0, MyOpacity.Delta))
If rc.OpacityStack.Count > 0 Then
transformedOpacity *= rc.OpacityStack.Peek
End If
rc.OpacityStack.Push(transformedOpacity)
rc.Graphics.TranslateTransform(X.Delta, Y.Delta)
End Sub
Protected Overrides Sub OnBeforeRender(rc As RenderContext)
MyBase.OnBeforeRender(rc)
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
So how does the drawing actually happen? Well by the time all the parent nodes in the Vash DOM apply their transforms (all handled by VashObject
, VashMoveable
and VashTransformable
), all each class has to do is override OnRender
and draw.
VectorObject Rendering
VectorObjects
are pretty simple. They set their line & fill colours (based on the delta values, of course, as we might be lerping them), adjust the colours based on the current value of the OpacityStack
in the RenderContext
instance, and call Graphics.FillPath
and Graphics.DrawPath
to render.
Protected Overrides Sub OnRender(rc As RenderContext)
If MyPath Is Nothing Then Return
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
Text Rendering
You would think that rendering text is quite complicated, but thankfully GDI+ comes with the ability to add a string to a GraphicsPath
object. Whenever the text, style, or size is changed in a Vash Text
object, the method RecreatePath
is called which initializes an internal GraphicsPath
object:
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
Then when it's time to render, we only need a few lines of code:
Protected Overrides Sub OnRender(rc As RenderContext)
If MyPath Is Nothing Then Return
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 Rendering
GDI+ contains a lot of handy methods for drawing raster images quickly, and can even handle scaling. Thus I could have used Graphics.DrawImage
(or one of the similarly-named functions) and called it a day's work. But I'm crazy so I wanted to figure out how I could allow you to move any of the four anchor points of the image and skew the image accordingly. For example, suppose I wanted something like this:
Sadly, GDI+ has nothing built-in for that type of image manipulation. So how do we skew raster images? Well, after reading a lot of stuff online about advanced image drawing techniques in GDI+ I had a rough idea of how to tackle the problem. I would basically have to write a texture-mapping algorithm.
I have no idea if how I implemented this is how it's really done by the pros, but this is the methodology I used:
- Get the four coordinates of the anchor points. Call them A, B, C, D.
- Figure out the length of lines AB, AC, CD, and BD.
- Create a new point E which is initialized to A, and a point F which is initialized to B.
- For each point along line EF, determine the percentage you are with respect to the length of EF. Call this u.
- Use u to calculate the corresponding position along AB (i.e. if you're 50% along EF get the coordinate at 50% AB), and call it G. Do the same to determine your position along CD and call it H. Determine the length of GH, and the percentage along GH your current coordinate is at. Call this v. Thus (u, v) is where EF and GH cross.
- From the original Image object, get the pixel at
(Width * u, Height * v)
. Set the pixel of the destination bitmap the to this value, adjusting the alpha value with the current opacity value for this object.
- Because of floating-point rounding during this process, gaps can occur in the resulting image. I solved this issue by adding a simple stitching heuristic: if our current coordinate is not at a right edge, fill in the pixel to the right with the same value as the current one. It's not beautiful, but it filled in the gaps.
- Using the rise/run values for AC and BD, move E and F down the left and right edges (respectively), and repeat until E is at C and F and is at D.
Speed is key to doing this sort of work so rather than use the criminally-expensive GetPixel
and SetPixel
, I used LockBits
and System.Runtime.InteropServices.Marshal.Copy
to copy the bitmap data into an array of bytes (one byte for each A, R, G, and B value). Then I could use quick memory operations to copy the pixels from the source image to their appropriate location in the destination image, adjusting the alpha values based on the current opacity value for the object.
Since my code is experimental, it's not as efficient as it could be (nor is it commented as well). For example, the in-memory image is drawn every time OnRender
is called. In reality it should be buffered and only redrawn if the object's opacity value or one of the four anchor points is changed.
Sound Rendering
As mentionned above, the Sound
class does something slightly different in OnRender
. Rather than draw it actually plays a sound! It does this using the NAudio library, which is a very complex .Net library for working with audio. I don't understand most of it but I muddled through it enough to actually get sound to come out. Part of the fun was knowing at what point to start playing the audio if you happened to start playing the scene at a frame after the keyframe the audio belonged to:
If WaveOut.PlaybackState <> PlaybackState.Playing AndAlso Action = SoundAction.Play Then
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
One of the other neat things I figured out was that if you're in design mode you don't want the sound to play in its entirety each time you click on a frame (especially if it's a 4-minute mp3), but you just want to play the audio at that moment for the fraction of a second that one frame comprises:
AudioReader.CurrentTime = TimeSpan.FromSeconds((rc.Frame - Me.GetAncestor(Of Layer)().GetKeyFrame(rc.Frame).Frame) / Designer.Project.FramesPerSecond)
Try
WaveOut.Play()
Threading.Thread.Sleep(1000 / Designer.Project.FramesPerSecond)
WaveOut.Stop()
Catch ex As Exception
WaveOutBuffers.Remove(Me.Id)
End Try
You can use Vash to lerp the Volume
property of the Sound
class, but I couldn't figure out how to have variable volume when mixing the audio during export to video, so only the initial volume level will export. I imagine I have to do some research into cross-fade mixing with NAudio or something. Maybe there's an NAudio guru out there who could tell me because it's really over my head.
Note: I lied a bit. When the designer is not actively playing the scene, Sound
will draw an audio icon at its coordinates to give the user something to visually see and click on at design time.
Effects
One of the flexible aspects of Vash is the ability to add different types of effects at any level in the hierarchy. Effects are derived from EffectBase
(which in turn is derived from VashObject
), but they are handled slightly differently and stored in a separate collection on each VashObject
(instead of the Children
collection). The reason I handle them differently is that effects are cumulative in their DOM branches; i.e., an effect assigned to an object will be propagated down through its children. This allows you to take an effect like "Drop Shadow" and add it to a layer, thus causing a drop shadow to appear on every visual object in that layer througout the animation.
So far, I've only created two effects:
- Drop Shadow - An effect that renders a shadow of every object it's applied to. The shadow colour and offset properties are lerpable
- Point Squiggle - An effect that oscillates
VectorPoint
object positions with each frame to give a wavy appearance to objects (see the bodies and heads of the father and son in my Catch example).
Effect values are lerpable, so you can lerp a drop shadow's offset over several frames, making it appear that the light source is moving (thus causing the shadow to move).
The Vash Designer
Now all this animating and rendering is all well and good, but without the ability to visually manipulate things this project would be pretty boring. So here's an overview of the interesting parts of the designer half of Vash.
Designer
There is, appropriately enough, an actual class called Designer
in Vash. It's a singleton class and it is the switchboard through which all controls and the Vash DOM communicate:
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()
End If
Return MySingleton
End Get
End Property
...
Private Sub New()
End Sub
...
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
The key properties of the Designer
class are as follows:
- Exporting - Determines if Vash is exporting the current scene (to a png or avi). DOM objects and controls change their behaviour depending on this value.
- FillColor - The current fill color to be applied to new objects
- LineColor - The current line color to be applied to new objects
- LineWidth - The current line width to be applied to new objects
- Playing - Determines if Vash is playing the current scene in the designer. DOM objects and controls change their behaviour depending on this value.
- PointEditMode - Toggles whether the designer allows you to manipulate the individual points of vector objects instead of their scale/rotation.
- Project - The currently-loaded project in the editor
- SelectedContainer - The current container that the designer is adding objects to. Normally a
Keyframe
, if you double-click on a Group
it will become the selected container and cover everything else with an opaque surface.
- SelectedLayer - The currently-selected layer in the
Timeline
control (see below)
- SelectedObjects - The collection of objects currently selected in the designer
- SelectedObject - The first (or only) selected object in
SelectedObjects
. If no objects are selected then Nothing
is returned.
- SelectedScene - The currently-selected scene in the designer.
- SelectedTool - The currently-selected tool in the designer.
Since Designer
inherits from PropertyChanger
, if any of the above properties change an OnPropertyChanged
event is fired. It also binds to the current project's OnPropertyChanged
event and bubbles it up through itself. Since Designer
is a singleton class, all the controls need to do is bind to that event in order to know when any property changes in the designer or the Vash DOM.
Designer
also contains the undo/redo list (see further down for details).
MainWindow
MainWindow
is the class name I give to the main form of every Windows application I write (I think it's a Borland Visual C++ thing from back in the 90's when I taught myself C++). It contains all the controls used to manipulate the Vash DOM and is by far the busiest class, though most of its code are event handlers for menu and toolstrip items. There's a lot going on here, so I'll break down some of the most import (to me) aspects of the form:
The DOM Tree Control
This TreeView
control in the top-left shows you the current path from where you are in the DOM back up to the project itself. I toyed with the idea of loading the entire tree into here, but the constant updating required made it a bit of a mess and confusing.
Clicking on a node in the DOM Tree will make that node the currently selected object in the designer, which means that the effects list (see below) and the property grid are displaying the items relevant to the selected node.
Nodes are displayed as their name (or class name if the Name
property has no value) along with the internal id in brackets. The id is kind of pointless now, but it was useful for me to debug the application, especially when dealing with the clones between keyframes.
The Effects List
The effects list (right beneath the DOM tree) shows you the effects being applied to the currently-selected object. It is worth noting that at animation/render time effects are applied cumulatively down the nodes of the tree. So if a layer has the "drop shadow" effect applied to it, every VectorObject
on that layer will have a drop shadow.
The Scene Selector
Hidden away between the SceneSurface
and the Timeline
controls is a toolstrip container with the scene selector in it. The scene selector is a dropdown control whose items are the current scenes in the project. The icon to the right will add a new scene to the project. When you change what scene is selected you change what scene you're editing/exporting/playing in the designer.
You can also create (or delete) scenes by looking under the "Scene" menu in the menubar.
The SceneSurface Control
Presentation is everything they say, so we need a pretty robust control when it comes to rendering our animation on screen. Since virtually all the rendering logic is handled by the Vash DOM, all we really have to do is track a few useful properties and tell the DOM to draw on our control at the appropriate times.
There are only three properties on the SceneSurface
control:
- AutoFit - a
Boolean
value which when true sets the zoom property to ensure that the entire stage fits in the control regardless of the dimensions.
- PanOffset - the distance from center that the user has panned the surface (when zoomed in larger than the control itself).
- Zoom - the amount to scale the scene when rendering. When
Autofit
is True
, this value is determined automatically to provide a best fit for the control's dimensions.
When I first started this project, SceneSurface
didn't do anything with mouse events except to pass them off to the currently-selected tool (Designer.SelectedTool
). Then I figured out how to make the sizing & rotation grips and selection boxes at the top level of the surface and I needed to change it to handle interaction with the grips. If the mouse isn't in a grip, then the event is passed on to the tool.
SceneSurface
was built to render the scene, and to do it flicker-free we set the OptimizedDoubleBuffer
style on the control in SceneSurface
's constructor:
SetStyle(ControlStyles.AllPaintingInWmPaint, True)
SetStyle(ControlStyles.OptimizedDoubleBuffer, True)
SetStyle(ControlStyles.UserPaint, True)
SetStyle(ControlStyles.ResizeRedraw, True)
SetStyle(ControlStyles.UserMouse, True)
SceneSurface
also exposes two public methods, OnBeforeRender
and OnAfterRender
, which, like VashObject
, perform any transformations to the graphics objects prior to rendering.
The Timeline Control
Adding visual objects to the current keyframe is great, but if you can't change what frame you're looking at, it's kind of useless. We also need a way to see, organize, and select our layers in the scene. This makes the Timeline
control the most complicated custom control in the entire project. Like the SceneSurface
control above, Timeline
has the OptimizedDoubleBuffer
style set to True
, allowing for flicker-free rendering.
The Timeline
control is where we visually view the layers in the selected scene and it can be a little complicated to deal with, as layers are potentially nested (layers within folders within folders) requiring them to be indented, and folders can be collpased or expanded. Every time we paint the control we could recursively go through the layers and draw them, and that is what I initially did, but it became confusing really quick, and when I went to write the hit test algorithm (below) I realized I'd have to recursively traverse the entire collection again. Not to mention that I wanted the user to be able to click on the circle glyph to toggle visibility and the lock glyph to toggle the locked state and it made figuring out those locations next to impossible.
What I ultimately decided on doing was creating a new class called LayerListItem
which contained a reference to the actual layer, and the bounds of every interactive part of the layer header (i.e. a rectangle for the green circle, the lock, the icon, and the text). Then I went through the layers recursively and only added items whose parents weren't collapsed and flattened the hierarchy into the list. That made drawing way easier (I just had to iterate through the list), and made hit testing way easier (again, just iterate through the list, no recursion neccessary). The only drawback was that every time the Expanded
property on a LayerGroup
changed, or the sort order of the layers changed, or a layer was added or removed, the list had to be rebuilt, but since there are only a few layers per scene, relatively speaking, it's not even noticeable.
Hit me baby one more time
One of the most difficult things I've tried to make a few non-programmers understand about modern computers is that the objects they see on the screen aren't real; there is no actual button on the screen, or text in their word processor, etc., but that everything they see is "painted" and complex algorithms figure out what you're actually clicking on or what you're hovering over. Even Windows Forms programmers are barely aware of this as they rely on their textboxes and gridviews to perform all sorts of automatic magic for them.
But anyone who has ever written a custom control and has peered behind the curtain can't help but appreciate the joy and wonder of writing their own hit test algorithm. Take the picture of the Timeline
control above: there are layers, folders, icons, glyphs to control visibility and locking, frame numbers, keyframe indicators and two scrollbar things. It's visually impressive, but in reality the operating system (and even the .Net Framework) is aware of none of it. All I'm given is a rectangular space to paint in and receive mouse and keyboard events. So I paint every piece of my timeline and then when the mouse moves or is clicked I have to figure out what "part" of the timeline I'm actually interacting with.
For those who have never written one, a hit test algorithm takes an x, y coordinate and based on the current state of the control returns what part of the control is at that point. It sounds simple, but remember that .Net knows nothing about the visual representation of the timeline, so we have to figure this out for ourselves.
The first thing we need is a list of distinct areas of the control. For our purposes, an Enum
will work just fine:
Public Enum TimelineArea
BlankSpace
HorizontalScroll
VerticalScroll
LayerName
LockToggle
LayerIcon
LayerHeader
GridHeader
Grid
VisibleToggle
End Enum
Hopefully the values are all pretty self-explanatory, with perhaps the exception of BlankSpace
; that's just the catchall term for any area of the control that has nothing interactive at that spot (e.g. the top-left section of the timeline).
We now have the means to report on what type of area a point is in, but there's more we need to return. We might be clicking on a GridHeader
(i.e. the top of the control where the frame numbers are shown in the grid), but what frame are we clicking on? If we're clicking on a layer header or icon, which layer is it? Just the area alone isn't enough. Our hit test algorithm is going to have to return something more complex. This is where we create another class, 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
This class doesn't do anything except expose read-only values of the parameters passed into its constructor but that's okay, because all the work of figuring out where we are is handled by the appropriately-named method, HitTest
.
Public Function HitTest(x As Integer, y As Integer) As TimelineHitTestResults
As I said above, a hit test algorithm takes coordinates as a parameter, so this method declaration shouldn't be any surprise. Now comes the fun part. At the top level (z-index-wise) in the timeline control are the two "scrollbars". They aren't Windows Forms scrollbars, but merely rectangles that I draw onto the control when the content is bigger than the control's client rectangle. So the first thing we do is see if our scrollbars are visible, and if they are, if the passed in coordinates are in either rectangle. If the coordinates are inside one of the rectangles, we return hit test results that say which scrollbar we're over (and since we're over a scrollbar, none of the other parameters matter):
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
If we aren't over a scrollbar, we've got more work to do. Our x and y values are in control coordinates relative to the top-left of the control, but if the user has scrolled the content vertically we need to adjust our y value to reflect this. Complicating matters is that the grid header doesn't change position regardless of your vertical scrolling (it's "frozen" in Excel parlance), so we should only adjust our y coordinate by the scroll position if it's lower than the grid header:
Dim originalY As Integer = y
If VScrollVisible Then
y += (VScrollRect.Top - MyLayerHeaderHeight) * MyLayerHeaderHeight
End If
Note: The reason I don't adjust the x coordinate in a similar fashion is because there's a separate internal member of Timeline
called FrameStart
which determines which frame we're starting from when rendering the grid from left to right and is controlled by the horizontal scroll, so it does the work that such an adjustment would have done.
If we're not over a scrollbar we're going to be returning more meaningful values for the other members of TimelineHitTestResults
. So we'll create some variables to hold the default values right off the hop:
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
Then we examine our x and y coordinates with respect to the headers and work out what specific part of the control we're over:
If x < MyLayerHeaderWidth Then
area = TimelineArea.LayerHeader
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
frame = ((x - MyLayerHeaderWidth) \ MyFrameWidth) + FrameStart
Else
area = TimelineArea.Grid
frame = ((x - MyLayerHeaderWidth) \ MyFrameWidth) + FrameStart
If layerIndex >= 0 AndAlso layerIndex < MyLayerList.Count() Then
listItem = MyLayerList(layerIndex)
layer = listItem.Layer
End If
End If
Finally all that's left is to return a new instance of TimelineHitTestResults
with all the values that we've set:
Return New TimelineHitTestResults(frame, listItem, layer, area)
End Function
That's hit testing in a nutshell. So what do we do with these results? Everything! The timeline control's mouse events all call HitTest
to figure out where the cursor is in the timeline control and the behaviour changes depending on what area you click on. Obviously clicking and dragging inside a scrollbar casuses the scrollbar to track with the mouse. Clicking and dragging on a keyframe allows you to move the keyframe anywhere between its previous and next siblings. Dragging layers lets you reorder them or move them in or out of folders. Clicking on the toggles flip their values, double-clicking on the folder icon toggles the Expanded
property, and double-clicking on the layer name causes the layer to start the inline renaming process.
What's in a name?
Because you can select the layer from the DOM Tree you can change its Name
property through the PropertyGrid
control. However most users would expect to be able to double-click on the text, or select the layer and press F2, and be able to type a new name for the layer right there on the control. And while using Vash to create my demo video Catch, I realized it was a useful feature and added it. Now you may be saying "Surely, Clayton, you didn't write the logic of a text-editor control into the Timeline for layer renaming?", and you would be right. With a control as complicated as Timeline
, it makes sense to make use of what's already there. So I use a textbox.
Private WithEvents Renamer As New TextBox
Renamer
is a hidden TextBox control inside the Timeline
control. To make it not so obvious we fiddle with its default appearance in the Timeline
constructor:
Renamer.Visible = False
Renamer.BorderStyle = BorderStyle.None
Renamer.BackColor = SystemColors.ButtonFace
Controls.Add(Renamer)
By turning off the border and setting the BackColor
property to match the system color I use for the layer header background we can show our textbox anywhere and it will appear to be part of our control. We then have two methods for showing and hiding 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
...which displays the textbox at the appropriate place for the given layer, and
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
...which hides the textbox and either commits the value to the seleted layer, or simply disregards it. When would you ever cancel a layer rename? Why when the user presses the Escape key during editing, of course!
Private Sub Renamer_KeyDown(sender As Object, e As KeyEventArgs) Handles Renamer.KeyDown
Select Case e.KeyCode
Case Keys.Escape
StopLayerRename(True)
e.Handled = True
Case Keys.Enter
StopLayerRename(False)
e.Handled = True
End Select
End Sub
The Color Picker
What I'm about to say will likely shock you, but the default color picker control that comes with .Net (what I assume is the native one for Windows) totally sucks. And after getting frustrated with it, I decided to create one that worked more like Adobe Photoshop's. The end result is my AdvancedColorPicker
dialog:
Adobe's color picker control (and to a limited extent, Microsoft's) works by breaking the three components of the colour (either R,G,B or H,S,V) into two controls; the skinny vertical one which adjusts the value of the currently-checked component (in the above image, H), and the larger color surface which is a 2D map of the other two components (in the above image, S and V). In my control I also include a horizontal slider under the 2D map which controls the alpha (opacity) value of the colour. I also have it keep a list of recently-used colours (for the current instance of the application), as well as a customizable list of swatches that are saved in an application setting.
After reading an article on MSDN about ColorPicker.Net, I knew it would be possible to replicate Adobe's picker so I set about trying to understand how HSV (or HSB or HSL depending on your preference) works and how it relates to RGB, which I understand really well. During my research into this however I decided that I don't really understand how Hue is calculated, but thanks to the magic of the Internet I didn't have to, and I quickly found VB.NET code to convert between RGB and HSV.
Drawing the vertical slider was easy. You start at the bottom, figure out the percentage of the control's height you are at and using that percentage, work out the value of the range for the selected component (0-360 for H, 0-100 for S and V, and 0-255 for R, G, and B). Then you figure out what colour it should be for that value (based on the values of the other two components), draw a horizontal line with that colour, and then move up to the next line. The one exception to the rule is for the Hue component; when calculating the colours for the current line of the colour bar you ignore S and V's values and just use 100 (so you get the brightest, most saturated versions of those hues).
The 2D map colour surface was a little more complicated. I didn't read up any strategies on how to do it, but I created an in-memory bitmap and used the LockBits
command to directly manipulate the pixels quickly (instead of using the notoriously slow, SetPixel
). I then went through each x and y coordinate, figuring out the percentage of the width and height of the control those values represented, then worked out the value of the other two components for those percentages, and used that to calculate the colour for that pixel.
Dim surfaceData As System.Drawing.Imaging.BitmapData = Nothing
Dim surfaceBytes((MySurface.Width * MySurface.Height * 3) - 1) As Byte
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)
Despite it being fairly fast (and my adding of heuristics to try and reduce the number of calls to the internal HSVtoRGB
conversion function) it's still way slower than Adobe's and there's noticable lag on the 2D map redraw when you drag the vertical slider around. I'm not sure how to do it any better.
I won't go into much detail about how I wrote the rest of the control; it wouldn't be too hard to read the code and figure it out. And of course, if you have any questions I'll try my best to answer them.
The way You Undo the Things You Do
Undoing and Redoing is handled so ubiquitously by every application out there that it gives the appearance of simplicity. Once you stop to think about it however, it's not so easy. How does one "undo" adding an object? Or removing an object? Or just altering a single point of a vector object? How do you redo something?
It didn't take me long to realize that there were many different types of "undo"s, and each had to perform different actions. Therefore, like everything else in this project, I needed a base class:
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
From UndoBase
, I created classes to handle objects being removed or added, having their properties changed, etc. There's a folder called Undo in the source code that you can look at to see how it was implemented; they're actually pretty small, code-wise.
Now that we could store undo/redo actions we needed to track them. The Designer
class has an internal list of UndoBase
objects and an index of where in the list you are. As you make changes, new undo objects are added to the list and the index is set to the end of the list. When you undo something, however, the index is moved backwards each time you undo. When you redo, it moves forwards. If you undo and then perform an action that causes a new undo object to be added to the list, all the old undo objects above the index are removed.
There is one special undo class called UndoBatch
. This class contains a list of undo objects and is used by the designer to group together several changes into one "undo" statement. For example, suppose you select 10 objects and move them at once. You wouldn't want to have to undo 10 times to go back to the previous state, yet all the values for those objects need to be tracked. Calling StartUndoBatch
on the Designer
class creates a new UndoBatch
and puts it at the top of the undo list. While the batch is open and new calls to AddUndo
adds the undo object to the batch instead of the internal list. Calling EndUndoBatch
closes the batch and undo processing continues normally.
The Drawing Tools
Tools, like just about every other object in Vash are derived from an abstract (i.e. MustInherit
) base class, ToolBase
. ToolBase
is a pretty empty class, consisting mostly of empty overridable methods for responding to keyboard and mouse events that the SceneSurface
redirects to it. It is up to the descendent classes to override those methods and actually respond to the events in the most appropriate way.
I personally hate having to add a button for every tool I create (especially when I don't know how many tools I'll be creating in the project), so I make use of reflection, find every subclass of ToolBase
, and dynamically add them to the ToolStripContainer
during the OnLoad
event of MainWindow
:
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
toolsToAdd.Sort(Function(x, y) CType(x, ToolBase).Index.CompareTo(CType(y, ToolBase).Index))
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
AddHandler tsb.Click, Sub(sender As Object, e2 As EventArgs)
Designer.SelectedTool = Activator.CreateInstance(tsb.Tag)
StatusInformation.Text = Designer.SelectedTool.Description
End Sub
DesignerTools.Items.Insert(toolbarPosition, tsb)
If Designer.SelectedTool Is Nothing Then Designer.SelectedTool = tool
toolbarPosition += 1
Next
Arrow Tool
The Arrow tool is the most versatile tool, as it's used not just for selecting and moving objects, but for doing different things when you double-click on objects. For example, double-clicking on a group details into that group and makes the group the new SelectedContainer
on the Designer
object. Double-clicking on a subscene makes its Scene
object the currently selected scene. Double-clicking a vector object toggles the PointEditMode
property. It's a busy tool.
Rectangle & Ellipse Tool
Rectangle and ellipse both work by tracking the rectangle the user is dragging, creating an internal GraphicsPath
, adding their shape to the path, then calling SetPath
on the new VectorObject
to recreate the object using the new path, firing off a call Refresh
on the SceneSurface
to update the display for the user during the drag.
These two tools are so identical that I should have created a base class for vector primitives and just made an override function when it came time to actually add the rectangle/ellipse to the internal GraphicsPath
object that gets built. Maybe next iteration I'll do that.
Sound Tool
The sound tool is very simple. When you click anywhere, it prompts you for a media file, and if you select one it creates an instance of the Sound
class and adds it to the selected container.
Pencil Tool
The pencil tool when through some very complex versions before I discovered that I could simply track all the points the mouse passed through while dragging, and use GraphicsPath.AddCurve
to get exactly what I wanted. If you want to hurt your brain you can look at my commented-out code to see how I tried to put together line segments and then "smooth" them out into the least amount of bezier curves. It wasn't a very good algorithm but it did a half-decent job for someone who had no idea what he was doing.
Polygon Tool
The polygon tool lets you click to add points, and then shift-click to close the figure. If I just used line segments you wouldn't be able to adjust the curviness of them, so instead of calling DrawingPath.AddLine or DrawingPath.AddPolygon to connect the previous point to the current point, I did this:
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)
Now when users draw polygons they get the straight-edged shape they were expecting, but with added feature of being able to treat each segment as a curve.
Text Tool
The text tool just creates a new instance of a Vash Text object with the default text and dumps it on the container that the mouse location. Nothing more to say about that.
Subscene Tool
The subscene tool creates an empty subscene object at 25% the size of the stage at the mouse location. I could have went 100% by default but I wanted users to see the subscene without it covering the scene they were actually editing.
Image Tool
Like the sound tool, the image tool prompts you for an external image file, then dumps it on the screen at that location.
Exporting Images
Exporting to PNG is actually really easy. You just create an in-memory bitmap the size of your stage, create a Graphics object from the image, and call Render on the current scene passing in that Graphics object. Then you [kind of] simply save your image:
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
g.Clear(Color.Transparent)
g.TranslateTransform(b.Width / 2, b.Height / 2)
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)
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
g.Dispose()
End Try
Designer.Exporting = False
Exporting Video
To export to a video file, I made use of ffmpeg. Rather than doing anything too intellectually complicated, I discovered that ffmpeg could be invoked to take a directory full of images and put them together into a video. So the export solution is actuallly quite simple:
- Create a temporary directory.
- Put the designer in exporting mode (
Designer.Exporting = True
).
- Loop through each frame in the current scene, render it to an in-memory image, and export that image as a PNG to the temporary folder in the format f0000.png (where 0000 is incremented for each frame). Maybe four digits isn't enough?
- Run the audio export algorithms, which involve using an NAudio.WaveMixerStream32 to mix all the audio files at their appropriate times. The end result is .wav file consisting of all the sounds where they're supposed to be, timeline-wise.
- Run ffmpeg with the right magic parameters to create the avi (thanks to this ffmpeg cheat sheet online, I didn't have to figure out what all the confusing parameters had to be).
- Delete the temporary folder.
- Play the video and weep at the beauty of it all.
Missing Features
Despite my attempt to make this as complete as possible, there are some features missing:
- Gradients - I really wanted to provide the ability to specify gradient fills and line colours, but I couldn't quite wrap my head around how one would Lerp from a gradient with X colour stops to a gradient with Y colour stops (or to a solid color, or radial gradient to linear gradient, etc.). I think I would have to reconsider how the entire
VashColor
object is structured and serialized/deserialized. Not to mention having to heavily alter my fancy AdvancedColorPicker
form.
- Sizing/Rotation Grips - I had trouble trying to allow the sizing grips to only stretch one side, leaving the other side anchored, and ultimately abandoned it. Thus the sizing grips stretch about the center which isn't how those sorts of things typically work.
- SVG Import - Apparently I understand enough about SVG to export my to it, I should be able to allow people to import their artwork from other editors like Inkscape. It's just about taking the time to do it.
- Javascript Export - I was seriously toying with the idea of exporting the entire animation to Javascript that, along with some supporting code, would render and play the animation in an HTML5 canvas object. That'd be pretty neat, eh?
- Line Width - While the default fill and line colours can be changed by clicking on them in the toolbar y ou can't actually change the default line width (3.0 units). You can always alter it after the fact on a per-object basis, but that's kind of sucky. The solution would likely require a custom
ToolStripContainer
control as none of the existing controls seemed appropriate.
- Configurable Options - There are some hard-coded things like the color of the selection box and sizing grips that could be configurable, and various UI states (like the splitter positions) that could be changed to be stored in user settings.
- Swatches - I don't think you can remove them. I don't remember writing that code.
- Rotation Point - When you initially create a vector object, its location acts as its rotation point; the point the object is rotated about. In most graphics programs you can adjust the rotation point. In Vash, once the rotation point is created it remains where it is, even if you adjust the points of the object so that the rotation point isn't in the true center any more.
- External Resource Management - Images and audio files are simply referenced to their locations in the file system. Most editing programs make a subdirectory relative to the project file and copy external resources into them, allowing all your references to be relative and increasing the portability of your projects. Vash doesn't do that, but it wouldn't take much to change it to work that way.
- Undo/Redo - I'm sure there's a spot or two (or more) where I failed to record undo/redo actions and thus there are things that can't be undone. Just sloppy work on my part.
- PropertyGrid Issues - When you have multiple objects selected and you alter a shared property using the PropertyGrid control, the PropertyValueChanged event doesn't contain any information about the previous values of the multiple objects (it does when only one object is selected), making undo tracking impossible.
Maybe if someone out there uses Vash and decides that it's worth enhancing I'll see what i can do about these features.
Points of Interest
The hardest part of this project was going through all the files afterward to clean up the code and comment it so that people could try and understand how the heck it all works. Man, that was exhausting. There were over 80 .vb files to go through, many with hundreds (or thousands) of lines of code! You're welcome.
History
- 2016-04-27 - Initial version 0.9 deployed to CodeProject
The End
As an animation program, Vash is definitely no Flash, and as a vector graphics editor it's certainly no Inkscape, but I still think it's pretty cool for a month's worth of work. If you end up using it for something neat, drop me a line and let me know and I'll link to it down below. If you like the sort of work I do, I'm available for contract work (*wink* *wink*). If you find a bug or think of a really neat feature that's lacking, please let me know in the comments below.
If there's an aspect of this article you feel I didn't explain very clearly, or you would like me to elaborate on some part of Vash that I haven't discussed here (and despite the length of this article, there's a lot I haven't discussed) let me know and I'll see what I can do.
Thanks for reading.
Cool Things made with Vash
- Catch - my Vash demo that I uploaded to YouTube
Did you use Vash to make something cool? Let me know and I'll link to it here.