VBfx / Common / Starfield

Introduction

Starfield

There's a Starfield simulation screensaver coming with any Windows version down to 3.11 but it doesn't look too good. If you ever wondered how to make your own starfield this is the right tutorial for you. It's in fact not even complicated nor do you need fancy mathematics. The whole trick to convert 3D coordinates into 2D coordinates is: NewX = x / z and NewY = y / z respectively.

Well, sure this is kind of a fake but that's more or less a true 3D starfield and it looks way better than the Windows one. Besides the code is much faster, you'll be able to fly through 20'000 stars fluently. Interested in the code? Well keep on reading...

Theory

Now let's get into theory first. As alread mentioned this is quite a small project and that's good for you. Basically we just have a list of stars where each star has it's 3d position (that's x, y and z). We need about 3 functions, one that creates the stars, one that moves them (towards the screen) and one that redraws the screen when everything is ready. Creating the stars happens (of course) before everything. In the main loop we follow the classical steps "clear-process-update-draw", that means clearing the screen, processing user input, updating the data (move the stars towards screen) and then draw everything again. Hm wait - we don't have any input to process... so we just skip that part ;)

That's it, we're already done with theory - let's get started:


The data type

We start out creating the essential things - the stars. Since our stars are just the same as 3D-pixels the definition is quite simple. Add this code to a new module in your empty project:

'Type definition
    Public Type tStar
        x As Single
        y As Single
        z As Single
    End Type

'Star data
    Public StarCount As Long
    Public Star() As tStar

First we do the data type definition where each tStar holds it's x, y and z position. The second part creates an open array to hold the star data we need later. Well I already told you we're going to use some API calls, so here they come. You can just add it somewhere in the same module:

'Declares
    Public Declare Function GetTickCount Lib "kernel32" () As Long

    Public Declare Function SetPixelV Lib "gdi32" (ByVal iDC As Long, _
       ByVal x As Long, ByVal y As Long, ByVal iColor As Long) As Long

Not too hard is it? GetTickCount should be well known and, oh, well, SetPixelV is just a more accurate version of SetPixel. It does exactly what it says - setting a pixel on the desired position. The DC property says where to draw the pixel, if you don't know about it read the BitBlt tutorial first (or just skip it, it's fairly easy to handle).

Now before continuing we have to define some options we want to use later. First we need a Speed variable telling the engine how fast to move on. Also we need some values to define the spread range of the stars. A small range means that all stars are coming "out of the middle", a larger range would spread them over the whole screen. You can later play with these values just as you like. Append the code to the declares-section of your module:

'Setup
    Public Speed as Single
    
    Public SpreadX as Single
    Public SpreadY as Single
    Public SpreadZ as Single

Since we already started declaring variables why not get it all done now? Well it's not much more, all we need is the screen position and the screen middle (just for optimization). Of course the screen middle is nothing more than Width / 2, Height / 2 but the code looks cleaner if you put this in variables:

'Screen size
    Public ScreenW as Long
    Public ScreenH as Long
    
'Screen middle
    Public OffsetX as Long
    Public OffsetY as Long

Wonder why it's called Offset? Well later we'll use the screen middle as the offset for drawing the stars. I'll get back to this later, just keep it in mind.

Creating the stars

When I'm talking about star creating don't get confused because it sounds complicated. In fact it's nothing more than setting some random positions. The code also goes into your module:

Public Sub SetupStars( iCount as Long )
    Dim A as Long
    
    'Create stars array
    StarCount = iCount
    ReDim Star( StarCount )
    
    'Set random positions
    For A = 0 To StarCount
        With Star( A )
            .x = ( Rnd - 0.5 ) * SpreadX
            .y = ( Rnd - 0.5 ) * SpreadY
            .z = Rnd * SpreadZ
        End With
    Next
End Sub

