Blog Archives

OpenTK, entering the C# 3D world

I was amazed how short the source code can be to show complex moving objects. It does take a bit of research and the OpenTK library though. There are a lot of very good YouTube tutorials out there. Not necessarily about OpenTK, but plenty about OpenGL, which is what OpenTK is using.

I asked my daughters to draw some Minecraft style avatars with a simple icon editor. They had their fun and I got something to display in 3D. I stored three icon files (Freya.ico, Merlin.ico and Steve.ico) in a subfolder called ‘Resources’.

OpenTk

Change the file properties. ‘Build Action’ should be set to ‘None’, because you don’t have to compile these files. And you don’t want to copy the files each time you run the compiler. Simply set ‘Copy to Output Directory’ to ‘Copy if newer’.

OpenTk2

The next step is to install and reference the OpenTK library (OpenTK, OpenTK.Compatibility and OpenTK.GLControl). Be aware that there is no real WPF support for OpenTK. You only host a Winform control within WPF. Therefore you should also reference “WindowsFormsIntegration”.
You can also open a pure OpenTK window. But it is impossible to add further WPF controls to the same window then. We are far away from programming games, so it is always a good option to leave the backdoor open for further WPF. You might want to add sliders to set angles and object positions.

 

OpenTk3

 

Sorry for posting less for a while. I am working like a dog – leaving home at 6am and returning around 10pm during the week.

 

<Window x:Class="OpenTkControl.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:WinF="clr-namespace:System.Windows.Forms;assembly=System.Windows.Forms"
        xmlns:OpenTK="clr-namespace:OpenTK;assembly=OpenTK.GLControl"
        Title="OpenTK Demo" Height="600" Width="800">
  <DockPanel LastChildFill="True">
    <WindowsFormsHost  x:Name="WinFormsContainer" Background="Transparent" DockPanel.Dock="Top"  >
      <OpenTK:GLControl x:Name="OpenTkControl" 
                        Paint="OpenTkControl_Paint" Dock="Fill" />
    </WindowsFormsHost>
  </DockPanel>
</Window>
using OpenTK;
using OpenTK.Graphics.OpenGL;
using System;
using System.Drawing;
using System.Windows;
using System.Windows.Threading;

namespace OpenTkControl {
  public partial class MainWindow : Window {

    private DispatcherTimer _Timer;
    private DateTime _ProgramStartTime;

    public MainWindow() {
      InitializeComponent();

      _ProgramStartTime = DateTime.Now;

      _Timer = new DispatcherTimer(DispatcherPriority.Send);
      _Timer.IsEnabled = true;
      _Timer.Interval = new TimeSpan(0, 0, 0, 0, 30);
      _Timer.Tick += OnTimer;
      _Timer.Start();
    } // constructor

    void OnTimer(object sender, EventArgs e) {
      OpenTkControl.Invalidate();
    } //

    private void OpenTkControl_Paint(object sender, System.Windows.Forms.PaintEventArgs e) {
      GLControl lControl = OpenTkControl;

      // Reset the depth and color buffer.
      // We want to render a new world. We do not want to continue with a previous rendering.
      GL.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);

      // Create a projection matrix transforming camera space to raster space. (google for "view frustum")
      // Which is like: Press the 3D world and make it flat like a pancake, so that it does fit on the 2D screen.
      //                All points between a distance of 1 and 1000 will be taken into account.
      float lAngleView = 1.2f;              // y direction (in radians)
      float lAspectRatio = 4f / 3f;         // width / height
      float lDistanceToNearClipPlane = 1f;
      float lDistanceToFarClipPlane = 1000f;
      Matrix4 lPerspective = Matrix4.CreatePerspectiveFieldOfView(lAngleView, lAspectRatio, lDistanceToNearClipPlane, lDistanceToFarClipPlane);
      GL.MatrixMode(MatrixMode.Projection); GL.LoadIdentity(); GL.LoadMatrix(ref lPerspective);

      // camera setup
      Vector3 lCameraLocation = new Vector3(100f, 10f, 0f);
      Vector3 lCameraLookingAt = new Vector3(0f, 0f, 0f);     // look at the center of the coordinate system
      Vector3 lCameraWhatIsUpside = new Vector3(0f, 1f, 0f);  // classical way to hold a camera
      Matrix4 lCamera = Matrix4.LookAt(lCameraLocation, lCameraLookingAt, lCameraWhatIsUpside);
      GL.MatrixMode(MatrixMode.Modelview); GL.LoadIdentity(); GL.LoadMatrix(ref lCamera);

      // this is the size on the screen
      GL.Viewport(0, 0, lControl.Width, lControl.Height);

      // only draw the nearest pixels and not pixels that are actually hidden by other pixels 
      GL.Enable(EnableCap.DepthTest);
      GL.DepthFunc(DepthFunction.Less);

      // set time dependent variables to generate movements
      double lTotalMillis = DateTime.Now.Subtract(_ProgramStartTime).TotalMilliseconds;
      double lTime1 = (lTotalMillis % 10000.0) / 10000.0; // between 0 and 1
      double lTime2 = (lTotalMillis % 2000.0) / 2000.0;   // between 0 and 1
      double lTimeRadians = lTime2 * 2.0 * Math.PI;
      float lJump = (float)(-20.0 + 10.0 * Math.Sin(lTimeRadians));
      float lRadius = -40f;

      // add the comet
      DrawComet(lTotalMillis);

