Click here to Skip to main content
15,860,859 members
Articles / Multimedia / GDI+

Vash - A GDI+ Flash-Like Vector Animation Program

Rate me:
Please Sign up or sign in to vote.
5.00/5 (28 votes)
27 Apr 2016CPOL46 min read 37.6K   1.5K   31   14
There are those who think I am crazy, and this just might be the proof.

Download Vash0.9.zip - source code and binaries - DOES NOT INCLUDE FFMPEG!

Image 1
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:

Image 2

  • 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:

VB.NET
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

In derived classes, you would make use of the OnPropertyChanged method like this:

VB.NET
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:

VB.NET
''' <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

and

VB.NET
''' <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

These two functions allow me to write code that would normally look like this:

VB.NET
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:

VB.NET
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 VectorObjects 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:

VB.NET
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:

VB.NET
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:

Image 3

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):

Image 4

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:

Image 5

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:

VB.NET
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

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:

VB.NET
'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

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:

Image 6

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:

VBScript
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

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:

VBScript
' 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
VB.NET
' 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

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.

VB.NET
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

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:

VBScript
''' <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

Then when it's time to render, we only need a few lines of code:

VBScript
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 Rendering

Image 7

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:

Image 8

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:

Image 9

  • 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:

VB.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

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:

VBScript
'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

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

Image 10

 

Image 11One 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:

VB.NET
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

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:

Image 12

The DOM Tree Control

Image 13

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

Image 14

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:

VB.NET
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

Image 15

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:

VB.NET
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:

VB.NET
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.

VB.NET
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:

VB.NET
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:

VBScript
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:

VB.NET
'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

Finally all that's left is to return a new instance of TimelineHitTestResults with all the values that we've set:

VBScript
    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.

Image 16

VB.NET
Private WithEvents Renamer As New TextBox 'The textbox used when renaming layers

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:

VB.NET
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:

VB.NET
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

VB.NET
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!

VB.NET
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

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:

Image 17

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.

VB.NET
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)

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:

VB.NET
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

Image 18

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:

VB.NET
'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

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:

VB.NET
'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)

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.

Image 19

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:

VB.NET
'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

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.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Software Developer (Senior) digifi inc.
Canada Canada
Clayton Rumley is web developer for hire from Winnipeg, Manitoba, Canada.

Comments and Discussions

 
GeneralMy vote of 5 Pin
abdellamk10-Jul-21 12:41
abdellamk10-Jul-21 12:41 
GeneralMy vote of 5! and need a little help Pin
A Mannan10-Feb-21 6:04
A Mannan10-Feb-21 6:04 
QuestionHow can I use the result? Pin
my_mimuel13-Sep-17 20:38
my_mimuel13-Sep-17 20:38 
AnswerRe: How can I use the result? Pin
Сергей Челноков8-Oct-17 23:49
Сергей Челноков8-Oct-17 23:49 
AnswerRe: How can I use the result? Pin
Clayton Rumley24-Oct-17 17:56
Clayton Rumley24-Oct-17 17:56 
QuestionWhere to get ffmpeg.exe? Pin
Robin H26-Jun-17 13:56
Robin H26-Jun-17 13:56 
AnswerRe: Where to get ffmpeg.exe? Pin
Clayton Rumley8-Jul-17 5:15
Clayton Rumley8-Jul-17 5:15 
PraiseRe: Where to get ffmpeg.exe? Pin
Robin H8-Jul-17 13:30
Robin H8-Jul-17 13:30 
QuestionMake this a UserControl Pin
DaveThomas201121-May-17 2:47
DaveThomas201121-May-17 2:47 
AnswerRe: Make this a UserControl Pin
Clayton Rumley8-Jul-17 5:14
Clayton Rumley8-Jul-17 5:14 
...and then add support for mouse & keyboard events, add a programmatic API to control the video and respond to events....and you've got Flash :P

It was something I considered, but in this day and age I figured everyone is more interested in publishing to YouTube than being able to reuse it in a Windows application.

It is a neat idea, though.

Thanks for the feedback!
QuestionYou 're right Pin
LightTempler25-Apr-17 10:01
LightTempler25-Apr-17 10:01 
AnswerRe: You 're right Pin
Clayton Rumley8-Jul-17 5:11
Clayton Rumley8-Jul-17 5:11 
GeneralSeriously... no FFMPEG !??? it's disappointing Pin
r3dqu33n27-Apr-16 4:33
r3dqu33n27-Apr-16 4:33 
GeneralRe: Seriously... no FFMPEG !??? it's disappointing Pin
Clayton Rumley27-Apr-16 4:35
Clayton Rumley27-Apr-16 4:35 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.