I guess you wonder about the x = ( Rnd - 0.5 ) * SpreadX part? Let me explain shortly: Rnd returns a random number between 0 and 1 (like 0.02 or 0.87). Now since we're flying "through the middle" of the starfield (position 0, 0) we need to place the stars around the middle and the simplest way to do this is randomize between -0.5 and 0.5 and that's what (Rnd - 0.5) does. Multiplying this with any value we can scale the placement range just as we need it. An alternative way to write this would be: x = (SpreadX * Rnd) - (SpreadX / 2) - that's exactly the same but probably easier to understand (but the other code is shorter ;).

Update scene - moving stars

Even if this sounds very complicated it's not! Moving the stars means nothing more than z = z - speed, really. The only thing we have to do is checking z to be smaller than 0. If it's below 0 the star would be "in front of the screen" and they are not supposed to be there. Erm wait, the stars won't jump out of the screen! The projection would just reflect them so they come towards the screen, bounce and then move backwards again.

However we don't want that so we have to prevent it. Since the stars would leave the screen, the user won't in any case see them any longer - therefore we can reset their positions so they appears again in the very back of the scene. I'm talking about re-using resources that are no longer displayed.

Public Sub MoveStars()
    Dim A as Long
    
    For A = 0 To StarCount
        With Star( A )
            'Move towards screen
            .z = .z - Speed
            
            If .z <= 0 Then
                'Reset star when in front of screen
                .x = ( Rnd - 0.5 ) * SpreadX
                .y = ( Rnd - 0.5 ) * SpreadY
                .z = SpreadZ
            End If
        End With
    Next
End Sub