      // render the floor
      GL.Rotate(360.0 * lTime1, 0.0, 1.0, 0.5);  // rotate around y axis and half as much around z axis
      DrawFloor();

      // render objects
      // from where we are; now rotate the objects into the opposite direction
      GL.Rotate(-lTime1 * 360.0 * 2.0, 0.0, 1.0, 0.0); DrawAvatar("Merlin", -30f, lRadius);
      GL.Rotate(60.0, 0.0, 1.0, 0.0); DrawAvatar("Freya", lJump, lRadius);
      GL.Rotate(60.0, 0.0, 1.0, 0.0); DrawAvatar("Steve", -30f, lRadius);
      GL.Rotate(60.0, 0.0, 1.0, 0.0); DrawAvatar("Merlin", lJump, lRadius);
      GL.Rotate(60.0, 0.0, 1.0, 0.0); DrawAvatar("Freya", -30f, lRadius);
      GL.Rotate(60.0, 0.0, 1.0, 0.0); DrawAvatar("Steve", lJump, lRadius);

      // render the cube in the center
      //GL.Rotate(360f * lTime2, 0f, 0f, 0f); // <= this kind of rotation lets the box bounce and change its size
      DrawCube(Color.SteelBlue, Color.DarkBlue, 0f, -25f, 0f, 8f, false);

      OpenTK.Graphics.GraphicsContext.CurrentContext.VSync = true; // caps GPU frame rate
      lControl.SwapBuffers();  // display our newly generated buffer with all objects
    } //

    private void DrawAvatar(string xName, float yShift, float zShift) {
      Icon lIcon = new Icon("Resources/" + xName + ".ico");
      Bitmap lBitmap = lIcon.ToBitmap();
      int lWidth = lBitmap.Width; float lHalfWidth = lWidth / 2f;
      int lHeight = lBitmap.Height; float lHalfHeight = lHeight;
      for (int y = 0; y < lHeight; y++) {
        for (int x = 0; x < lWidth; x++) {
          Color lColor = lBitmap.GetPixel(x, y);
          if (lColor.A != 0) DrawCube(lColor, lColor, (float)x - lHalfWidth, lHeight + yShift - (float)y, (float)zShift, 1f, true);
        }
      }
    } //

    private void DrawFloor() {
      for (int x = -100; x < 100; x += 10) {
        for (int z = -100 + (x % 10 == 0 ? 5 : 0); z < 100; z += 10) {
          DrawCube(Color.White, Color.Gray, x, -30f, z, 5f, false);
        }
      }
    } //

    private void DrawComet(double xTotalMillis) {
      xTotalMillis = (xTotalMillis % 7000.0) / 7000.0; // between 0 and 1

      GL.PushMatrix();
      GL.LoadIdentity();
      GL.Translate(xTotalMillis * 30f - 40f , 40f,  400f * xTotalMillis - 400f);
      GL.Rotate(360f * xTotalMillis * 3f, 1f, 1f, 1f);
      DrawTetrahedron(Color.Orange, Color.OrangeRed, 0f, 0f, 0f, 8f);
      GL.Rotate(180f, 1f, 0f, 0f);
      DrawTetrahedron(Color.Orange, Color.OrangeRed, 0f, 0f, 0f, 8f);
      GL.PopMatrix();
    } //

    private void DrawCube(System.Drawing.Color xColor, System.Drawing.Color xColor2, float X, float Y, float Z, float xWidth, bool xHasDarkBack) {
      float lHalfWidth = xWidth / 2f;
      float lTop = Y + lHalfWidth;
      float lBottom = Y - lHalfWidth;
      float lLeft = X - lHalfWidth;
      float lRight = X + lHalfWidth;
      float lFront = Z + lHalfWidth;
      float lRear = Z - lHalfWidth;

      GL.Begin(PrimitiveType.Quads);

      Color lColor; if (xHasDarkBack) lColor = Color.DarkGray; else lColor = xColor;
      Color lColor2; if (xHasDarkBack) lColor2 = Color.DarkGray; else lColor2 = xColor2;

      Action lPointFrontTopLeft = () => { GL.Color3(xColor); GL.Vertex3(lLeft, lTop, lFront); };
      Action lPointFrontTopRight = () => { GL.Color3(xColor2); GL.Vertex3(lRight, lTop, lFront); };
      Action lPointFrontBottomLeft = () => { GL.Color3(xColor2); GL.Vertex3(lLeft, lBottom, lFront); };
      Action lPointFrontBottomRight = () => { GL.Color3(xColor2); GL.Vertex3(lRight, lBottom, lFront); };
      Action lPointRearTopLeft = () => { GL.Color3(lColor); GL.Vertex3(lLeft, lTop, lRear); };
      Action lPointRearTopRight = () => { GL.Color3(lColor2); GL.Vertex3(lRight, lTop, lRear); };
      Action lPointRearBottomLeft = () => { GL.Color3(lColor2); GL.Vertex3(lLeft, lBottom, lRear); };
      Action lPointRearBottomRight = () => { GL.Color3(lColor2); GL.Vertex3(lRight, lBottom, lRear); };

      // front square
      lPointFrontTopLeft(); lPointFrontTopRight(); lPointFrontBottomRight(); lPointFrontBottomLeft();

      // rear square
      lPointRearTopLeft(); lPointRearTopRight(); lPointRearBottomRight(); lPointRearBottomLeft();

      // top square
      lPointFrontTopLeft(); lPointFrontTopRight(); lPointRearTopRight(); lPointRearTopLeft();

      // bottom square
      lPointFrontBottomLeft(); lPointFrontBottomRight(); lPointRearBottomRight(); lPointRearBottomLeft();

      // left square
      lPointFrontTopLeft(); lPointRearTopLeft(); lPointRearBottomLeft(); lPointFrontBottomLeft();

      // right square
      lPointFrontTopRight(); lPointRearTopRight(); lPointRearBottomRight(); lPointFrontBottomRight();

      GL.End();
    } //

