Procedurally Generated Nebulae in Unity3D
The Quest Begins
One of the goals I had for the visuals of Luna's Wandering Stars was a beautiful background. Starting out, the game prototype had backgrounds created by yours truly, one for each of the worlds in the game. They weren't bad, but I saw room for improvement.
I yearned for a more dynamic background, something more varied than a static image. Space is vast, and there are many astounding structures that litter its expanse. To limit all you saw to a few stock images, no matter how beautiful, seemed to me a waste of an opportunity. One of the allures of space for myself, and many others I'm sure, is the possibility of discovery, and of seeing new sights. So I decided that a procedurally generated background would be the way to go. Even if the background was limited to a single category of imagery, at least it would be varied.
I spent some time looking for ways to render beautiful nebulae. I didn't find much. There were some examples that looked amazing, but were pre-rendered, or kept their methods secret. Simple solutions and tutorials didn't look nearly as interesting as I wanted, and scientific papers with various rendering techniques required too much processing power. So, I decided to make my own method.
Noise
I needed a starting point, so I began with the classic method of Perlin Clouds. I'd found numerous tutorials that started and ended with Perlin Clouds, so it seemed to be a decent starting point. Perlin Clouds are created by adding different resolutions of Perlin Noise together.
The basic idea is simple. You take some big circles, slap on some smaller circles, and then some even smaller circles, and then your image will look somewhat like a cloud. Perlin Noise and Perlin Clouds are covered in many tutorials across the web (I recommend this one), so I won't be covering them in this tutorial. They're definitely powerful and useful tools, and I'd recommend learning about them if you haven't already.
Above is an example of my noise generator's output. It's just Perlin noise added together, and uses bicubic interpolation to reduce how blocky and square it looks. Since it's about to go through a lot of processing, it's not as cloudy or smooth as you might see in a normal Perlin Cloud example.
In actuality, the full image looks like this:
This is because each of the color channels (Red, Green, Blue) have their own separate pattern. I'm actually putting 3 different black-and-white patterns into this image by making one pattern black-and-red, another black-and-green, and the third black-and-blue. The final image holds all 3 patterns, all I need to do is only look at one color at a time. This way, I can get more information in each texture I use. A solid starting point.
Distortion and Shaders
One of the most interesting applications of Perlin noise I've seen is a simple marble texture. By offsetting the values of a cosine function with Perlin noise, one can take the simple stripes of cos(x) across a plane, and transform them to look convincingly like marble. I decided to take inspiration from this idea, and start distorting my clouds with more noise.
At this point, it becomes necessary to use shaders. Shaders are programs that tell computers how to choose a pixel color, given that a certain object is filling up that pixel. In the simplest form, it just returns a single color: the flat color of the object the computer's trying to display. Stepping up in complexity, you can add shading to the object (thus their name), images to place on the surface of the object, and even distort the object.
This effect is only one of an endless assortment of awesome things that can be accomplished with shaders. The fundamentals of writing shaders is out of the scope of this tutorial, but you should be able to understand the thought process behind the effect without too much knowledge of how to write the shader.
For my own purposes, I was interested in distorting the image on the surface of an object ( in this case a flat plane ). For this, I simply had two textures on my surface. One was used to choose a color for the screen, and the other was used to distort the texture coordinates on the first texture. In CG, the fragment or pixel function (which tells the computer what color to make a pixel) looks something like this:
float4 frag(v2f i) : COLOR{
//Get the appropriate color value from the first texture
half4 col = tex2D(_MainTex,i.tex.xy * _MainTex_ST.xy + _MainTex_ST.zw);
//Offset the coordinates for the second color value
//Then get the color value from the second texture
float2 offset = float2(_Distort*(col.r-.5),_Distort*(col.g-.5));
half4 col2 = tex2D(_MainTex2,i.tex.xy * _MainTex2_ST.xy + _MainTex2_ST.zw + offset);
//Return the grayscale value in the red channel
return col2.a;
}
First, I read a color from the main noise texture. Then I use that value to offset the texture coordinates when reading a color from the second noise texture. There's also a variable that controls how much distortion happens, which makes it simple to tweak how the end product looks.
Here's what it looks like in monochrome, with a distortion value of .3 :
Now we're getting somewhere! The pattern is a lot more interesting, and looks like its been flowing around a bit. A step up from blobs, if you ask me.
Masking
With the swirls and feeling of movement, we've got some nice texture for the nebulae. But we need to add some shape to it as well! Nebulae often have tendrils or tube-like structures, and this square of swirls doesn't quite have the right feel.
To fix that, we'll add a mask to the whole thing. A Mask simply hides parts of the image. So, we need some tendrils!
To create an appropriate mask, I wound up using a simple cellular automaton (think Conway's Game of Life). Shoutouts to Tom Blanchet, who found this method. The process itself is pretty simple. First, seed a grid with random values between 0 and 2. Then, have each value sum itself with its 8 neighbors. If the sum is greater than 5, change its own value to 1. Otherwise, change its own value to 0. Repeat several times, and you have some tendrils! A little blurring helps too.
As with the noise, I put 3 masks into a single image using the red, green, and blue color channels. With one mask texture, we can use quite a few different masks, if we want! If we don't need so many masks, we should just use a grayscale image instead of a color image.
Once we apply this mask to the swirls, our clouds of gas have more shape to them.
The cutoff is a bit too sharp and defined for clouds, so we can distort this a bit too. We'll just mask our mask with a different mask (in a separate color channel)! This essentially combines the two masks, and softens up their shapes a bit.
One major flaw remains with the shape of our nebula. It's a square! We'll add a simple round mask to hide the fact its a box.
Now that's a decent puff of cloud!
Coloring
Now that we've got a nicely shaped cloud, we need to add some color. I wound up just applying a gradient over a monochrome image. It was a simple way to have clouds with some color variation and depth to them.
Rather than using just the image above, though, I used one of the yet unused channels of the distorted noise. This creates color that's unrelated to the structure of the cloud, which looks a lot better in my opinion.
That's starting to look pretty good. But it's just one component of a full nebula! Nebulae contain various types of gas, and that has to be accounted for. I identified three different types of gas/structure I wanted to include. First, the large swaths of color that make up the bulk of a nebula. Next, dark clouds of matter that block light. They're a key part of some of the most gorgeous pictures of nebulae. Finally, some bright, detailed structures to add to the complexity.
These three different parts only require some changes to the coloring, blending, and brightness. Here's a puff with some details added to it.
Now our base components are finished! Time to build some nebulae!
Finished Fragment Shader
Let's take a look at the final shader's fragment function. It's not the most compact it could be, but in exchange it should be easier to read.
//fragment shader
float4 frag(v2f i) : COLOR{
//Get the colors at the right point on the first texture
half4 col = tex2D(_MainTex,i.tex.xy * _MainTex_ST.xy + _MainTex_ST.zw);
//Use that to create an offset for the second texture
float2 offset = float2(_Distort*(col.x-.5),_Distort*(col.y-.5));
//Get the colors from the second texture, using the offset to distort the image
half4 col2 = tex2D(_Tex2,i.tex.xy * _MainTex_ST.xy + _MainTex_ST.zw + offset);
//Create a circular mask: if we're close to the edge the value is 0
//If we're by the center the value is 1
//By multipling the final alpha by this, we mask the edges of the box
fixed radA = max(1-max(length(half2(.5,.5)-i.tex.xy)-.25,0)/.25,0);
//Get the mask color from our mask texture
half4 mask = tex2D(_MaskTex,i.tex.xy*_MaskTex_ST.xy + _MaskTex_ST.zw);
//Add the color portion : apply the gradient from the highlight to the color
//To the gray value from the blue channel of the distorted noise
float3 final_color = lerp(_HighLight,_Color, col2.b*.5).rgb
//calculate the final alpha value:
//First combine several of the distorted noises together
float final_alpha = col2.a*col2.g*col.b;
//Apply the a combination of two tendril masks
final_alpha *= mask.g*mask.r;
//Apply the circular mask
final_alpha *= radA;
//Raise it to a power to dim it a bit
//it should be between 0 and 1, so the higher the power
//the more transparent it becomes
final_alpha = pow(final_alpha, _Pow);
//Finally, makes sure its never more than 90% opaque
final_alpha = min(final_alpha, .9);
//We're done! Return the final pixel color!
return float4(final_color, final_alpha);
}
Final Pieces
For the final background, we're going to need a lot more than just one patch of gas. Each patch is just a flat square with our shader rendering it. I ended up using around four patches for the major color swathes, and 12 each for the dark portions and the detailed, bright portions. Add in some randomization for position, scale, and rotation, and here's what we get:
The dimming that's done in the shader is very important. Since the nebulae are background elements, they can't become too bright or they'll risk interfering with the foreground of the game.
Add in some stars...
And for a final touch, make the background a different color. While unrealistic, it adds a lot to the final feel of the environment. In particular, it makes the dark sections of the nebula a lot more noticeable. The background color can also cause certain colors in the nebula to really stand out. The blue here makes the yellow clouds stand out a bit more.
And we're done! With different color palettes, there's a distinct feel for each world, while allowing for a huge amount of variety as well. And best of all, as long as the noise and masks are pre-calculated (you only need a few of each), your computer doesn't have to struggle to create a stunning and dynamic background.
Thanks for reading!
Can you post the code to the entire shader? I’m trying to emulate your results and am having trouble writing the shader.