Foreword
This document (hopefully) will help you integrate the sky module of my HLSL Sky Demo into your own project.
It covers the following points :
- The engine model, what the overriden methods do and where the data comes from;
- The sky model, how the meshes and shaders work;
- The different settings that have an effect on how the sky is rendered;
- What can be taken off or moved to initialization;
- The known problems and annoyances, and what causes them;
- Future work, things you could do to make it look better.
1 - Engine Model
You'll find that most classes, including the one that interests us (
Sky.cs), inherit from
CoreUser. This is
a little pattern that probably has a name in some book somewhere but I just made it from the top of my head.
Basically, all
CoreUsers have access to all "Factory" TrueVision3D classes; i.e.
TVMaterialFactory,
TVTextureFactory,
TVScene,
TVLightEngine, etc. With the exception of
TVInputEngine
which is added for convenience. The thing is that they did not create them; they access singleton instances made in
Core.cs.
Now, during the mainloop in
Core, there are three stages :
- A
foreach iterates through all CoreUsers and runs the ForcedUpdate method. This is an update method that says "what I contain MUST be run each and every loop";
- Then, if the
UpdateDelay is ready, it runs the Update method. These are all the calculations that are not essential
every loop, but need to be done at a set interval;
- And finally, another iteration calls the
Render method, every loop (obviously).
I am saying all this because there is a
whole lot of stuff in
Sky that must NOT be called each and every loop, because
they are very CPU-intensive and instruction heavy : the sun position calculation, the distrubution coefficients calculation, the zenith
chromacity calculation.
These two last calculations were taken out of the sky Vertex Shader for this very reason; a vertex shader
has to be run each loop,
it transforms the vertices to clip space. But my calculations did not need that, so I transfered them to C# code (following Waterman's advice,
by the way).
So, however your engine works, make sure you have a seperate "thread" (not in a literal sense...) that does those calculations. You'll lose
pointless FPS otherwise.
Waterman proposed
another (and better) way of load balancing,
which spans the action on different frames based on a modulus operation. This way even less work is done every loop, and it still looks
good. I encourage you to implement that in some way. :)
Other thing, I don't use
AccurateTimeElapsed or any TV3D time-calculation stuff of any kind. I've had nothing but bad experiences
with it. If it works for you, fine, use it. I use WinAPI functions to get precision timers.
Anyway, the
_accumulatedTime(for
Update) and
_elapsedTime(for
ForcedUpdate) represent
the amount of milliseconds that occured since the last update, or in the current loop, so it should be fairly easy to adapt it.
I also use a .Net
DateTime object for the game-time. This was the simplest way to get useable time calculations, and I encourage
you to use this too. It's just so damn simple. :)
On the file structure side, the only two files that you'd need to carry in your project would be
Sky.cs and
SkyMaths.cs.
The separation between the two files is really conceptual, because you could put everything from
SkyMaths to
Sky without
problems. But it helps on readability and clarity.
2 - Sky Model
Here is a wireframe screenshot of the domes. You can tell that there are two meshes; one finely tessellated and the other alot less :
Hemisphere
The finer one is Hemisphere.tvm. It is a half-geosphere made with 3D Studio MAX. I opted for a geosphere, because the normal
sphere primitive has a lot more tessellation in the top, and little on the sides; as the color changes are alot "tighter" on the horizon
than at zenith, this was not a good option. A geosphere has perfect distribution of polygons all across its surface, and looked like the
best option.
The hemisphere mesh could be tessellated a little less for more speed, but he less polygons you put, the less detail you have in the
sunrise/sunset transitions.
The hemisphere is used to represent the sky colors depending on the sun position, and to paint the stars during night-time.
It is mapped to SkyShader.fx. This shader effect file is mostly a huge vertex shader which calculates the un-lit vertex colors
for each vertex, and a small pixel shader which interpolates the vertex color with the stars texture when the night falls.
Clouds Dome
The rougher one is Dome.tvm. It is a squeezed half-sphere made with 3D Studio MAX. It's made so that the zenith is almost planar
and the horizon drops really fast. It doesn't need high poly-count because no work is done in the vertex shader, only the pixel shader.
This one is mapped to CloudsShader.fx. The shader is actually very simple. It takes the cloud colors, the layers opacities, the
clouds translation and scale, and then mixes both cloud layers to the single resulting texel.
The former implementation (in my 6.2 Sky Demo) used two separate domes. This was a total waste of polygons, since they were the same shape and
size. Now, it's a single dome but two blended texture layers, and better performance.
3 - Settings
Several settings have been made modifiable in real-time in the demo platform : turbidity, wind power, clouds size,
cloud layers opacity, and the game-time factor. I'll skip on the game-time factor because it's not related to the
sky rendering stuff, it's just for demo purposes.
There is also a couple of other settings that I classified as "constants" in code, because they shouldn't be changed at runtime.
Turbidity
Turbidity measures how thick the atmosphere is. A high (4-6) turbidity level implies the following :
- Longer sunset/sunrise sequences;
- Darker horizon colors (tend towards grey and blue-green);
- Sunrise/sunset sky color are more reddish than yellowish;
- The fog is thicker as well, so the faked aerial perspective is more noticeable.
The fog is EXP2 model, and the density is set with TV3D code using the following factors :
_atmo.Fog_SetParameters(0f, 0f, (float)(_turbidity / 8500d));
_atmo is there a
TVAtmosphere object.
The turbidity settings are also clamped from 2 to 6. The reasons behind this are a little obscure, but all implementations
I've seen on GameDev.Net agree on this : even if the original paper uses turbidity levels up to 30 to do entierly
overcast skies, the Perez function (used in sky color computation) return values are almost undefined after turbidities
of 6-7, and below 2.
They suggest using the CIE Overcast Sky Model for turbidities over 6, but it looked like a waste of time to me for the time
being. Instead, maybe the darken the clouds' color, put both layers' opacity to 1.0f and make the cloud size bigger,
with a turbidity of 6.
Here are a couple of thumbnails of the same time at different turbidities (2, 3.5 and 6). First series of shots is taken at 6:00 AM,
and second at 11:00 AM.
It might like look like the sunrise/sunset is clearer in higher turbidities; that's because at the same time, the sky colors
are later in the sunrise sequence at higher turbidities. That's what I meant by "longer sunset/sunrise sequences".
The blue-green tones are also noticeable in the day sky. It may be an error in the color distribution, but it gives a "stormy
skies" feel too...
Another thing that's worth mentioning is that turbidity changes have a lot more effect in the early numbers (2, 3) than in the
later ones (4-6).
Turbidity is a complex effect, and it has incidences on nearly everything in the scene. I'll cover other points later. I find
that the perfect all-around turbidity is ~2.25, but that's up to you of course...
Wind Power
Well, that's alot more straightforward.
Wind power defines the direction and speed at which the cloud layers scroll on the cloud-dome. It's a TV_2DVECTOR,
so that .x defines the X component of the direction, and .y defines the Z component. It's in very
small numbers, and the speed in accumulated in two other vectors (_cloudsTranslationOuter and _cloudsTranslationInner)
that define the amount of u/v texture coordinates that are scrolled.
You can't control each layer's wind speed, because the inner layer (composed of smaller clouds) are hard-coded to move two times faster
than the outside layer, based on the general speed that is set.
Clouds Size
It's a linear scale of the cloud-dome texture coordinates for each layer. In this case, the .x of the TV_2DVECTOR
means the inner layer, and .y the outer layer.
Cloud Layers Opacity
Alpha level of both cloud layers, how much they appear. On a 2 turbidity level, you should lay off the clouds normally. Or put nice big
cumulus ones (outer layer), not the thin saturated inner layer.
Other Settings ("Constants")
In the "Constants" region, you'll find some other parameters :
- Latitude : The observer's latitude, in radians. Negative values are south, positive are north, range is from -PI/2 to PI/2. Have fun experimenting, it really works with seasons!
- Sky Radius : The size of the sky meshes. If you have a bigger world and the sky overlaps your landscape, you can raise that value. Just raise the far-plane distance as well...
- Fog Night Color : This is a hack because normally, the fog color is half of the clouds color, which give a fairly nice effect when the sun is up. In night-time, I like to have visible clouds and very dark fog, so I put this one in the equation. You can change it of course.
- Clouds Night Color : Another hack, normally the clouds color is based on a color lookup using the same forumlaes that do the sky coloring. Just that during night-time, I like my clouds blueish-gray.
- Clouds Day Color : The obvious third hack. If this wasn't done, the clouds would be blue during daytime. Sonic says : "That's no good".
The other two aren't parameters, they're quite absolute (trial and error).
4 - Optional And Movable Parts
For optimization's sake, you might want to tear my classes up to bits and shreds then make it whole again. It's open source, I can't stop you from
doing that.
I wish I could though. But I'd still like to give you some pointers on what will make it all crash, and what doesn't matter
at all.
Optional Parts
- Everything concerning
LineFactory.Line objects. This is debugging and informational stuff, not linked to rendering in any way;
- The
ManageInput method.
Movable Parts
Another thing is that many calculations are done every
Update and could very well be done once at initialization in other contexts :
- The
_distribCoeffs xyY colors coeffecients are calculated and passed to the sky shader every Update. This is only necessary when the turbidity factor can change, and then again there could be a "TurbidityChanged" event or something to prevent over-calculation;
- The
gamma factor (set to _invGammaCorrection and _invPowLumFactor in the sky shader) is calculated everytime as well, and if turbidity doesn't change you can do it once at init;
- The aforementionned
TVAtmosphere.Fog_SetParameters call is not necessary everytime if turbidity doesn't change either;
- The
_cloudsSize and _layersOpacity shader params are set and sent every loop, but if they don't change, there is no use to do that.
- There is a Ray
Collision check made everytime against the landscape, it's necessary but could be done a little less often, moreover considering that you'll have more meshes than just a landscape in your game, and you want them to stop the flares too.
The rest is pretty much fixed, but nothing restrains you from trying to chop out some more stuff.
5 - Known Issues
Sadly, there is a number of issues that I couldn't really fix, and had to work around. Some of these workarounds are costly on the FPS side or
on the code structure side, so you'd better know about them.
Luminance Clamping = Headache
The xyY color values calculated by the distribution function are composed of two Chromacity coordinates (
x,
y) and one
Luminance factor (
Y). Of course to make that renderable, we need to transfer them to RGB space, and there are documented ways to
do that, with linear transformations.
The problem is that the luminance is not 0-1 like chromacity coordinates are. I guess that the initial paper planned to use
HDRI so that the actual lighting emitted by the sun is weighted properly. But since we're
in
LDR colors, we need to clamp that to 0-1 in some way.
But luminance isn't
only a measure of how bright the color is; it also kinda defines its saturation and how pastel it is. It relates
to color temperature I believe.
So, after a couple of days of experimentation, I found that not one, but
two exponential factors on luminance and another gamma correction
on the final RGB color produced the best results. Here are the C# code lines :
float gamma = 1f / (1.6f + (_turbidity - 2f) * 0.1f);
_skyShader.SetEffectParamFloat("_invGammaCorrection", 1.5f * gamma);
_skyShader.SetEffectParamFloat("_invPowLumFactor", gamma);
_skyShader.SetEffectParamFloat("_invNegMaxLum", -1.25f / SkyMaths.MaximumLuminance(_turbidity, sunTheta, _zenithColors, _distribCoeffs));
...And how they're used in the HLSL vertex shader :
skyColor[2] = pow(1.0f - exp(_invNegMaxLum * skyColor[2]), _invPowLumFactor);
...
OUT.vertexColor = pow(OUT.vertexColor, _invGammaCorrection);
skyColor[2] is the third index, so the
Y luminance factor.
So basically :
- The more turbidity, the darker the sky, and I found that a little annoying. So I put gamma in relation with it.
gamma is used to scale the luminance as well as the RGB colors. That was a try, it works OK, so I left it.
- Maximum luminance is adapative, it changes as the
sunTheta changes; the luminance when sun is at zenith is obviously higher than when it's at horizon. This assures that we get 0-1 values, because the maximum luminance is evaluated at vertexTheta = sunTheta.
Two
pow and one
exp HLSL instrisic calls per vertex is not what I'd call "fully optimized". But I just can't
manage it otherwise. If you have the courage to venture in there, try other values and methods, and tell me if you get something good. :)
Moon and Ambient Lighting Hacks
You might see a
TODO tag in my code in the following lines :
// TODO : A bug with ambient lighting forces me to use the material instead...
float ambientColorRG = -0.05f * (1f - sunIntensity) + 0.21f;
// _lights.SetGlobalAmbient(ambientColorRG, ambientColorRG, 0.2f);
// _sunLight.SetAmbientColor(ambientColorRG, ambientColorRG, 0.2f);
_matFact.SetAmbient(_globals.GetMat("Matte"), ambientColorRG, ambientColorRG, 0.2f, 1.0f);
First, I wanted to implement another directional light for the moon. Then Sylvain told me that for the time being, only a single
simultaneous directional light is supported by the LightEngine.
OK. Ambient lighting was then the logical way, and anyway it'd be faster this way. But another wall; the
SetGlobalAmbient
method does not work! Sylvain had no idea why when I asked him.
Alright... I can manage it... let's just use the light's ambient color, with a fully ambient-recieving terrain material.
STILL DOESN'T WORK! Aaah, the joy of beta software...
So I had to resort to the last option; play with the ambient color of the material itself. That is really annoying, because if you have ten
materials in your scene, you'd have to iterate through all of them and change their color every
Update call. But eh.
Can't
say I didn't try now can you.
AttachTo vs. Code Repetition
Another
TODO tag a little later :
camPos = _camera.GetPosition();
_skyHemisphere.SetPosition(camPos.x, 0f, camPos.z);
_cloudsDome.SetPosition(camPos.x, 0f, camPos.z); // TODO : AttachTo should work soon
Soon... well the next beta DLL should work.
Thing is that I can't attach the clouds dome to the sky hemisphere and use relative scale and position. Looks like the child
doesn't inherit its parent mesh matrix properly. There were a number of posts in the Beta forum about this, and it should get fixed
soon enough. Anyway, it's not that bad, only a couple unneeded lines.
6 - Future Work
For one thing, clouds are nowhere near being perfect in this demo. Waterman's pixel-shaded clouds have a lot more depth,
and a little research on that would surely help making the sky alot more realistic.
It would be nice making the shaders with SM 1.1-compatibility, with ps_1_4 and vs_1_1 codepaths. I tried, and suceeded with
the clouds shader (just replace the pow intrinsic by a texture lookup), but it made it slower so I left it behind. The sky shader
is something else though... It has a full 249 vertex shader instructions, and must fit in 128. The biggest problem is the lack of preshaders
in vs_1_1, which makes all calculations done every vertex. Lots of stuff would need to be done in the C# code, and it would probably make things
alot messier.
But of course I don't plan on doing any of this. Unless I have a lot of courage and time on my hands. :P
...thanks for reading!