Saturday, December 4, 2010

Old School Silverlight Effects

This weekend I was working a little bit on my personal web site (rewriting it again, to use some better themes and my Jounce framework) and decided it would be fun to tinker with some old school effects. I decided to focus on two specific things: a transparent three-dimensional cube, and a plasma effect.

You can see the results here (mouse over the cube to control the spin rate and direction):


So how's it done? Of course, full source code is available.

First, I knew I could handle the cube using plane projections. It turns out that this project had a decent example using images. I simply refactored it to use filled rectangles instead.

The XAML defines the six sides of the cube:

<UserControl x:Class="OldSkool.C64.Cube"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    mc:Ignorable="d"
    d:DesignHeight="300" d:DesignWidth="400">       
        <Grid x:Name="LayoutRoot" Height="250" Width="490">
        <Grid.Resources>
            <Style TargetType="Rectangle">
                <Setter Property="Margin" Value="170,50"/>
                <Setter Property="Height" Value="150"/>
                <Setter Property="Width" Value="150"/>
                <Setter Property="Opacity" Value="0.5"/>
            </Style>
        </Grid.Resources>
            <Rectangle Fill="Red">
                <Rectangle.Projection>
                    <PlaneProjection x:Name="Rectangle1Projection" CenterOfRotationZ="-75" RotationX="-180"/>
                </Rectangle.Projection>
            </Rectangle>
        <Rectangle Fill="Green">
            <Rectangle.Projection>
                <PlaneProjection x:Name="Rectangle2Projection" CenterOfRotationZ="-75" RotationX="-90"/>
            </Rectangle.Projection>
        </Rectangle><!-- etc. -->

Four sides are defined this way, then two sides must not only be projected, but also rotated to keep the sides of the square flush:

<!-- ...continued -->
        <Grid Margin="170,50">
            <Grid.Projection>
                <PlaneProjection x:Name="Rectangle5Projection" CenterOfRotationZ="-75" RotationY="-90"/>
            </Grid.Projection>
            <Rectangle Margin="0" Fill="Yellow" RenderTransformOrigin="0.5,0.5">
                <Rectangle.RenderTransform>
                    <TransformGroup>
                        <ScaleTransform/>
                        <SkewTransform/>
                        <RotateTransform x:Name="Rectangle5Rotation" Angle="0"/>
                        <TranslateTransform/>
                    </TransformGroup>
                </Rectangle.RenderTransform>
            </Rectangle>
        </Grid><!-- etc. -->
    </Grid>  
</UserControl>

Now in the code behind it gets more interesting.

public partial class Cube
{
    private Point _pt;
        
    public Cube()
    {
        InitializeComponent();
        Loaded += Cube_Loaded;
    }

    void Cube_Loaded(object sender, RoutedEventArgs e)
    {
        Loaded -= Cube_Loaded;
        if (!DesignerProperties.IsInDesignTool)
        {
            LayoutRoot.MouseMove += LayoutRoot_MouseMove;
        }
        CompositionTarget.Rendering += CompositionTarget_Rendering;
        
    }
        
    void CompositionTarget_Rendering(object sender, EventArgs e)
    {        
        Rectangle1Projection.RotationY += ((_pt.X - (LayoutRoot.ActualWidth / 2)) / LayoutRoot.ActualWidth) * 10;
        Rectangle2Projection.RotationY += ((_pt.X - (LayoutRoot.ActualWidth / 2)) / LayoutRoot.ActualWidth) * 10;
        Rectangle3Projection.RotationY += ((_pt.X - (LayoutRoot.ActualWidth / 2)) / LayoutRoot.ActualWidth) * 10;
        Rectangle4Projection.RotationY += ((_pt.X - (LayoutRoot.ActualWidth / 2)) / LayoutRoot.ActualWidth) * 10;
        Rectangle5Projection.RotationY += ((_pt.X - (LayoutRoot.ActualWidth / 2)) / LayoutRoot.ActualWidth) * 10;
        Rectangle6Projection.RotationY += ((_pt.X - (LayoutRoot.ActualWidth / 2)) / LayoutRoot.ActualWidth) * 10;
        Rectangle1Projection.RotationX += ((_pt.Y - (LayoutRoot.ActualHeight / 2)) / LayoutRoot.ActualHeight) * 10;
        Rectangle2Projection.RotationX += ((_pt.Y - (LayoutRoot.ActualHeight / 2)) / LayoutRoot.ActualHeight) * 10;
        Rectangle3Projection.RotationX += ((_pt.Y - (LayoutRoot.ActualHeight / 2)) / LayoutRoot.ActualHeight) * 10;
        Rectangle4Projection.RotationX += ((_pt.Y - (LayoutRoot.ActualHeight / 2)) / LayoutRoot.ActualHeight) * 10;
        Rectangle5Rotation.Angle -= ((_pt.Y - (LayoutRoot.ActualHeight / 2)) / LayoutRoot.ActualHeight) * 10;
        Rectangle6Rotation.Angle += ((_pt.Y - (LayoutRoot.ActualHeight / 2)) / LayoutRoot.ActualHeight) * 10;            

        if (DesignerProperties.IsInDesignTool)
        {
            CompositionTarget.Rendering -= CompositionTarget_Rendering;
        }
    }

    void LayoutRoot_MouseMove(object sender, MouseEventArgs e)
    {
        _pt = e.GetPosition(LayoutRoot);
    }        
}

Once it is loaded, we start to plot the mouse to help control the cube. In addition, we latch into the Silverlight rendering cycle. Normally for controlled animations we'd use something like a DispatcherTimer to be consistent across platforms, as this will run faster or slower on your machine based on your frame rate. Once we pop into a frame, it's fairly simple: we spin the cube slightly in a vector towards the mouse pointer.

That's all there is too it. It's not true 3D, but by using a trick that lets us project the sides around a common point we give you the pretty convincing illusion of a three dimensional cube (if you have a good eye, you'll spot some iterations that warp the perspective slightly due to the way it's done).

So the cube is great but I wanted something more funky to put it on. Let's go to plasma. Plasma is an effect used by creating a "frequency" of noise combined with a sine function. This, when plotted, makes for some interesting color cycling.

Instead of trying to throw it all together from scratch, I grabbed the open source Demo Effects library that is written in C++ and converted it over. First, we hold a few tables such as our palette, our sine function, and offsets into the function to cycle it:

private const int SCREEN_HEIGHT = 200;
private const int SCREEN_WIDTH = 320;

private ushort _pos1, _pos3, _tpos1, _tpos2, _tpos3, _tpos4;
private readonly int[] _aSin = new int[512];
private readonly Color[] _palette = new Color[256];

Notice I'm rendering to a very small buffer to preserve some CPU cycles. We'll put it in an image that stretches to fill and that's how it ends up taking the entire window.

Here's the sine table. Tweaking the variables will create slightly different plasma effects:

private void _CreateSineTable()
{
    for (var i = 0; i < 512; i++)
    {
        var rad = (i * 0.703125) * 0.0174532;         
        _aSin[i] = (int)(Math.Sin(rad) * 1024);     }
}

A simple algorithm to generate a "hot" palette:

private void _CreatePalette()
{
    for (var i = 0; i < 64; ++i)
    {                
        var r = i << 2;
        var g = 255 - ((i << 2) + 1);
        _palette[i] = Color.FromArgb(255, (byte)r, (byte)g, 0);
        g = (i << 2) + 1;
        _palette[i + 64] = Color.FromArgb(255, 255, (byte)g, 0);
        r = 255 - ((i << 2) + 1);
        g = 255 - ((i << 2) + 1);
        _palette[i + 128] = Color.FromArgb(255, (byte)r, (byte)g, 0);
        g = (i << 2) + 1;
        _palette[i + 192] = Color.FromArgb(255, 0, (byte) g, 0);
    } 
}

Then, every frame, I process the iterations, look up my palette value and write it out for each pixel, and finally update the image source:

void CompositionTarget_Rendering(object sender, EventArgs e)
{     
    var final = new WriteableBitmap(SCREEN_WIDTH, SCREEN_HEIGHT);

    _tpos4 = 0;
    _tpos3 = _pos3;
            
    for (var i = 0; i < SCREEN_HEIGHT; ++i)
    {
        _tpos1 = (ushort)(_pos1 + 5);
        _tpos2 = 3;

        _tpos3 &= 511;
        _tpos4 &= 511;

        for (var j = 0; j < SCREEN_WIDTH; ++j)
        {
            _tpos1 &= 511;
            _tpos2 &= 511;

            var x = _aSin[_tpos1] + _aSin[_tpos2] + _aSin[_tpos3] + _aSin[_tpos4]; 
    
            var index = (byte)(128 + (x >> 4)); 

            var c = _palette[index];

            final.Pixels[i * SCREEN_WIDTH + j] = c.A << 24 | c.R << 16 | c.G << 8 | c.B;                  

            _tpos1 += 5;
            _tpos2 += 3;
        }

        _tpos4 += 3;
        _tpos3 += 1;
    }

    _pos1 += 9;
    _pos3 += 8;

    PlasmaImage.Source = final;

    if (DesignerProperties.IsInDesignTool)
    {
        CompositionTarget.Rendering -= CompositionTarget_Rendering;
    }
}

Notice the last bit - unhooking the event. For both the cube and the plasma I'll do this. Why? Because that way I can do one pass of rendering, then stop, so I'm not killing Visual Studio during design time, but can still see the effect in the designer.

With those two controls done, it just required a little bit of stacking. I placed the plasma first, then stuck the cube in a view box set to stretch to the available dimensions, and you see the effect above.

I'm very excited to see what these types of routines will look like with the new APIs for graphics and 3D rendering in Silverlight 5!

Download the source code.

Jeremy Likness

5 comments:

  1. Def old skool - plasma rocks! Let me know when you solve the visual states issue with your site as I am just waiting on that to proceed with Jounce. I dont have this problem outside the framework. Thanks!

    ReplyDelete
  2. w1ck3d d3m0. where's the warez? :)

    ReplyDelete
  3. Kewl. I love old skool demos! Did you see my mix10k entry?

    http://www.scottlogic.co.uk/blog/colin/2010/01/my-mix10k-entry-old-skool-demo-plus-a-few-tips/

    I prefer plasma effects that look a bit less sin-ey, if you know what I mean. I think a bit of Perlin Noise makes it look slightly more 'organic'.

    Anyhow, great stuff ... more please!

    When I get a spare moment, I want to have a go at creating some old skool sippling scolly text :-)

    Colin E.

    ReplyDelete
  4. will it run on WP7 ?

    ReplyDelete
  5. Hi Anonymous,

    It probably will run, but a lot slower! I ported my mix10k entry (linked above) to WP7, but had to significantly strip back on features to get a half-decent framerate. See the video below:

    http://www.youtube.com/watch?v=6PeJVv5FICc

    Regards, Colin E.

    ReplyDelete