Recent developments have focused mostly on improving the architecture of the system and utilities for playing around with a scene, without much focus on improving how things look. Specifically, the way a star looks has been bothering me for some time. It simply didn’t look like a star at all.

With some relatively small changes, it’s possible to make it look a lot nicer, which you can see in the image below.

Compare that to how it originally looked.

The most important changes for this result have been:

  • Removing the height map from the sphere
  • Creating a separate shader for stars
  • Increasing the glow effect
  • Adding texture to the star’s surface

I’m still no Procedural Content Generation expert, but I’m starting to get the feeling that I understand OpenGL shaders well enough to work toward specific ideas in my mind. Before I always had to look up how to solve very specific problems instead of playing around with shader concepts myself. It’s nice to see that playing around is improving my skills.

Now, let’s discuss in a bit more detail how this result was achieved.

Extract height map from sphere generation

This is a super obvious one. There is a single implementation to generate a sphere, which was used to generate spheres. Those spheres were used as a basis for the planets and the stars. Unfortunately, when playing around with heightmaps on planets, I did that straight in the sphere-generation code. As a result, the stars also had a height map applied, which had a bit of an odd look to it.

In the new version, the height function is extracted and can be provided as input to the Sphere class. I’m not 100% happy with this implementation, because to me it feels like spheres shouldn’t even really have the concept of a height map, but at least for now, this is sufficient.

#pragma once
 
#include "engine/components/model.hpp"
 
#include <functional>
#include <map>
 
#include <glm/glm.hpp>
 
namespace Engine {
    class Entity;
}
 
namespace Engine::Components::Models {
    class Sphere : public Model {
      public:
        explicit Sphere(Entity& entity, std::function<glm::vec3(float)> colorGenerator,
                        int depth = 4);
        explicit Sphere(Entity& entity, std::function<glm::vec3(float)> colorGenerator,
                        std::function<float(glm::vec3&)> noiseFunction, int depth = 4);
        ~Sphere() override = default;
 
      private:
        (...)
 
        std::function<glm::vec3(float)> m_colorGenerator;
        std::function<float(glm::vec3&)> m_noiseFunction;
    };
}
#include "engine/components/models/sphere.hpp"
 
#include "engine/models/effects/debug_vectors.hpp"
 
#include <glm/gtx/norm.hpp>
#include <math.h>
 
namespace Engine::Components::Models {
    Sphere::Sphere(Entity& entity, std::function<glm::vec3(float)> colorGenerator, int depth)
          : Model{entity, "Sphere Model"}
          , m_depth{depth}
          , m_colorGenerator{colorGenerator} {
        addPostRenderEffect(std::make_unique<DebugVectors>(entity));
    }
    Sphere::Sphere(Entity& entity, std::function<glm::vec3(float)> colorGenerator,
                   std::function<float(glm::vec3&)> noiseFunction, int depth)
          : Model{entity, "Sphere Model"}
          , m_depth{depth}
          , m_colorGenerator{colorGenerator}
          , m_noiseFunction{noiseFunction} {
        addPostRenderEffect(std::make_unique<DebugVectors>(entity));
    }
  
    (...)
 
    auto Sphere::createVertex(glm::vec3 point) -> unsigned int {
        auto normalizedHeight = 0.0f;
        if (m_noiseFunction) {
            normalizedHeight = m_noiseFunction(point);
        }
 
        // Create Vertex at _vertexIndex
        m_vertices.push_back(Vertex{point, // Position
                                    point, // Normal is equal to Position on a Sphere
                                    m_colorGenerator(normalizedHeight)});
 
        // Add 1 to _vertexIndex but return pre-incremented value (Value of vertex created in this
        // method)
        return m_vertexIndex++;
    }
}

Separate shader for sun

This is again a super obvious one. There was a single low-poly shader that was used for planets and stars. The shader included code for lighting, which doesn’t make any sense for a star (where would the light even come from?). With this change, stars look much brighter and look like a source of light.

The code for the shader will be shown in the next section because the noise function is defined in the shader as well.

Adding texture to the star

The original star was completely texture-less, which looks boring. Adding some texture, suddenly makes it look a lot more realistic. One downside though, is the fact that this texture doesn’t match well with the low-poly style. I’ll have to look into this a bit more to get the styles matching.

The texture isn’t implemented as an actual OpenGL texture, but rather as a noise-function inside the shader.

The specific noise function used can be found on this website containing a list of glsl noise functions.

#version 330 core
layout(location = 0) in vec3 vPosition;
layout(location = 1) in vec3 vNormal;
layout(location = 2) in vec3 vColor;
 
// std140 for explicit layout specification (for sharing)
layout(std140) uniform ModelViewProjection {
    mat4 model;
    mat4 view;
    mat4 projection;
};
 
out vec3 originalPosition;
flat out vec3 vertexColor;
 
void main() {
    originalPosition = vPosition;
    // Transform Vertex Position from Local to World Space
    gl_Position = projection * view * model * vec4(vPosition, 1.0f);
    vertexColor = vColor;
}
#version 330 core
out vec4 fColor;
 
flat in vec3 vertexColor;
in vec3 originalPosition;
 
float hash(float n) { return fract(sin(n) * 1e4); }
float hash(vec2 p) { return fract(1e4 * sin(17.0 * p.x + p.y * 0.1) * (0.1 + abs(sin(p.y * 13.0 + p.x)))); }
 
float noise(vec3 x) {
    const vec3 step = vec3(110, 241, 171);
 
    vec3 i = floor(x);
    vec3 f = fract(x);
 
    // For performance, compute the base input to a 1D hash from the integer part of the argument and the 
    // incremental change to the 1D based on the 3D -> 1D wrapping
    float n = dot(i, step);
 
    vec3 u = f * f * (3.0 - 2.0 * f);
    return mix(mix(mix( hash(n + dot(step, vec3(0, 0, 0))), hash(n + dot(step, vec3(1, 0, 0))), u.x),
                   mix( hash(n + dot(step, vec3(0, 1, 0))), hash(n + dot(step, vec3(1, 1, 0))), u.x), u.y),
               mix(mix( hash(n + dot(step, vec3(0, 0, 1))), hash(n + dot(step, vec3(1, 0, 1))), u.x),
                   mix( hash(n + dot(step, vec3(0, 1, 1))), hash(n + dot(step, vec3(1, 1, 1))), u.x), u.y), u.z);
}
 
void main() {
    vec3 vertexColorWithNoise = vertexColor + vec3(0.15f, 0.15f, 0.15f)*noise(originalPosition*5.0f);
 
    fColor = vec4(vertexColorWithNoise, 1.0);
}

You can see that the Vertex Shader passes the original position of the vertex to the fragment shader. The noise must be based on the original position because otherwise, the texture will not seem to stay in place when rotating the star for example.

Realistic star colour

The colour of a star is a function of its temperature, which can be seen in the image below. Star color in function of its temperature in a Hertzsprung-Russel diagram

Here’s an example of what it looks like when using a function approximation for the colours in the Hertzsprung-Russel diagram: Star changing color depending on temperature

#include "color.hpp"
 
#include <algorithm>
#include <math.h>
 
#include "util.hpp"
 
namespace color {
    (...)
 
    glm::vec3 starColor(unsigned int temperature) {
        // Based on
        // https://stackoverflow.com/questions/21977786/star-b-v-color-index-to-apparent-rgb-color
 
        float rawTemperatureIndicator = calculateTemperatureIndicator(temperature);
        float temperatureIndicator    = std::clamp(rawTemperatureIndicator, -0.4f, 2.0f);
 
        glm::vec3 result = glm::vec3();
        double t;
 
        if (temperatureIndicator < 0.0f) {
            t        = temperatureIndicator / 0.4f + 1.0f;
            result.r = 0.61 + (0.11 * t) + (0.1 * t * t);
        } else if (temperatureIndicator < 0.4f) {
            t        = temperatureIndicator / 0.4f;
            result.r = 0.83 + (0.17 * t);
        } else {
            result.r = 1.00;
        }
 
        if (temperatureIndicator < 0.0f) {
            t        = temperatureIndicator / 0.4f + 1.0f;
            result.g = 0.70 + (0.07 * t) + (0.1 * t * t);
        } else if (temperatureIndicator < 0.4f) {
            t        = temperatureIndicator / 0.4f;
            result.g = 0.87 + (0.11 * t);
        } else if (temperatureIndicator < 1.6f) {
            t        = (temperatureIndicator - 0.4f) / 1.2f;
            result.g = 0.98 - (0.16 * t);
        } else {
            t        = (temperatureIndicator - 1.60) / 0.4f;
            result.g = 0.82 - (0.5 * t * t);
        }
 
        if (temperatureIndicator < 0.4f) {
            t        = (temperatureIndicator + 0.40) / 0.8f;
            result.b = 1.00;
        } else if (temperatureIndicator < 1.5f) {
            t        = (temperatureIndicator - 0.40) / 1.1f;
            result.b = 1.00 - (0.47 * t) + (0.1 * t * t);
        } else if (temperatureIndicator < 1.94f) {
            t        = (temperatureIndicator - 1.50) / 0.44f;
            result.b = 0.63 - (0.6 * t * t);
        }
 
        return result;
    }
 
    float calculateTemperatureIndicator(unsigned int temperature) {
        float floatTemperature = (float)temperature;
 
        return (-2.1344 * floatTemperature + 8464 +
                sqrt(0.98724096 * floatTemperature * floatTemperature + 71639296)) /
               (1.6928 * floatTemperature);
    }
}

Glow effect as a component

The actual change to the glow effect itself was to increase the offset, basically increasing the radius. More importantly, effects have now been moved to the component-based system, finalising the transition. Effects were the last type of components that had to be refactored.

Next steps

Although this already looks a lot better, there is one big improvement that I want to look into, which is the bloom effect.