    private void DrawTetrahedron(System.Drawing.Color xColor, System.Drawing.Color xColor2, float X, float Y, float Z, float xSideLength) {
      float lDistMidToVertex = (float)Math.Sqrt(6.0) / 4f * xSideLength;
      float lDistMidToFloor = (float)Math.Sqrt(6.0) / 12f * xSideLength;
      float lHeight = (float)Math.Sqrt(2.0 / 3.0) * xSideLength; // = lDistMidToVertex + lDistMidToEdge
      float lTop = Y + lDistMidToVertex;
      float lBottom = Y - lDistMidToFloor;
      float lRight = X + xSideLength / 2f;
      float lLeft = X - xSideLength / 2f;
      float lRear = Z - (float) (xSideLength * Math.Sqrt(3.0) / 3.0);
      float lFront = Z + (float)(xSideLength * Math.Sqrt(3.0) / 6.0);

      GL.Begin(PrimitiveType.Triangles);

      Action lPointTop = () => { GL.Color3(xColor); GL.Vertex3(X, lTop, Z); };
      Action lPointFrontBottomLeft = () => { GL.Color3(xColor2); GL.Vertex3(lLeft, lBottom, lFront); };
      Action lPointFrontBottomRight = () => { GL.Color3(xColor); GL.Vertex3(lRight, lBottom, lFront); };
      Action lPointRear = () => { GL.Color3(xColor2); GL.Vertex3(X, lBottom, lRear); };

      // front triangle
      lPointTop(); lPointFrontBottomLeft(); lPointFrontBottomRight();

      // left triangle
      lPointTop(); lPointFrontBottomLeft(); lPointRear();

      // right triangle
      lPointTop(); lPointFrontBottomRight(); lPointRear();

      // bottom triangle
      lPointFrontBottomLeft(); lPointFrontBottomRight(); lPointRear();

      GL.End();
    } //

  } // class
} // namespace

 

Slogan / News Ticker / Scrolling Text in WPF (Part 2)

 

This is the follow-up of my last post.

I promised to enhance the scroll behavior. Here it is. You can influence the text in various ways. When the zoom factor turns negative you can even see upside down characters. Also the drift can turn negative; if so the wave moves to the left, but its peaks drift to the right.
Weirdly enough it is a lot of fun playing with the sliders and watching the wave change. Of course, we could still create far more complex movements. This source code here is providing the full basics. The rest is up to you.

What you can influence:

  • text zoom  (negative numbers turn the text upside down)
  • scroll speed
  • wave amplitude
  • wave drift
  • wave length

I had to slightly change the way the vectors are stored to enable zooming. The y-zero-line now is in the middle of the characters and not at the top anymore. Anything above that line is negative, anything below positive. Hence the vectors can be multiplied with a zoom factor without the need to recalculate the position in the WPF control.

<Window x:Class="Ticker.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="Ticker"
        Height="250"
        Width="500"
        Loaded="Window_Loaded">
  <DockPanel x:Name="MyDockPanel"
             SizeChanged="MyImage_SizeChanged"
             LastChildFill="True">
    <Grid DockPanel.Dock="Top">
      <Grid.ColumnDefinitions>
        <ColumnDefinition Width="110"/>
        <ColumnDefinition Width="50*"/>
        <ColumnDefinition Width="110"/>
        <ColumnDefinition Width="50*"/>
      </Grid.ColumnDefinitions>
      <Grid.RowDefinitions>
        <RowDefinition Height="Auto" />
        <RowDefinition Height="Auto" />
        <RowDefinition Height="Auto" />
        <RowDefinition Height="Auto" />
        <RowDefinition Height="*" />
      </Grid.RowDefinitions>

      <Label Content="calculation speed" Grid.Row="0"
             HorizontalAlignment="Right" Padding="0,0,0,0"/>
      <TextBox Name="Info"
               Grid.Row="0" Grid.Column="1"/>

      <Label Content="zoom" Grid.Row="0" Grid.Column="2"
             HorizontalAlignment="Right" Padding="0,0,0,0"/>
      <Slider Minimum="-1.0" Maximum="1.0" Value="1.0"
              ValueChanged="Slider_Zoom_ValueChanged"
              Grid.Row="0" Grid.Column="3" />

      <Label Content="scroll speed" Grid.Row="1" Grid.Column="0"
             HorizontalAlignment="Right" Padding="0,0,0,0"/>
      <Slider Minimum="0.5" Maximum="8.0" Value="2.0"
              ValueChanged="Slider_ScrollSpeed_ValueChanged"
              Grid.Row="1" Grid.Column="1" />

      <Label Content="wave amplitude" Grid.Row="1" Grid.Column="2"
             HorizontalAlignment="Right" Padding="0,0,0,0"/>
      <Slider Minimum="0.0" Maximum="100.0" Value="50.0"
              ValueChanged="Slider_Amplitude_ValueChanged"
              Grid.Row="1" Grid.Column="3" />

      <Label Content="wave drift" Grid.Row="2" Grid.Column="0"
             HorizontalAlignment="Right" Padding="0,0,0,0"/>
      <Slider Minimum="-10.0" Maximum="10.0" Value="5.0"
              ValueChanged="Slider_Drift_ValueChanged"
              Grid.Row="2" Grid.Column="1" />

      <Label Content="wave length" Grid.Row="2" Grid.Column="2"
             HorizontalAlignment="Right" Padding="0,0,0,0"/>
      <Slider Minimum="0.0" Maximum="2.0" Value="0.5"
              ValueChanged="Slider_WaveLength_ValueChanged"
              Grid.Row="2" Grid.Column="3" />
    </Grid>
    <Image Name="MyImage"
           DockPanel.Dock="Top"
           Stretch="None"
           Width="{Binding ActualWidth, ElementName=MyDockPanel}"/>
  </DockPanel>
