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...
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:
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.
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
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.
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.
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.
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.
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!
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: