EXOTIC SILICON
“Programming in pure ‘c’ until the sun goes down”
Starfield simulator - Part three: Improvements and conclusions
Material covered in this part
  • Making improvements
  • Variable star size
  • Colors, random and calculated
  • Partial star trails
  • Side note - different random sources
  • Conclusions and further ideas for private study
Making improvements
 
Now that we've got our basic starfield simulator working, let's look at some changes and improvements that we can make to the algorithm to produce various interesting effects.
Variable star size
 
Logically, we would expect stars that are closer to use to appear larger. Since we've already added some code to make the stars larger and easier to see on-screen by plotting them as four pixels instead of one, it would be easy to apply this effect selectively.
We'll re-factor some of the code in the main function to place the movement vector calculation before the extra pixel plotting code, then we can check the average value of the X and Y vectors and only plot the extra three white pixels if the star is moving above a particular speed:
for (i=0; i<STARS_TOTAL; i++) {
framebuffer_clear(framebuffer);
SETPIXEL((star[i].x/SCALE), (star[i].y/SCALE), 255, 255, 255);
vx=SCALE*FRAME_WIDTH/2-star[i].x; vx=vx/32;
vy=SCALE*FRAME_HEIGHT/2-star[i].y; vy=vy/32;
if (abs(vx)+abs(vy)>512) {
SETPIXEL((star[i].x/SCALE+1), (star[i].y/SCALE), 255, 255, 255);
SETPIXEL((star[i].x/SCALE), (star[i].y/SCALE+1), 255, 255, 255);
SETPIXEL((star[i].x/SCALE+1), (star[i].y/SCALE+1), 255, 255, 255);
}
star[i].x=star[i].x-vx;
star[i].y=star[i].y-vy;
if (star[i].x<0 || star[i].x>=SCALE*(FRAME_WIDTH-1) || star[i].y<0 || star[i].y>=SCALE*(FRAME_HEIGHT-1)) {
star[i].x=arc4random_uniform(SCALE*(FRAME_WIDTH-1));
star[i].y=arc4random_uniform(SCALE*(FRAME_HEIGHT-1));
}
}
I've left the call to framebuffer_clear in place in the code above to show where it would go if we don't want to see the trails behind each star. All of the example images on this page were created without this function call, so that we can visualise the paths of the stars more easily. It obviously makes sense to analyse the output of both variations.
The way we test for the average value of the velocity vectors is simple, simply take the absolute integer value ignoring any negative sign, and average the results. The value of 512 as a cut-off is fairly arbitrary, and based on half of the smallest screen dimension, that being the height 1080, rounded to the nearest round binary number.
Unfortunately, this simple approach produces a noticeable artifact, in that the centre of the field of view is noticeably darker than the outside. It's actually not too distracting when we view the animation without the trails, but we can probably improve on it. Let's try adding a random offset to the 512 threshold:
if (abs(vx)+abs(vy)>512+arc4random_uniform(128)) {
This certainly softens the hard edge, but it creates an interesting side-effect which becomes quite visible when the animation is played with the framebuffer_clear call in place before each frame. Some of the stars twinkle as they cross the 128 pixel boundary, because they switched from small to large, back to small, and then back to large again. This effect can be seen even more vividly by increasing the random offset:
if (abs(vx)+abs(vy)>128+arc4random_uniform(1024)) {
Now almost the whole screen seems to be full of scintillating stars. I actually quite like the effect, but I can understand that not everybody would. We can also remove the 512 velocity threshold, and simply make a few stars twinkle completely at random:
if (arc4random_uniform(12)>0) {
Here we just give each star a one in twelve chance of being rendered small, as a single pixel, otherwise it's rendered large. This effect looks quite visually appealing.
Of course, if we want to create an effect like this, it would probably be better to add some random to the RGB values, which we will try shortly in the next section, but first let see the effect of assigning each star a specific size value for it's lifetime:
struct starinfo {int x; int y; int size;} ;
star[i].size=arc4random_uniform(2);
if (star[i].size==1) {
star[i].size=arc4random_uniform(2);
We actually don't really need to reset the size value when the star moves off the screen and is placed at a new random location. The visual effect is almost identical if we leave that last line out, but I've kept it here in the example for completeness.
Now it's starting to look more ‘realistic’, or at least more like other starfield simulators. What will really help to complete the illusion of flying through space, though, is to move the large stars more quickly, and the smaller ones more slowly:
vx=SCALE*FRAME_WIDTH/2-star[i].x; vx=vx/(star[i].size==0 ? 32 : 20);
vy=SCALE*FRAME_HEIGHT/2-star[i].y; vy=vy/(star[i].size==0 ? 32 : 20);
Wow! What a difference that makes! Now it looks much better.
Inverting the formula we just added, and making the small stars move more quickly than the large ones creates an unusual and unrealistic visual effect. Inverting just one of the axes, for example making small stars move more quickly horizontally, and large stars move more quickly vertically gives an interesting effect of two distinct planes of movement. That could plausibly be a useful video effect.
Of course, instead of having a simple binary flag for a large star or a small star and basing the speed on this, we could just introduce a new ‘base speed’ value for each star, giving us a range of possible speeds with a much finer grained visual result:
struct starinfo {int x; int y; int base_speed;} ;
void init_stars(struct starinfo * star)
{
int i;
for (i=STARS_TOTAL; i--;) {
star[i].x=arc4random_uniform(SCALE*(FRAME_WIDTH-1));
star[i].y=arc4random_uniform(SCALE*(FRAME_HEIGHT-1));
star[i].base_speed=arc4random_uniform(32);
}
return ;
}
for (i=0; i<STARS_TOTAL; i++) {
SETPIXEL((star[i].x/SCALE), (star[i].y/SCALE), 255, 255, 255);
vx=SCALE*FRAME_WIDTH/2-star[i].x; vx=vx/(64-star[i].base_speed);
vy=SCALE*FRAME_HEIGHT/2-star[i].y; vy=vy/(64-star[i].base_speed);
if (star[i].base_speed>6) {
SETPIXEL((star[i].x/SCALE+1), (star[i].y/SCALE), 255, 255, 255);
SETPIXEL((star[i].x/SCALE), (star[i].y/SCALE+1), 255, 255, 255);
SETPIXEL((star[i].x/SCALE+1), (star[i].y/SCALE+1), 255, 255, 255);
}
star[i].x=star[i].x-vx;
star[i].y=star[i].y-vy;
if (star[i].x<0 || star[i].x>=SCALE*(FRAME_WIDTH-1) || star[i].y<0 || star[i].y>=SCALE*(FRAME_HEIGHT-1)) {
star[i].x=arc4random_uniform(SCALE*(FRAME_WIDTH-1));
star[i].y=arc4random_uniform(SCALE*(FRAME_HEIGHT-1));
star[i].base_speed=arc4random_uniform(32);
}
}
These changes to the code introduce a new element to the star structure called base_speed. It's set to a random value between 0 and 31 for each star, and when we come to calculate the movement vectors, we subtract it from 64, to give us a range of possible values between 33 and 64, instead of the two values 20 or 32 that we had before. These larger dividers also slow the overall movement of the animation down by about half. We plot a large, four-pixel star if base_speed is above 6, so on average 6 out of 32 stars will be large.
We could continue making further changes, such as constraining new stars or small starts to the centre quarter of the screen, but I'll leave those as programming exercises for you to do on your own.
Now let's move on to color!
Colors, random and calculated
 
Adding color to the stars is fairly straightforward. Our pixel plotting function already accepts an RGB value, but we currently just set it to the maximum value of 255 for each channel, producing pure white. We could start by simply setting a random RGB value for each point plotted:
for (i=0; i<STARS_TOTAL; i++) {
SETPIXEL((star[i].x/SCALE), (star[i].y/SCALE), arc4random(256), arc4random(256), arc4random(256));
vx=SCALE*FRAME_WIDTH/2-star[i].x; vx=vx/(64-star[i].base_speed);
vy=SCALE*FRAME_HEIGHT/2-star[i].y; vy=vy/(64-star[i].base_speed);
if (star[i].base_speed>6) {
SETPIXEL((star[i].x/SCALE+1), (star[i].y/SCALE), arc4random(256), arc4random(256), arc4random(256));
SETPIXEL((star[i].x/SCALE), (star[i].y/SCALE+1), arc4random(256), arc4random(256), arc4random(256));
SETPIXEL((star[i].x/SCALE+1), (star[i].y/SCALE+1), arc4random(256), arc4random(256), arc4random(256));
}
It's a nice twinkling effect, but we don't really notice the individual colors because they are continuously changing.
Assigning a random color to each star when we set it's initial co-ordinates is another possibility:
struct starinfo {int x; int y; int base_speed; int red; int green; int blue;} ;
void init_stars(struct starinfo * star)
{
int i;
for (i=STARS_TOTAL; i--;) {
star[i].x=arc4random_uniform(SCALE*(FRAME_WIDTH-1));
star[i].y=arc4random_uniform(SCALE*(FRAME_HEIGHT-1));
star[i].base_speed=arc4random_uniform(32);
star[i].red=arc4random_uniform(256);
star[i].green=arc4random_uniform(256);
star[i].blue=arc4random_uniform(256);
}
return ;
}
for (i=0; i<STARS_TOTAL; i++) {
SETPIXEL((star[i].x/SCALE), (star[i].y/SCALE), star[i].red, star[i].green, star[i].blue);
vx=SCALE*FRAME_WIDTH/2-star[i].x; vx=vx/(64-star[i].base_speed);
vy=SCALE*FRAME_HEIGHT/2-star[i].y; vy=vy/(64-star[i].base_speed);
if (star[i].base_speed>6) {
SETPIXEL((star[i].x/SCALE+1), (star[i].y/SCALE), star[i].red, star[i].green, star[i].blue);
SETPIXEL((star[i].x/SCALE), (star[i].y/SCALE+1), star[i].red, star[i].green, star[i].blue);
SETPIXEL((star[i].x/SCALE+1), (star[i].y/SCALE+1), star[i].red, star[i].green, star[i].blue);
}
star[i].x=star[i].x-vx;
star[i].y=star[i].y-vy;
if (star[i].x<0 || star[i].x>=SCALE*(FRAME_WIDTH-1) || star[i].y<0 || star[i].y>=SCALE*(FRAME_HEIGHT-1)) {
star[i].x=arc4random_uniform(SCALE*(FRAME_WIDTH-1));
star[i].y=arc4random_uniform(SCALE*(FRAME_HEIGHT-1));
star[i].base_speed=arc4random_uniform(32);
}
}
The colors are certainly noticeable now, and it's a nice effect, but it's not really appropriate for a starfield simulation. What we really need to do is to calculate an appropriate color for each star, based on it's velocity vectors.
A simple linear relationship, making faster moving stars brighter, works quite well:
SETPIXEL((star[i].x/SCALE), (star[i].y/SCALE), (star[i].base_speed*8), (star[i].base_speed*8), (star[i].base_speed*8));
vx=SCALE*FRAME_WIDTH/2-star[i].x; vx=vx/(64-star[i].base_speed);
vy=SCALE*FRAME_HEIGHT/2-star[i].y; vy=vy/(64-star[i].base_speed);
if (star[i].base_speed>6) {
SETPIXEL((star[i].x/SCALE+1), (star[i].y/SCALE), (star[i].base_speed*8), (star[i].base_speed*8), (star[i].base_speed*8));
SETPIXEL((star[i].x/SCALE), (star[i].y/SCALE+1), (star[i].base_speed*8), (star[i].base_speed*8), (star[i].base_speed*8));
SETPIXEL((star[i].x/SCALE+1), (star[i].y/SCALE+1), (star[i].base_speed*8), (star[i].base_speed*8), (star[i].base_speed*8));
}
Now that some of the stars are quite dull, let's increase the overall number, and set the threshold for any particular star to be drawn larger to 12, from it's current value of 6.
#define STARS_TOTAL 2000
if (star[i].base_speed>12) {
We can add a blue hue to the starfield simply by reducing the red and green components:
SETPIXEL((star[i].x/SCALE), (star[i].y/SCALE), (star[i].base_speed*6), (star[i].base_speed*6), (star[i].base_speed*8));
SETPIXEL((star[i].x/SCALE+1), (star[i].y/SCALE), (star[i].base_speed*6), (star[i].base_speed*6), (star[i].base_speed*8));
SETPIXEL((star[i].x/SCALE), (star[i].y/SCALE+1), (star[i].base_speed*6), (star[i].base_speed*6), (star[i].base_speed*8));
SETPIXEL((star[i].x/SCALE+1), (star[i].y/SCALE+1), (star[i].base_speed*6), (star[i].base_speed*6), (star[i].base_speed*8));
}
At this point, I think we've achieved a fairly respectable result. It's certainly usable as a video effect now, and the actual C code still comes in at under a hundred lines. Not bad at all.
Partial star trails
 
At various stages throughout the development process, we've tested the starfield simulator without the call to framebuffer_clear in place in order to more clearly visualise the trails of each star.
It turns out that a simple one-line change, to apply it only for certain animation frames, allows us to create a classic, ‘warp-drive’ effect:
if (current_frame<50) { framebuffer_clear(framebuffer); }
Of course, a more flexible way to create star trails would be to store the co-ordinate values from previous frames so that we could display, for example, the last five positions of each star. We could even make the current position of the star brighter than the previous positions, which would create comet-like trails.
But at this point, I think the original aim of this programming project has been achieved. I'm pretty pleased and satisfied with the result:
Final result
Side note - different random sources
 
Using arc4random_uniform to generate all of the random numbers, as I've done in the example code, ensures that the starfield is different on every run of the program. The arc4random family of functions is designed to generate particularly high-quality random data, which doesn't follow any sort of predictable pattern, in other words it is non-deterministic. In most applications that use random data, this is exactly what is desired.
However, other random sources do exist which, whilst seemingly random on the surface, do actually follow a clearly pre-determined pattern. In most cases, this is not a desired feature, and the use of deterministic random data can cause serious problems in applications that expect or require high-quality random.
Nevertheless, applications for deterministic random do exist, and the starfield generator is an interesting example.
Whilst preparing the screenshots for these pages, I changed the code to use the deterministic rand function instead of arc4random_uniform. This allowed me to generate the exact same starfield on multiple runs of the program. Knowing how the random number generator was seeded, I can exactly re-generate any of the screenshots just using the C source code.
In this case, generating the exact same starfields is potentially useful. The first screenshot showing the ragged star trails, for example, might not show this artifact quite as clearly if, by chance, the initial positions of all of the stars was significantly further from the middle of the framebuffer.
So in the case of the starfield generator, we can make good use of both types of random number generator. High-quality random from arc4random_uniform ensures that each run of the program produces a fresh result. This is especially useful here for debugging, to identify any bugs that might only show up in certain cases. For example, when we changed the code from plotting a single pixel for each star to plotting four, if we hadn't changed the code to check for stars reaching the edges of the screen, then trying to draw any star with a Y co-ordinate of exactly 1079 would have caused a write to data outside of the area of the framebuffer, and likely caused a segmentation fault. Such a bug could plausibly have gone unnoticed if we had used deterministic random that had been seeded in such a way which never produced starting co-ordinates that caused this to happen. However, the bug would still have been present in the code, and could have shown up at a later date if, for example, we decided to increase the total number of animation frames.
Conclusions and further ideas for private study
 
We've written a good implementation of a starfield simulator from first principles, without relying on third party graphics libraries, fancy programming language features, or even floating point arithmetic.
The final version of the program is less than three kilobytes of C source code. It runs reasonably quickly, generating 90 animation frames with 2000 stars in under three seconds on an 1800 Mhz arm CPU. On a 4500 Mhz, 9th generation core i7 CPU, it runs in about 0.6 seconds. Not bad, considering that we didn't really focus on optimising the code for performance.
We've already touched on some ideas for further improvements, such as implementing the comet-trail effect, and constraining the generation of new stars to the centre of the screen. Other ideas might be to implement a variable blue-shift in color, relative to the speed of each star, fully variable star size, rather than the simple binary single pixel or group of four pixels approach that we've used so far, and also to optimise the code for performance.
That's the end of the first project in this real programming series, where we attempt to explain methods of implementing arbitrary concepts in a computer program from scratch. It was intentionally a fairly simplistic project to start things off, but if you enjoyed it then I hope you'll join me again for the next project where we'll up the complexity level somewhat, and develop an almost pixel-accurate simulation of an old home computer tape loading sequence.