Generating SVG Solar Systems, Part 1: Setting the Scene
At 4/19/2024
Lately I’ve been having lots of fun creating procedurally generated artwork. These art pieces are drawn by a computer following a series of predefined steps and making random choices along the way. My favorite piece so far generates solar systems:
I learned a ton about JavaScript, SVGs, CSS (and space!) while making this. It’s way too much to fit into a single article so in this post will focus on generating and animating solar systems using SVGs, JavaScript, randomness, and CSS.
By the end of this section, we’ll have built the following generative art piece. (Part 2 will finish the solar system shown above.)
All of my generative art pieces share three building blocks: JavaScript randomness functions, an SVG wrapper, and a draw()
function.
JavaScript Randomness functions
The fun of procedurally generated artwork comes from mixing things up. By having the program make random choices we can make each art piece unique and different.
JavaScript exposes a Math.random()
function that will return a pseudo-random number between 0 and 1. We can use this to build a few other randomness helper functions. None of these are perfectly random, but they’re close enough for our use case:
// Return a number between two values.
function random(min, max) {
const difference = max - min;
return min + difference * Math.random();
}
// Returns a random integer between two values
function randomInt(min, max) {
return Math.round(random(min, max));
}
// Returns true or false. By default the chance is 50/50 but you
// can pass in a custom probability between 0 and 1. (Higher
// values are more likely to return true.)
function randomBool(probability = 0.5) {
return Math.random() > probability;
}
// Returns a random item from an array
function randomItemInArray(array) {
return array[randomInt(0, array.length - 1)];
}
Code language: JavaScript (javascript)
With these helpers in place we’ll be able to introduce randomness so that each solar system we generate is unique. Next up, we need an SVG wrapper for our solar system. Footnote 1
SVG Wrapper
An SVG element will contain all of the graphics that make up our solar systems:
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
width="200"
height="200"
viewBox="0 0 1000 1000"
style="background: #000;"
role="img"
aria-labelledby="solarSystemTitle"
aria-describedby="solarSystemDescription"
>
<title id="solarSystemTitle">
A procedurally generated solar system
</title>
<desc id="solarSystemDescription">
A 2D rendering of a solar system,
with planets orbiting a central star.
</desc>
<!-- This element will store our graphics -->
<g class="js-svg-wrapper"></g>
<!-- This stores any global CSS styles -->
<style></style>
</svg>
Code language: HTML, XML (xml)
There are a few things to note about this SVG:
- The
viewBox
attribute defines our SVG coordinate system. In this case we’re saying that all of our graphics will be placed relative to a 1000 pixel square grid. - I’ve given the SVG the
role="img"
and an accessible name and description usingaria-labelledby
andaria-describedby
. This will give screen reader users information about the graphic we’re creating. (Though writing good descriptions for generative art can be very tricky!) - I’ve added a
.js-svg-wrapper
group element. This will be the element we insert content into. - We’ve got an empty
<style>
tag. We’ll be adding some global CSS there later.
draw()
Function
A draw()
Function
Next we need to populate our SVG. To do so, we’ll create a draw()
function which sets the innerHTML
of our wrapper element. For now, we’ll place a randomly sized circle at the center of our SVG to represent our solar system’s star:
// Define a couple variables about our SVG grid
const width = 1000;
const height = 1000;
// Our draw function is where the ✨ magic ✨ happens
function draw() {
let starSize = randomInt(70, 120);
let markup = drawStar(starSize);
document.querySelector(".js-svg-wrapper").innerHTML = markup;
}
// We create a separate drawStar function to make it easier
// to change later
function drawStar(size) {
// `cx` and `cy` represent the x and y coordinates for the
// center of our circle.
// `r` sets the radius of the circle. We'll use our star size.
// For now we'll make our star white (`#fff`).
// We'll change the color soon.
return `
<circle
cx="${width / 2}"
cy="${height / 2}"
r="${size}"
fill="#fff"
/>
`;
}
Code language: JavaScript (javascript)
We’ll hook up this function to run when the script first loads, and re-run when a “Refresh” button is clicked:
draw();
const refreshButton = document.querySelector(".js-refresh-button")
refreshButton.addEventListener("click", draw);
Code language: JavaScript (javascript)
We’ve now got a generative art piece! (Just not a very exciting one.) Click the refresh button to randomly resize the star in our SVG.
draw()
Function
Expanding Our draw()
Function
We’ve now got all of our boilerplate in place. From here on out, we’ll be making changes to our draw()
function.
Adding Planets
First off, let’s add some planets. It’s not much of a solar system if it’s just one star.
Let’s add a couple functions to draw planets and their orbit paths. For now our planets are plain white, but we’ll add colors soon.
function drawPlanet(size, distance) {
// We center the planet vertically, but we adjust the x
// position by our orbit distance. The `planet` class
// will be used to set up our planet orbit CSS
return `
<circle
cx="${width / 2 + distance}"
cy="${height / 2}"
r="${size}"
fill="#fff"
class="planet"
/>
`;
}
function drawOrbit(distance) {
// The orbit is centered and has a radius equal to our
// current distance
return `
<circle
cx="${width / 2}"
cy="${height / 2}"
r="${distance}"
stroke="#ccc"
fill="none"
/>
`;
}
Code language: JavaScript (javascript)
Now we need to call these functions. We’ll create a new addPlanets()
function and call it from our main draw()
function:
let markup = drawStar() + addPlanets(starSize);
Code language: JavaScript (javascript)
We’ll use a while
loop in our addPlanets()
function to keep adding planets until we’re getting to the edge of our canvas:
// Define some helper functions to randomize plant size
// and orbit distance
let randomPlanetSize = () => randomInt(10, 50);
let randomOrbitDistance = () => randomInt(100, 120);
function addPlanets(starSize) {
let markup = "";
// Set up our first planet
let planetSize = randomPlanetSize();
let orbitDistance = starSize + randomOrbitDistance();
// Keep adding planets until a planet's orbital distance and
// size would lead to it extending past our canvas
while (orbitDistance + planetSize < 500) {
// Add our new planet and its orbit path to our markup
markup += drawOrbit(orbitDistance);
markup += drawPlanet(planetSize, orbitDistance);
// Prep our next planet so the while loop can check
// whether it's in bounds
planetSize = randomPlanetSize();
orbitDistance += randomOrbitDistance();
}
return markup;
}
Code language: JavaScript (javascript)
Now we’re getting somewhere! We’ve got a central star with planets placed on orbit paths around it! Go ahead and click that “Refresh” button a few times and watch the scene re-draw itself.
Animating our Orbits
This is still a little boring. It would be nice to get the planets to rotate around the star. Luckily, we can use CSS transforms and keyframe animations to get this working! Let’s go back and add some CSS to the <style>
tag inside of our main SVG markup.
/*
Storing values as custom properties will make them
easier to change later
*/
:root {
--start-rotation: 0deg;
--rotation-speed: 10s;
}
/* Set up an animation to rotate from 0 to 360 degrees */
@keyframes orbit {
from {
transform: rotate(var(--start-rotation));
}
to {
transform: rotate(calc(var(--start-rotation) + 360deg));
}
}
.planet {
/* Apply our animation to the planets */
animation: orbit var(--rotation-speed) infinite linear;
/*
Within an SVG, the transform-origin is set relative
to the SVG. This ensures our orbit will rotate around
the center of our star
*/
transform-origin: 50% 50%;
}
Code language: CSS (css)
This is closer but it’s still not quite right. All of the planets are orbiting around at the same speed and rotation. Ideally each planet would have its own speed and rotation. Since our CSS is already using custom properties, we can update our drawPlanet()
function to set unique values for those custom properties. We can incorporate the distance into our random rotation speed to make further orbits take longer.
function drawPlanet(size, distance) {
return `
<circle
cx="${width / 2 + distance}"
cy="${height / 2}"
r="${size}"
fill="#fff"
class="planet"
style="
--start-rotation:${randomInt(0, 360)}deg;
--rotation-speed:${distance * randomInt(40, 70)}ms;
"
/>
`;
}
Code language: JavaScript (javascript)
Now each planet has a randomized starting rotation and rotation speed:
But these planets still feel a little plain. Let’s add some colors!
Adding colors
For generative art I really like using HSL colors. Since they allow us to separately set the hue, saturation, and lightness of a color, they make it easy to randomly generate colors within specific parameters. Let’s update our drawPlanet()
function to use a randomized color for its fill
:
function drawPlanet(size, distance) {
const hue = randomInt(0, 360);
const saturation = randomInt(70, 100);
const lightness = randomInt(50, 70);
const color = `hsl(${hue}, ${saturation}%, ${lightness}%)`;
return `
<circle
cx="${width / 2 + distance}"
cy="${height / 2}"
r="${size}"
fill="${color}"
class="planet"
style="
--start-rotation:${randomInt(0, 360)}deg;
--rotation-speed:${distance * randomInt(40, 70)}ms;
"
/>
`;
}
Code language: JavaScript (javascript)
Note that hue is a value between 0 and 360, while saturation and lightness are both percentages (and require percentage signs.) We’re picking a random hue from 0 to 360, while limiting saturation and lightness to predefined ranges. Here’s what that looks like:
Next up, let’s give our star a color. This is going to use a similar technique but is a little trickier. According to my super-scientific method of googling “what color are stars” it turns out that humans don’t see green or purple light emitted from stars, so we shouldn’t allow any of our stars to be green or purple!
We’ll need to adjust how we calculate our hue to omit green and purple hues. To do so, we’ll need to take a look at the how HSL hue values map to colors:
For our use case we’ll allow the following hue ranges:
- Red: 0 to 30, or 330 to 360.
- Yellow: 40 to 60
- Blue: 190 to 240
With a bit of math, we can choose a random hue in one of those ranges:
function drawStar(size) {
// Note upper range of red exceeds 360
const hueRange = randomItemInArray([
[330, 390],
[40, 60],
[190, 240],
]);
// Pass along chosen array as arguments
let hue = randomInt(...hueRange);
// If red is greater than 360, use the remainder
if (hue > 360) {
hue = hue - 360;
}
// We'll use higher saturation and lightness values for our
// star than our planets.
const saturation = randomInt(90, 100);
const lightness = randomInt(60, 80);
const color = `hsl(${hue}, ${saturation}%, ${lightness}%)`;
return `
<circle
cx="${width / 2}"
cy="${height / 2}"
r="${size}"
fill="${color}"
/>
`;
}
Code language: JavaScript (javascript)
Now our star should be red, yellow or blue:
Next Steps
Awesome! We’ve built a program for procedurally generating solar system! But it’s still feeling a bit flat… It would be great if we could make this a little more lifelike with textures and realistic lighting. To do so, we’ll need to add a few more tools to our toolbox.
In part two of this series we’ll use SVG filters, gradients, and clipping paths to turn our flat solar system into something a little more lifelike:
Continue the journey with part two of this series: SVG Filters, Gradients, and Clip Paths
Footnotes
- (If you’re not familiar with SVGs, check out Tyler Sticka’s excellent presentation on SVGs). Return to the text before footnote 1