</Window>
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Drawing;
using System.Threading;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media.Imaging;
using System.Windows.Threading;

namespace Ticker {

  public partial class MainWindow : Window {

    private Thread _Thread;
    private TextEngine _TextEngine = new TextEngine();
    private float _WidthInPixels;
    private double _Speed = 1.0; // number of pixels to shift per iteration
    private AutoResetEvent _AutoResetEvent = new AutoResetEvent(true);
    private BitmapImage _BitmapImage = null;
    private string _ElapsedTime = string.Empty;

    public MainWindow() {
      InitializeComponent();
    } //  constructor

    private void Window_Loaded(object sender, EventArgs e) {
      DataContext = this;

      _Thread = new Thread(Loop);
      _Thread.Name = "MainLoop";
      _Thread.IsBackground = true;
      _Thread.Priority = ThreadPriority.AboveNormal;
      _Thread.Start();

      TimeSpan lInterval = new TimeSpan(0, 0, 0, 0, 50); // run each 50 ms
      EventHandler lHandler = new EventHandler(OnTime);
      DispatcherTimer lDispatcherTimer = new DispatcherTimer(lInterval, DispatcherPriority.Send, lHandler, this.Dispatcher);
    } //

    private void MyImage_SizeChanged(object sender, SizeChangedEventArgs e) {
      DockPanel lDockPanel = sender as DockPanel;
      if (lDockPanel == null) return;
      using (var lGraphics = Graphics.FromHwnd(IntPtr.Zero)) {
        _WidthInPixels = (float)(e.NewSize.Width * lGraphics.DpiX / 96.0);
      }
    } //

    public void OnTime(object XSender, EventArgs e) {
      BitmapImage lBitmapImage = _BitmapImage;
      if (lBitmapImage == null) return;

      MyImage.Source = lBitmapImage;
      Line.adjustDrift();
      Info.Text = _ElapsedTime;
      _AutoResetEvent.Set();
    } //

    private void Loop() {
      float lEnd = 0f;
      int lSectionFrom = 0;
      int lSectionTo = 0;
      Stopwatch lStopwatch = new Stopwatch();

      while (true) {
        _AutoResetEvent.WaitOne();
        lStopwatch.Restart();

        float lWidthInPixel = _WidthInPixels; // copy the value to avoid changes during the calculation
        if (lWidthInPixel <= 0.0) continue;
        List<Line> lSection = _TextEngine.getVectorSection(ref lSectionFrom, ref lSectionTo, lEnd, lWidthInPixel);

        // This value determines the speed.
        // Even numbers give better results due to the rounding error nature of bitmaps.
        // Odd numbers create jitter. Luckily humans have bad eyes, they cannot perceive it.
        lEnd += (float)_Speed;

        if (lSection == null) {
          // end reached, reset text
          lSectionFrom = 0;
          lSectionTo = 0;
          lEnd = 0f;
        }
        else {
          Bitmap lBitmap = _TextEngine.VectorsToBitmap(lSection, lWidthInPixel);
          _BitmapImage = _TextEngine.BitmapToImageSource(lBitmap);
        }

        _ElapsedTime = lStopwatch.ElapsedMilliseconds.ToString("#,##0");
        //lStopwatch.Stop();
      }
    } //

    private void Slider_ScrollSpeed_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e) { _Speed = e.NewValue; }
    private void Slider_Amplitude_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e) { Line.Amplitude = (float)e.NewValue; }
    private void Slider_Drift_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e) { Line.Drift = (float)e.NewValue; }
    private void Slider_WaveLength_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e) { Line.WaveLength = (float)e.NewValue; }
    private void Slider_Zoom_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e) { Line.Zoom = (float)e.NewValue; }

  } // class
} // namespace
using System;
using System.Collections.Generic;
using System.Drawing;
using System.IO;
using System.Windows.Media.Imaging;

namespace Ticker {
  public class TextEngine {

    private const float cFontHeight = 40f;
    private const string cText = @"Die Gedanken sind frei, wer kann sie erraten, sie fliegen vorbei wie nächtliche Schatten. Kein Mensch kann sie wissen, kein Jäger erschießen mit Pulver und Blei: Die Gedanken sind frei! Ich denke was ich will und was mich beglücket, doch alles in der Still', und wie es sich schicket. Mein Wunsch und Begehren kann niemand verwehren, es bleibet dabei: Die Gedanken sind frei! Und sperrt man mich ein im finsteren Kerker, das alles sind rein vergebliche Werke. Denn meine Gedanken zerreißen die Schranken und Mauern entzwei: Die Gedanken sind frei! Drum will ich auf immer den Sorgen entsagen und will mich auch nimmer mit Grillen mehr plagen. Man kann ja im Herzen stets lachen und scherzen und denken dabei: Die Gedanken sind frei! Ich liebe den Wein, mein Mädchen vor allen, sie tut mir allein am besten gefallen. Ich sitz nicht alleine bei meinem Glas Weine, mein Mädchen dabei: Die Gedanken sind frei!";
    private List<Line> _TextAsVectorChain = new List<Line>();
    private Font _Font = new Font("Arial", cFontHeight, System.Drawing.FontStyle.Bold, GraphicsUnit.Pixel);