This code also goes in your module. It does nothing fancy so I won't explain it any further. Just a little not about reseting the z-position: When creating the stars we randomized the value. Here we can't (or shouldn't) because this would make the stars plop anywhere on the scene. Instead we want them to smoothly re-appear from the very back, therefore setting z to SpreadZ here which is the maximum for z-positions.

Drawing the stars

I always talked about how easy the functions are, but not here, fear the draw-function *muahaha*. Ehm, wait, just kidding here...

On the other hand this is the main part of the starfield so keep your eyes open, you have to understand the following. I'm switching to line-by-line mode now, guiding you through the code.

Public Sub DrawStars( DC as Long )
    Dim A as Long
    
    Dim TempX as Long
    Dim TempY as Long
    Dim TempColor as Single

Not too hard yet but let me explain the Temp values. Basically it's just everything we need for the SetPixel function now: x and y position and the pixel color. Putting this into temp variables makes your code cleaner and easier to extend later. Okay let's go on...

    For A = 0 To StarCount
        With Star( A )
            'Get 2D (projected) position
            TempX = ( .x / .z ) + OffsetX
            TempY = ( .y / .z ) + OffsetY

Remember what I said before? The whole 2D projection happens here, this means converting 3D positions to 2D positions. And the formula - as I told you - is just NewX = x / z and NewY = y / z respectively.

Now have a look at the Offset (screen middle) variables. You can comment it out to see what happens: The middle of the starfield is in top-left corner of the screen. Why? Simply because the stars are placed around the 0-point (as mentioned when creating them). To fix the problem we just move the whole starfield to the middle of the screen and that's what adding the Offset does. Note that we can't add a pixel-based offset before the projection because the stars don't have pixel-based positions. In fact they only have relative coordinates because in 3D it's all about scaling.

            'Get color from distance
            TempColor = ( 1 - ( .z / SpreadZ ) ) * 255

I hope this isn't too hard to understand. It's a little trick to make the color of each star dependant of it's z-position. The z-value goes from 0 to SpreadZ, thus dividing z / SpreadZ returns values between 0 and 1 (playing with maths here). Now the far-away stars would be white, getting darker as the come nearer. To invert this behaviour we need the 1 - x statement before the bracket. And finally we have to multiply the whole thing so we can feed the RGB-function with this values.

            'Draw pixel
            SetPixelV DC, TempX, TempY, RGB( TempColor, TempColor, TempColor )
        End With
    Next
End Sub

The last step should be clear, putting everything together by calling the SetPixel function, passing the TempX, TempY position and the color we get from the RGB function.

The final step

Now that we got all the functions I talked about it's time for the last part of this tutorial: Setup the whole thing and running the main loop.

I put the whole setup in Form_Load, shouldn't bee too hard to understand:

Private Sub Form_Load()
    'Setup values
    Speed = 0.1
    
    SpreadX = 2000
    SpreadY = 2000
    SpreadZ = 10
    
    'Setup screen
    Me.Move 0, 0, Screen.Width, Screen.Height
    Me.Show
    
    'Get size
    ScreenW = Me.ScaleWidth
    ScreenH = Me.ScaleHeight
    
    'Get screen middle
    OffsetX = ScreenW / 2
    OffsetY = ScreenH / 2
    
    'Initialize data
    SetupStars 10000
    
    'Call main loop
    Main
End Sub

Speed should be clear, erm, yes. As mentioned at the beginning you can chose any values for the Spread variables. Try larger and smaller ranges to see the effect. Note that a wider spreading demands more stars if you still want the screen to be filled.

The main function that is called in the last line looks as follows. It's controlled by a simple GetTickCount timer, making sure it runs at 30 frames / second:

Public Sub Main()
    Dim NextTick as Long
    
    'Init timer
    NextTick = GetTickCount
    
    While DoEvents
        If GetTickCount > NextTick Then
            'Set wait interval
            NextTick = GetTickCount + ( 1000 / 30 ) 'About 30 FPS
            
            'Clear the screen
            Me.Cls
            
            'Move all stars
            MoveStars
            
            'Draw stars and update screen
            DrawStars Me.hdc
            Me.Refresh
        End If
    Wend
End Sub

Finally we need something to exit the program - The escape key will do the job for us, make it work by adding this line of code to the Form:

Private Sub Form_KeyDown( KeyCode as Integer, Shift as Integer )
    If KeyCode = vbKeyEscape Then: Unload Me
End Sub

...should be clear.

Now wait! Before pressing the "Run" button now make sure your Form is setup correctly! Set the BackgroundColor to full black and the ScaleMode property to 3 - vbPixels. Also important: Set the BorderStyle property to 0 - none, otherwise it won't run in fullscreen mode. To prevent flickering also set AutoRedraw to true. Note that this only works because we added the Me.Refresh statement to update the screen (otherwise nothing would show up).

You can even add a background picture if you like.

Running the project

If you did everything correctly this it the moment of interest. Run the project and fly through the stars!

A little side note: Instead of just using Me.Cls to clear the screen (Cls meas Clear screen) you could also overdraw each pixel by it's original color. To achieve this you have to store the original pixel's color before drawing it and (for speed reasons) the projecte 2D-position so you can overdraw it later without re-calculating the projection again. If you're interested in this technique check out the second example project called Starfield2. Overdrawing is faster in some cases, however as soon as you have a lot of stars Me.Cls works faster again.

Conclusion

In this tutorial you learned the basics steps of an engine: Setup, then clear-input-update-draw and finally release everything (we skipped some parts but you can imagine ;). You also came in touch with the projection formula which is fairly simple. However this wasn't the whole truth - you can for example extend the projection by a FoV (field of view) range or additional corrections. When working with DirectX you'll meet these things again but for the moment it's just fine.

Now download the example projects and enjoy your starfield!

Extending the starfield

What? Guess we're done? Well not yet. Those who tried a background picture will have noticed that the stars are just black and not truly faded out. Also the points don't get bigger as they come nearer.

To satisfy you guys I created a 3rd version of the starfield covering both effects, smoothly faded stars and bigger points. The fading is done by getting the background color and calculating the necessary color to make it look transparent, see the code for details. For bigger points I added a DrawPoint function that uses some API calls, in fact you can't use SetPixel for this but draw a line of 0 pixels instead. This, of course, slows down everything but we still get nice results. Well check it out:

Navigation