A starfield was one of the first things I built when learning to program. It's been quite some time now, and I've started learning shader programming with GLSL and three.js, so I decided why not go back to where it all started!
The Final Product
If you're in a hurry and just want to see what I've put together, you can look at the final product here, and view the GitHub repository here!
(I'd drop a gif, but you couldn't really make out what's happening 🤷♂️)
Let's Build It!
If you're not familiar with shader programming, don't worry! I'll be sure to keep it informative, but accessible.
Also, there's a lot of boring padding code to get everything running, so all the GLSL here is paraphrased for your enjoyment (and my own sanity). Take a look at the repository for the real code.
Part 1 - The classical approach
let's start with the most straight forward way to do this, a rough GLSL port of what you might write in JavaScript:
// Loop through all the stars we want
for (int i = 0; i < STAR_COUNT; i++) {
// Give the star a random position
vec2 star = vec2((random(i) - 0.5) * 2.0, (random(i) - 0.5) * 2.0);
// Get the direction from the center to the star, and scale it by time and a random offset
star = normalize(star) * mod(time + random(float(i) * 16.0), 1.414214);
// If the star is within 0.1% if the viewport size then draw it as white
if (distance(screenPosition, star) < 0.001) {
color = vec3(1, 1, 1);
break;
}
}
So what's wrong with this method? Mostly, that it just doesn't scale. GLSL runs your shader for every pixel, you can think about it like this:
for (let pixel of screen) {
for (let star of stars) {
...code
}
}
This is horribly inefficient!
So how can we make this more performant, and maybe make it even better?
Part 2 - Let's make it better!
In order to make this thing awesome, we're going to need to fix the biggest issue. Iterating over hundreds of stars.
My favorite thing to do in a situation like this is to try a totally new perspective. Like, what if instead of each star being a point emitted from the center, it was a point along a column that went from the center to the edge?
Imagine a pie that covered the entire screen, each slice would represent one star traveling from the center to the edge.
Since the "slices" wouldn't move, we could map screenPosition
to a slice, and figure out what star to process:
vec2 direction = normalize(floor(normalize(screenPosition) * STAR_DENSITY) / STAR_DENSITY)
We can define STAR_DENSITY
for the number of slices we want.
Now, instead of using i
to figure out the stars offset, we can convert direction
from a point, to a float and use that instead:
// I'm using `scale` because `distance` is a built-in method
float scale = mod(time + random(direction.x + direction.y * 10.0), 1.414214);
With a direction
and a scale
we've now defined our star using polar coordinates, using just the screenPosition
!
We can now do our distance check like this:
if (abs(scale - distance(screenPosition, vec3(0, 0, 0)) < 0.001) {
...
}
🎉 Tada, mission accomplished! We've now not only improved performance, but created a super dense starfield visualization that you couldn't do in JavaScript!
Thanks for reading, I hope you enjoyed the article, I aim to make more of these (hopefully better) so if you have any feedback please let me know!
Top comments (2)
Apply your GLSL skills to the V Shader Hackathon, running until 22 May 2025!
Create unique Shader art to win up to $1000 :)
How to join: medium.com/vsystems/13-26-april-cr...
Any questions? Join our community!
Awesome, just started learning about GLSL and this is very useful :)