    public TextEngine() {
      // convert the entire text to vectors
      float lPosition = 0;
      Dictionary<char, List<Line>> lVectorCache = new Dictionary<char, List<Line>>();
      char[] lChars = cText.ToCharArray();

      foreach (char lChar in lChars) {
        if (lChar == ' ') lPosition += 10; // distance for an empty space character
        else {
          List<Line> lOneCharVectors;

          if (!lVectorCache.TryGetValue(lChar, out lOneCharVectors)) {
            Bitmap lBitmap = CharToBitmap(lChar);
            lOneCharVectors = BitmapToVectors(lBitmap);
            lVectorCache.Add(lChar, lOneCharVectors);
          }

          float lNewPosition = lPosition;
          foreach (Line lLine in lOneCharVectors) {
            Line lClone = lLine.Clone();
            lClone.X += lPosition;
            lNewPosition = lClone.X;
            _TextAsVectorChain.Add(lClone);
          }
          lPosition = lNewPosition + 4; // 4 == space between two characters
        }

      }
    } // constructor

    // Convert a bitmap to an ImageSource.
    // We can then display the result in the WPF Image element.
    public BitmapImage BitmapToImageSource(Bitmap xBitmap) {
      using (MemoryStream lMemoryStream = new MemoryStream()) {
        xBitmap.Save(lMemoryStream, System.Drawing.Imaging.ImageFormat.Bmp);
        lMemoryStream.Position = 0;
        BitmapImage lBitmapImage = new BitmapImage();
        lBitmapImage.BeginInit();
        lBitmapImage.StreamSource = lMemoryStream;
        lBitmapImage.CacheOption = BitmapCacheOption.OnLoad;
        lBitmapImage.EndInit();
        lBitmapImage.Freeze();
        return lBitmapImage;
      }
    } //

    // draw a single character into a bitmap
    private Bitmap _Bitmap = null;
    public Bitmap CharToBitmap(char xChar) {
      _Bitmap = new Bitmap((int)(cFontHeight * 1.25f), (int)(cFontHeight * 1.25f));
      using (Graphics lGraphics = Graphics.FromImage(_Bitmap)) {
        lGraphics.Clear(Color.White);
        lGraphics.DrawString(xChar.ToString(), _Font, Brushes.Black, 0f, 0f);
      }
      return _Bitmap;
    } //

    // Replicate the characters now by reading the vectors and drawing lines.
    Pen lPen = new Pen(Color.Black, 2f);
    public Bitmap VectorsToBitmap(List<Line> xLines, float xBitmapWidth) {
      int lHeight = (int)cFontHeight + (int)Math.Abs(Line.Amplitude);
      Bitmap lBitmap = new Bitmap((int)xBitmapWidth, lHeight);
      lHeight /= 2;   // half height, rounded down
      using (Graphics lGraphics = Graphics.FromImage(lBitmap)) {
        lGraphics.Clear(Color.White);
        foreach (Line lLine in xLines) {
          lGraphics.DrawLine(lPen, lLine.X, lLine.Y1 + lHeight, lLine.X, lLine.Y2 + lHeight);
        }
      }
      return lBitmap;
    } //

    // Convert a single character to vectors.
    private List<Line> BitmapToVectors(Bitmap xBitmap) {
      int lXCoordinateOfFirstPixel = -1;
      List<Line> lList = new List<Line>();

      for (int x = 0, lWidth = xBitmap.Width; x < lWidth; x++) {
        Line lVector = null;
        for (int y = 0, lHeight = xBitmap.Height; y < lHeight; y++) {
          Color lColor = xBitmap.GetPixel(x, y);
          bool lIsWhite = lColor.B == 255;
          if (lIsWhite) {
            if (lVector != null) {
              lList.Add(lVector);
              lVector = null;
            }
          }
          else {
            int lHalfHeight = xBitmap.Height / 2;
            if (lVector == null) {
              if (lXCoordinateOfFirstPixel < 0) lXCoordinateOfFirstPixel = x;  // to always start at zero for our vectors
              lVector = new Line { X = x - lXCoordinateOfFirstPixel, Y1 = y - lHalfHeight, Y2 = y - lHalfHeight };
            }
            else lVector.Y2 = y - lHalfHeight;
          }
        }
      }

      return lList;
    } //

    // The text was converted to vectors.
    // Now we cut out the sequence we need for the display.
    internal List<Line> getVectorSection(ref int xSectionFrom, ref int xSectionTo, float xEnd, float xWidth) {
      int lCount = _TextAsVectorChain.Count;
      float lStart = xEnd - xWidth;

      // find the right section
      do {
        xSectionTo++;
        if (xSectionTo >= lCount) { xSectionTo = lCount - 1; break; }
        if (xEnd < _TextAsVectorChain[xSectionTo].X) break;
      } while (true);

      do {
        if (lStart < 0) break; // to allow empty spaces at the beginning of the slogan
        if (xSectionFrom >= lCount) return null;
        if (lStart < _TextAsVectorChain[xSectionFrom].X) break;
        xSectionFrom++;
      } while (true);

      // clone that section
      List<Line> lList = new List<Line>();
      for (int x = xSectionFrom; x <= xSectionTo; x++) {
        Line lClone = _TextAsVectorChain[x].Clone();
        lClone.X -= lStart; // adjust the X-axis
        lClone.RecalcYShift();
        lList.Add(lClone);
      }

      return lList;
    } //

  } // class
} // namespace
using System;

namespace Ticker {
  public class Line {
    private float _Y1, _Y1Shift;
    private float _Y2, _Y2Shift;
    private static float _XDrift = 0f;

    public float X { get; set; }
    public static float Amplitude { get; set; }    // This is the additional height needed for the vertical swing
    public static float Drift { get; set; }        // How fast does the wave crest/trough move?
    public static float WaveLength { get; set; }   // How wide is the wave?
    public static float Zoom { get; set; }         // Character zoom factor around the horizontal middle.

    public static void adjustDrift() { _XDrift += Drift; }

    public float Y1 {
      get { return _Y1 * Zoom + _Y1Shift; }
      set { _Y1 = value; }
    } //

    public float Y2 {
      get { return _Y2 * Zoom + _Y2Shift; }
      set { _Y2 = value; }
    } //

    public void RecalcYShift() {
      double d = (double)(WaveLength * (X + _XDrift));
      d = Math.IEEERemainder(d / 50.0, 1.99999999999);
      float lAngle = (float)Math.Sin(Math.PI * d);          // 0.0 <= d < 2.0  therefore -1.0 <= lAngle <= 1.0
      float lShift = Amplitude / 2.0f * lAngle;
      _Y1Shift = lShift;
      _Y2Shift = lShift;
    } //

    public Line Clone() {
      Line lLine = new Line();
      lLine.X = X;
      lLine.Y1 = _Y1;
      lLine.Y2 = _Y2;
      return lLine;
    } //

  } // class
} // namespace

Slogan / News Ticker / Scrolling Text in WPF

Ticker

Oh, I used to write so called intros with scrolling text slogans at the bottom on the Commodore Amiga. The hardware was pretty much unalterable and you could easily work with the Blitter and Copper. The vertical blank beam on the monitor was my timer. These days are over. No more Assembler. And who cares about high and low byte these days?

I have to admit that using C# in combination with WPF is not the best approach for moving text around. You should directly talk to graphic cards for such. Anyway, it started with a short benchmark test and ended up with this example program. If you program properly, then you can make WPF do the job without any flickering.

You might wonder why my approach is quite indirect this time. I am not simply rendering text to the screen and then change its position. This program is slightly more complex. The reason is that I am planning a follow-up with more complex movements soon. I am planning to show you how to scroll text along the screen in a sinus curve while the curve itself behaves like a wave. Maybe I will add some topsy-turvy stuff as well. Hehe, it is not written yet.

This post is providing the basics to get prepared for the next step. Today, the text simply moves from the right to the left. And no worries about the end of the text. It is dealt with properly.
You can change the speed with the slider while the text field returns the calculation time in milliseconds. You can see that we still have some timing leeway. There are no issues with our frequency, which is 20 updates per second. The screen itself obviously updates more often. Its frequency has little to do with the one for showing new BitmapImages. Our thread loop takes less than 20 milliseconds.

How does the program work?

  • A loop is running on an independent thread. This loop executes each time right after our WPF update. This well chosen moment avoids timing conflicts efficiently. The next BitmapImage is ready to be shown before the next WPF GUI update.
  • At the beginning of the program we convert the entire text to vectors. The orientation of all vectors is vertical. Each x-value can consist of several vertical vectors. For instance the character “E” has 1 or 3 vertical vectors. The first vector is a straight vertical black line from the top to the bottom. When you are in the middle of the character, then you have three black and two white areas. We only convert the black parts. Imagine a vertical cut right through the middle from the top to the bottom.
  • The conversion from “character to bitmap to vector” is NOT conducted in real-time. This is done during the program initialization as mentioned above. Only then we can run at full steam afterwards.
  • We only change the precalculated vectors and render the bitmap in real-time – nothing else.
  • These vectors are then drawn to bitmaps. These bitmaps are not dependent on the Dispatcher thread. We are still running at maximum speed. Before we can change the source for the Image control, we have to convert the Bitmap class to an ImageSource.
  • This ImageSource is then assigned to the Image control during a DispatcherTimer event.

Once again the program was kept to a minimum length. Maybe I should have added some more classes. I hope I found the right length for this example program in the end.

This code was not straight forward. There had to be a learning curve … as usual. First, I tried some direct WPF binding. The timing was nearly out of control. So I gave up after a while. But I had some nice blog ideas in the meantime. We should enter the 3D world at some point, don’t you think so?
The 3D approach would most likely the best approach for our scrolling text as well. I expect the efficiency to be extreme. There are so many games with millions of mesh triangles rendered within milliseconds – this cannot be bad stuff. We could even use 3D features for showing 2D text.

This will surely require some profound research on my side. Don’t expect anything anytime soon. I am definitely not omniscient.

<Window x:Class="Ticker.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="Ticker" 
        Height="130"
        Width="400"
        Loaded="Window_Loaded">
  <StackPanel x:Name="MyStackPanel"
              SizeChanged="MyImage_SizeChanged">
    <DockPanel LastChildFill="True">
      <Slider Width="300"  DockPanel.Dock="Right" 
              Minimum="0.5" Maximum="8.0"
              Value="2.0"
              ValueChanged="Slider_ValueChanged" />
      <TextBox Name="Info"  DockPanel.Dock="Right"/>
    </DockPanel>
    <Image Name="MyImage" 
           Stretch="None"
           Height="60" 
           Width="{Binding ActualWidth, ElementName=MyStackPanel}"/>
  </StackPanel>
</Window>
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Drawing;
using System.Threading;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media.Imaging;
using System.Windows.Threading;

namespace Ticker {

  public partial class MainWindow : Window {

    private Thread _Thread;
    private TextEngine _TextEngine = new TextEngine();
    private float _WidthInPixels;
    private double _Speed = 1.0; // number of pixels to shift per iteration
    private AutoResetEvent _AutoResetEvent = new AutoResetEvent(true);
    private BitmapImage _BitmapImage = null;
    private string _ElapsedTime = string.Empty;

    public MainWindow() {
      InitializeComponent();
    } //  constructor

    private void Window_Loaded(object sender, EventArgs e) {
      DataContext = this;

      _Thread = new Thread(Loop);
      _Thread.Name = "MainLoop";
      _Thread.IsBackground = true;
      _Thread.Priority = ThreadPriority.AboveNormal;
      _Thread.Start();

      TimeSpan lInterval = new TimeSpan(0, 0, 0, 0, 50); // run each 50 ms
      EventHandler lHandler = new EventHandler(OnTime);
      DispatcherTimer lDispatcherTimer = new DispatcherTimer(lInterval, DispatcherPriority.Send, lHandler, this.Dispatcher);
    } //

    private void MyImage_SizeChanged(object sender, SizeChangedEventArgs e) {
      StackPanel lStackPanel = sender as StackPanel;
      if (lStackPanel == null) return;
      using (var lGraphics = Graphics.FromHwnd(IntPtr.Zero)) {
        _WidthInPixels = (float)(e.NewSize.Width * lGraphics.DpiX / 96.0);
      }
    } //

    public void OnTime(object XSender, EventArgs e) {
      BitmapImage lBitmapImage = _BitmapImage;
      if (lBitmapImage == null) return;

      MyImage.Source = lBitmapImage;
      Info.Text = _ElapsedTime;
      _AutoResetEvent.Set();
    } //

    private void Loop() {
      float lEnd = 0f;
      int lSectionFrom = 0;
      int lSectionTo = 0;
      Stopwatch lStopwatch = new Stopwatch();

      while (true) {
        _AutoResetEvent.WaitOne();
        lStopwatch.Restart();

        float lWidthInPixel = _WidthInPixels; // copy the value to avoid changes during the calculation
        if (lWidthInPixel <= 0.0) continue;
        List<Line> lSection = _TextEngine.getVectorSection(ref lSectionFrom, ref lSectionTo, lEnd, lWidthInPixel);

        // This value determines the speed. 
        // Even numbers give better results due to the rounding error nature of bitmaps. 
        // Odd numbers create jitter. Luckily humans have bad eyes, they cannot perceive it.
        lEnd += (float)_Speed;

        if (lSection == null) {
          // end reached, reset text
          lSectionFrom = 0;
          lSectionTo = 0;
          lEnd = 0f;
        }
        else {
          Bitmap lBitmap = _TextEngine.VectorsToBitmap(lSection, lWidthInPixel);
          _BitmapImage = _TextEngine.BitmapToImageSource(lBitmap);
        }

        _ElapsedTime = lStopwatch.ElapsedMilliseconds.ToString("#,##0");
        lStopwatch.Stop();
      }
    } //

    private void Slider_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e) {
      _Speed = e.NewValue;
    } //

  } // class
} // namespace
using System.Collections.Generic;
using System.Drawing;
using System.IO;
using System.Windows.Media.Imaging;

namespace Ticker {
  public class TextEngine {

    private const string cText = @"Die Gedanken sind frei, wer kann sie erraten, sie fliegen vorbei wie nächtliche Schatten. Kein Mensch kann sie wissen, kein Jäger erschießen mit Pulver und Blei: Die Gedanken sind frei! Ich denke was ich will und was mich beglücket, doch alles in der Still', und wie es sich schicket. Mein Wunsch und Begehren kann niemand verwehren, es bleibet dabei: Die Gedanken sind frei! Und sperrt man mich ein im finsteren Kerker, das alles sind rein vergebliche Werke. Denn meine Gedanken zerreißen die Schranken und Mauern entzwei: Die Gedanken sind frei! Drum will ich auf immer den Sorgen entsagen und will mich auch nimmer mit Grillen mehr plagen. Man kann ja im Herzen stets lachen und scherzen und denken dabei: Die Gedanken sind frei! Ich liebe den Wein, mein Mädchen vor allen, sie tut mir allein am besten gefallen. Ich sitz nicht alleine bei meinem Glas Weine, mein Mädchen dabei: Die Gedanken sind frei!";
    private List<Line> _TextAsVectorChain = new List<Line>();

    public TextEngine() {
      // convert the entire text to vectors
      float lPosition = 0;
      Dictionary<char, List<Line>> lVectorCache = new Dictionary<char, List<Line>>();
      char[] lChars = cText.ToCharArray();

      foreach (char lChar in lChars) {
        if (lChar == ' ') lPosition += 10; // distance for an empty space character
        else {
          List<Line> lOneCharVectors;

          if (!lVectorCache.TryGetValue(lChar, out lOneCharVectors)) {
            Bitmap lBitmap = CharToBitmap(lChar);
            lOneCharVectors = BitmapToVectors(lBitmap);
            lVectorCache.Add(lChar, lOneCharVectors);
          }

          float lNewPosition = lPosition;
          foreach (Line lLine in lOneCharVectors) {
            Line lClone = lLine.Clone();
            lClone.X += lPosition;
            lNewPosition = lClone.X;
            _TextAsVectorChain.Add(lClone);
          }
          lPosition = lNewPosition + 4; // 4 == space between two characters
        }

      }
    } // constructor


    // Convert a bitmap to an ImageSource.
    // We can then display the result in the WPF Image element.
    public BitmapImage BitmapToImageSource(Bitmap xBitmap) {
      using (MemoryStream lMemoryStream = new MemoryStream()) {
        xBitmap.Save(lMemoryStream, System.Drawing.Imaging.ImageFormat.Bmp);
        lMemoryStream.Position = 0;
        BitmapImage lBitmapImage = new BitmapImage();
        lBitmapImage.BeginInit();
        lBitmapImage.StreamSource = lMemoryStream;
        lBitmapImage.CacheOption = BitmapCacheOption.OnLoad;
        lBitmapImage.EndInit();
        lBitmapImage.Freeze();
        return lBitmapImage;
      }
    } //

    // draw a single character into a bitmap
    private Font _Font = null;
    private Bitmap _Bitmap = null;
    public Bitmap CharToBitmap(char xChar) {
      if (_Font == null) {
        _Font = new Font("Arial", 40.0f, System.Drawing.FontStyle.Bold, GraphicsUnit.Pixel);
        _Bitmap = new Bitmap(60, 70);
      }
      using (Graphics lGraphics = Graphics.FromImage(_Bitmap)) {
        lGraphics.Clear(Color.White);
        lGraphics.DrawString(xChar.ToString(), _Font, Brushes.Black, 0f, 0f);
      }
      return _Bitmap;
    } //

    // Replicate the characters now by reading the vectors and drawing lines.
    Pen lPen = new Pen(Color.Black, 2f);
    public Bitmap VectorsToBitmap(List<Line> xLines, float xBitmapWidth) {
      if (_Font == null) { _Font = new Font("Arial", 40.0f, System.Drawing.FontStyle.Bold, GraphicsUnit.Pixel); }
      Bitmap lBitmap = new Bitmap((int)xBitmapWidth, 60);
      using (Graphics lGraphics = Graphics.FromImage(lBitmap)) {
        lGraphics.Clear(Color.White);
        foreach (Line lLine in xLines) {
          lGraphics.DrawLine(lPen, lLine.X, lLine.Y1, lLine.X, lLine.Y2);
        }
      }
      return lBitmap;
    } //

    // Convert a single character to vectors.
    private List<Line> BitmapToVectors(Bitmap xBitmap) {
      int lXCoordinateOfFirstPixel = -1;
      List<Line> lList = new List<Line>();

      for (int x = 0, lWidth = xBitmap.Width; x < lWidth; x++) {
        Line lVector = null;
        for (int y = 0, lHeight = xBitmap.Height; y < lHeight; y++) {
          Color lColor = xBitmap.GetPixel(x, y);
          bool lIsWhite = lColor.B == 255;
          if (lIsWhite) {
            if (lVector != null) {
              lList.Add(lVector);
              lVector = null;
            }
          }
          else {
            if (lVector == null) {
              if (lXCoordinateOfFirstPixel < 0) lXCoordinateOfFirstPixel = x;  // to always start at zero for our vectors
              lVector = new Line { X = x - lXCoordinateOfFirstPixel, Y1 = y, Y2 = y };
            }
            else lVector.Y2 = y;
          }
        }
      }

      return lList;
    } //


    // The text was converted to vectors.
    // Now we cut out the sequence we need for the display.
    internal List<Line> getVectorSection(ref int xSectionFrom, ref int xSectionTo, float xEnd, float xWidth) {
      int lCount = _TextAsVectorChain.Count;
      float lStart = xEnd - xWidth;

      // find the right section
      do {
        xSectionTo++;
        if (xSectionTo >= lCount) { xSectionTo = lCount - 1; break; }
        if (xEnd < _TextAsVectorChain[xSectionTo].X) break;
      } while (true);

      do {
        if (lStart < 0) break; // to allow empty spaces at the beginning of the slogan
        if (xSectionFrom >= lCount) return null;
        if (lStart < _TextAsVectorChain[xSectionFrom].X) break;
        xSectionFrom++;
      } while (true);


      // clone that section
      List<Line> lList = new List<Line>();
      for (int x = xSectionFrom; x <= xSectionTo; x++) {
        Line lClone = _TextAsVectorChain[x].Clone();
        lClone.X -= lStart; // adjust the X-axis
        lList.Add(lClone);
      }

      return lList;
    } //

  } // class
} // namespace
namespace Ticker {
  public class Line {
    public float X { get; set; }
    public float Y1 { get; set; }
    public float Y2 { get; set; }

    public Line Clone() {
      Line lLine = new Line();
      lLine.X = X;
      lLine.Y1 = Y1;
      lLine.Y2 = Y2;
      return lLine;
    } //

  } // class
} // namespace