// shadermanager.cpp
//
// Copyright (C) 2001-present, the Celestia Development Team
// Original version by Chris Laurel <claurel@gmail.com>
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.

#include "shadermanager.h"

#include <algorithm>
#include <cassert>
#include <cstddef>
#include <cmath>
#include <filesystem>
#include <fstream>
#include <iomanip>
#include <iterator>
#include <optional>
#include <ostream>
#include <string_view>
#include <system_error>
#include <tuple>
#include <utility>

#include <boost/container_hash/hash.hpp>

#include <fmt/format.h>
#include <fmt/ostream.h>

#include <celutil/flag.h>
#include <celutil/logger.h>
#include "atmosphere.h"
#include "body.h"
#include "glsupport.h"
#include "lightenv.h"


using celestia::util::GetLogger;
using namespace std::string_view_literals;
namespace gl = celestia::gl;
namespace util = celestia::util;

#define POINT_FADE 1

namespace
{
constexpr const int ShadowSampleKernelWidth = 2;

const std::filesystem::path ShaderDirectory{ "shaders" };

constexpr std::array StaticShaderBaseNames
{
    "comet"sv,
    "crosshair"sv,
    "depth"sv,
    "galaxy"sv,
    "globular"sv,
    "largestar"sv,
    "psfstarglow"sv,
    "psfstarglowlarge"sv,
    "psfstarpoint"sv,
    "srgb"sv,
    "selpointer"sv,
    "star"sv,
    "text"sv,
    "tidal"sv,
    "warpmesh"sv,
};

static_assert(StaticShaderBaseNames.size() == static_cast<std::size_t>(StaticShader::_Count));

constexpr unsigned int ShadowBitsPerLight = 8;

enum ShaderVariableType
{
    Shader_Float,
    Shader_Integer,
    Shader_Vector2,
    Shader_Vector3,
    Shader_Vector4,
    Shader_Matrix4,
    Shader_Sampler1D,
    Shader_Sampler2D,
    Shader_Sampler3D,
    Shader_SamplerCube,
    Shader_Sampler1DShadow,
    Shader_Sampler2DShadow,
};

enum ShaderInOut
{
    Shader_In,
    Shader_Out
};

constexpr std::string_view errorVertexShaderSource = R"glsl(
layout(location = 0) in vec4 in_Position;
void main(void)
{
    gl_Position = MVPMatrix * in_Position;
}
)glsl"sv;

constexpr std::string_view errorFragmentShaderSource = R"glsl(
void main(void)
{
   fragColor = vec4(1.0, 0.0, 0.0, 1.0);
}
)glsl"sv;

#ifdef GL_ES
constexpr std::string_view VersionHeader = "#version 300 es\n"sv;
constexpr std::string_view CommonHeader = "precision highp float;\nprecision highp sampler2DShadow;\n"sv;
#else
constexpr std::string_view VersionHeader = "#version 330\n"sv;
constexpr std::string_view CommonHeader = "\n"sv;
#endif
constexpr std::string_view VertexHeader = R"glsl(
uniform mat4 ModelViewMatrix;
uniform mat4 ProjectionMatrix;
uniform mat4 MVPMatrix;

invariant gl_Position;
)glsl"sv;

constexpr std::string_view VPFunctionFishEye = R"glsl(
vec4 calc_vp(vec4 in_Position)
{
    float PID2 = 1.570796326794896619231322;
    vec4 inPos = ModelViewMatrix * in_Position;
    float l = length(inPos.xy);
    if (l != 0.0)
    {
        float phi = atan(l, -inPos.z);
        float lensR = phi / PID2;
        inPos.xy *= (lensR / l);
    }
    return ProjectionMatrix * inPos;
}
void set_vp(vec4 in_Position)
{
    gl_Position = calc_vp(in_Position);
}
)glsl"sv;

constexpr std::string_view VPFunctionUsual = R"glsl(
vec4 calc_vp(vec4 in_Position)
{
    return MVPMatrix * in_Position;
}
void set_vp(vec4 in_Position)
{
    gl_Position = calc_vp(in_Position);
}
)glsl"sv;

constexpr std::string_view
VPFunction(bool enabled)
{
    return enabled ? VPFunctionFishEye : VPFunctionUsual;
}

constexpr std::string_view NormalVertexPosition = R"glsl(
    set_vp(in_Position);
)glsl"sv;

constexpr std::string_view LineVertexPosition = R"glsl(
    vec4 thisPos = calc_vp(in_Position);
    vec4 nextPos = calc_vp(in_PositionNext);
    thisPos.xy /= thisPos.w;
    nextPos.xy /= nextPos.w;
    vec2 transform = normalize(nextPos.xy - thisPos.xy);
    transform = vec2(transform.y * lineWidthX, -transform.x * lineWidthY) * in_ScaleFactor;
    gl_Position = vec4((thisPos.xy + transform) * thisPos.w, thisPos.zw);
    lineU = in_LineSide;
)glsl"sv;

std::string_view
VertexPosition(const ShaderProperties& props)
{
    return util::is_set(props.texUsage, TexUsage::LineAsTriangles) ? LineVertexPosition : NormalVertexPosition;
}

constexpr std::string_view FragmentHeader = R"glsl(
out vec4 fragColor;
)glsl"sv;

constexpr std::string_view CommonAttribs = R"glsl(
layout(location = 0) in vec4 in_Position;
layout(location = 1) in vec3 in_Normal;
layout(location = 2) in vec4 in_TexCoord0;
layout(location = 3) in vec4 in_TexCoord1;
layout(location = 4) in vec4 in_TexCoord2;
layout(location = 5) in vec4 in_TexCoord3;
layout(location = 8) in vec4 in_Color;
)glsl"sv;

constexpr std::string_view TextureTransformUniforms = R"glsl(
uniform vec2 texCoordBase0;
uniform vec2 texCoordBase1;
uniform vec2 texCoordBase2;
uniform vec2 texCoordBase3;
uniform vec2 texCoordDelta0;
uniform vec2 texCoordDelta1;
uniform vec2 texCoordDelta2;
uniform vec2 texCoordDelta3;
)glsl"sv;

std::string
LightProperty(unsigned int i, std::string_view property)
{
    return fmt::format("light{}_{}", i, property);
}

std::string
ApplyShadow(bool hasSpecular)
{
    constexpr std::string_view code = R"glsl(
if (cosNormalLightDir > 0.0)
{{
    shadowMapCoeff = calculateShadow();
    diff.rgb *= shadowMapCoeff;
    {}
}}
)glsl"sv;

    return fmt::format(code, hasSpecular ? "spec.rgb *= shadowMapCoeff;"sv : ""sv);
}

constexpr std::string_view
ShaderTypeString(ShaderVariableType type)
{
    switch (type)
    {
    case Shader_Float:
        return "float";
    case Shader_Integer:
        return "int";
    case Shader_Vector2:
        return "vec2";
    case Shader_Vector3:
        return "vec3";
    case Shader_Vector4:
        return "vec4";
    case Shader_Matrix4:
        return "mat4";
    case Shader_Sampler1D:
        return "sampler1D";
    case Shader_Sampler2D:
        return "sampler2D";
    case Shader_Sampler3D:
        return "sampler3D";
    case Shader_SamplerCube:
        return "samplerCube";
    case Shader_Sampler1DShadow:
        return "sampler1DShadow";
    case Shader_Sampler2DShadow:
        return "sampler2DShadow";
    default:
        return "unknown";
    }
}

std::string
IndexedParameter(std::string_view name, unsigned int index0)
{
    return fmt::format("{}{}", name, index0);
}

std::string
IndexedParameter(std::string_view name, unsigned int index0, unsigned int index1)
{
    return fmt::format("{}{}_{}", name, index0, index1);
}

std::string
DeclareUniform(std::string_view name, ShaderVariableType type)
{
    return fmt::format("uniform {} {};\n", ShaderTypeString(type), name);
}

std::string
DeclareInput(std::string_view name, ShaderVariableType type)
{
    return fmt::format("in {} {};\n", ShaderTypeString(type), name);
}

std::string
DeclareOutput(std::string_view name, ShaderVariableType type)
{
    return fmt::format("out {} {};\n", ShaderTypeString(type), name);
}

std::string
DeclareAttribute(std::string_view name, ShaderVariableType type, int location)
{
    return fmt::format("layout(location = {}) in {} {};\n", location, ShaderTypeString(type), name);
}

std::string
DeclareLocal(std::string_view name, ShaderVariableType type)
{
    return fmt::format("{} {};\n", ShaderTypeString(type), name);
}

std::string
DeclareLocal(std::string_view name, ShaderVariableType type, std::string_view value)
{
    return fmt::format("{} {} = {};\n", ShaderTypeString(type), name, value);
}

std::string
RingShadowTexCoord(unsigned int index)
{
    return fmt::format("ringShadowTexCoord.{}", "xyzw"[index]);
}

std::string
CloudShadowTexCoord(unsigned int index)
{
    return fmt::format("cloudShadowTexCoord{}", index);
}

void
DumpShaderSource(std::ostream& out, std::string_view source)
{
    std::size_t lineNumber = 1;
    while (!source.empty())
    {
        auto pos = source.find('\n');
        auto line = source.substr(0, pos);
        fmt::print(out, "{}: {}\n", lineNumber, line);
        if (pos == std::string_view::npos)
            break;

        source = source.substr(pos + 1);
        ++lineNumber;
    }

    out.flush();
}

void DumpVSSource(std::string_view source)
{
    if (g_shaderLogFile == nullptr)
        return;

    *g_shaderLogFile << "Vertex shader source:\n";
    DumpShaderSource(*g_shaderLogFile, source);
    *g_shaderLogFile << '\n';
}

inline void DumpFSSource(std::string_view source)
{
    if (g_shaderLogFile != nullptr)
    {
        *g_shaderLogFile << "Fragment shader source:\n";
        DumpShaderSource(*g_shaderLogFile, source);
        *g_shaderLogFile << '\n';
    }
}

std::string
DeclareLights(const ShaderProperties& props)
{
    if (props.nLights == 0)
        return {};

    std::string source;
    for (unsigned int i = 0; i < props.nLights; i++)
    {
        source += DeclareUniform(fmt::format("light{}_direction", i), Shader_Vector3);
        source += DeclareUniform(fmt::format("light{}_diffuse", i), Shader_Vector3);
        if (props.hasSpecular())
        {
            source += DeclareUniform(fmt::format("light{}_specular", i), Shader_Vector3);
            source += DeclareUniform(fmt::format("light{}_halfVector", i), Shader_Vector3);
        }
        if (props.lightModel == LightingModel::AtmosphereModel || props.hasScattering())
            source += DeclareUniform(fmt::format("light{}_color", i), Shader_Vector3);
        if (util::is_set(props.texUsage, TexUsage::NightTexture))
            source += DeclareUniform(fmt::format("light{}_brightness", i), Shader_Float);
    }

    return source;
}


std::string
SeparateDiffuse(unsigned int i)
{
    // Used for packing multiple diffuse factors into the diffuse color.
    return fmt::format("diffFactors.{}", "xyzw"[i & 3]);
}


std::string
SeparateSpecular(unsigned int i)
{
    // Used for packing multiple specular factors into the specular color.
    return fmt::format("specFactors.{}", "xyzw"[i & 3]);
}


// Used by rings shader
std::string
ShadowDepth(unsigned int i)
{
    return fmt::format("shadowDepths.{}", "xyzw"[i & 3]);
}


std::string
TexCoord2D(unsigned int i, bool transform)
{
    return transform ? fmt::format("(texCoordBase{} + texCoordDelta{} * in_TexCoord{}.st)", i, i, i) : fmt::format("in_TexCoord{}.st", i);
}


// Tangent space light direction
std::string
LightDir_tan(unsigned int i)
{
    return fmt::format("lightDir_tan_{}", i);
}


std::string
ScatteredColor(unsigned int i)
{
    return fmt::format("scatteredColor{}", i);
}


std::string
TangentSpaceTransform(std::string_view dst, std::string_view src)
{
    return fmt::format("{0}.x = dot(T, {1});\n"
                       "{0}.y = dot(-bitangent, {1});\n"
                       "{0}.z = dot(N, {1});\n",
                       dst, src);
}


constexpr std::string_view
NightTextureBlend()
{
#if 1
    // Output the blend factor for night lights textures
    return "totalLight = 1.0 - totalLight;\ntotalLight = totalLight * totalLight * totalLight * totalLight;\n"sv;
#else
    // Alternate night light blend function; much sharper falloff near terminator.
    return "totalLight = clamp(10.0 * (0.1 - totalLight), 0.0, 1.0);\n"sv;
#endif
}


std::string
AssignDiffuse(unsigned int lightIndex, const ShaderProperties& props)
{
    if (!props.usesShadows() && !props.hasSpecular())
        return fmt::format("diff.rgb += {} * ", LightProperty(lightIndex, "diffuse"));
    else
        return fmt::format("{} = ", SeparateDiffuse(lightIndex));
}


std::string
LunarLambert()
{
    std::string source;

    source += "phaseAngle = degrees(acos(clamp(cosPhaseAngle, -1.0, 1.0)));\n";

    // Limb-darkening parameter L(alpha) for the Moon from McEwen (1996),
    // Lunar and Planetary Science, volume 27, page 841.
    source += "lunarLimbDarkening = 1.0 + phaseAngle * (-0.019 + phaseAngle * (0.242e-3 - phaseAngle * 1.46e-6));\n";

    // While L(alpha) is intended to be used on its own to weigh the Lambert and
    // Lommel-Seeliger functions which compose the lunar-Lambert model, here it
    // is multiplied by the LunarLambert parameter as a rough way to generalize
    // it to other bodies.
    source += "lunarWeight = lunarLambert * max(lunarLimbDarkening, 0.0);\n";

    return source;
}


// Values used in generated shaders:
//    N - surface normal
//    V - view vector: the normalized direction from vertex to eye
//    L - light direction: normalized direction from vertex to light
//    H - half vector for Blinn-Phong lighting: normalized, bisects L and V
//    R - reflected light direction
//    NL - dot product of light and normal vectors
//    NV - dot product of light and view vectors
//    NH - dot product of light and half vectors

std::string
AddDirectionalLightContrib(unsigned int i, const ShaderProperties& props)
{
    std::string source;

    if (props.lightModel == LightingModel::ParticleDiffuseModel)
    {
        // The ParticleDiffuse model doesn't use a surface normal; vertices
        // are lit as if they were infinitesimal spherical particles,
        // unaffected by light direction except when considering shadows.
        source += "NL = 1.0;\n";
    }
    else if (props.effects == LightingEffects::CloudLighting)
    {
        // GPU Gems, Chapter 16.2, "Simple Scattering Approximations":
        // https://developer.nvidia.com/gpugems/gpugems/part-iii-materials/chapter-16-real-time-approximations-subsurface-scattering
        std::string sunCos = IndexedParameter("cloudSunCos", i);
        std::string twilight = IndexedParameter("cloudTwilight", i);
        source += "float " + sunCos + " = dot(N, " + LightProperty(i, "direction") + ");\n";
        source += "float " + twilight + " = max(0.0, (" + sunCos
                  + " + cloudHorizon) / (1.0 + cloudHorizon));\n";
        source += "NL = " + twilight + ";\n";
    }
    else
    {
        source += "NL = max(0.0, dot(N, " + LightProperty(i, "direction") + "));\n";
    }

    if (props.usesTangentSpaceLighting())
    {
        source += TangentSpaceTransform(LightDir_tan(i), LightProperty(i, "direction"));
    }
    else if (props.hasSpecular())
    {
        if (util::is_set(props.lightModel, LightingModel::LunarLambertModel))
        {
            source += "cosPhaseAngle = dot(eyeDir, " + LightProperty(i, "direction") + ");\n";
            source += LunarLambert();
            source += AssignDiffuse(i, props) + " mix(NL, 2.0 * NL / (max(NV, 0.001) + NL), lunarWeight);\n";
        }
        else
        {
            source += SeparateDiffuse(i) + " = NL;\n";
        }
    }
#if 0
    else if (props.lightModel == LightingModel::OrenNayarModel)
    {
        source += "float cosAlpha = min(NV, NL);\n";
        source += "float cosBeta = max(NV, NL);\n";
        source += "float sinAlpha = sqrt(1.0 - cosAlpha * cosAlpha);\n";
        source += "float sinBeta = sqrt(1.0 - cosBeta * cosBeta);\n";
        source += "float tanBeta = sinBeta / cosBeta;\n";
        source += "float cosAzimuth = dot(normalize(eye - in_Normal * NV), normalize(light - in_Normal * NL));\n";
        // TODO: precalculate these constants; place them in uniform values
        source += "float roughness2 = 0.7 * 0.7;\n";
        source += "float A = 1.0f - (0.5f * roughness2) / (roughness2 + 0.33);\n";
        source += "float B = (0.45f * roughness2) / (roughness2 + 0.09);\n";
        // TODO: add normalization factor so that max brightness is always 1
        // TODO: add gamma correction
        source += "float d = NL * (A + B * sinAlpha * tanBeta * max(0.0, cosAzimuth));\n";
        if (props.usesShadows())
        {
            source += SeparateDiffuse(i) += " = d;\n";
        }
        else
        {
            source += "diff.rgb += " + LightProperty(i, "diffuse") + " * d;\n";
        }
    }
#endif
    else if (util::is_set(props.lightModel, LightingModel::LunarLambertModel))
    {
        source += "cosPhaseAngle = dot(eyeDir, " + LightProperty(i, "direction") + ");\n";
        source += LunarLambert();
        source += AssignDiffuse(i, props) + " mix(NL, 2.0 * NL / (max(NV, 0.001) + NL), lunarWeight);\n";
    }
    else if (props.usesShadows())
    {
        // When there are shadows, we need to track the diffuse contributions
        // separately for each light.
        source += SeparateDiffuse(i) + " = NL;\n";
        if (props.hasSpecular())
            source += SeparateSpecular(i) + " = pow(NH, shininess);\n";
    }
    else
    {
        source += "diff.rgb += " + LightProperty(i, "diffuse") + " * NL;\n";
        if (props.hasSpecular())
            source += "spec.rgb += " + LightProperty(i, "specular") + " * (pow(NH, shininess) * NL);\n";
    }

    if (util::is_set(props.texUsage, TexUsage::NightTexture) && !props.usesTangentSpaceLighting())
        source += "totalLight += NL * " + LightProperty(i, "brightness") + ";\n";

    return source;
}


std::string
BeginLightSourceShadows(const ShaderProperties& props, unsigned int light)
{
    std::string source;

    if (props.usesTangentSpaceLighting())
    {
        if (props.hasShadowsForLight(light))
            source += "shadow = 1.0;\n";
    }
    else
    {
        source += "shadow = " + SeparateDiffuse(light) + ";\n";
    }

    if (props.hasRingShadowForLight(light))
    {
        const std::string ringShadowTexCoord = IndexedParameter("ringShadowTexCoord", light);

        source += DeclareLocal(ringShadowTexCoord,
                               Shader_Float,
                               RingShadowTexCoord(light));
        source += "if ((dot(position * ringScale, ringPlane.xyz) + ringPlane.w) * dot(" +
                  LightProperty(light, "direction") + ", ringPlane.xyz) <= 0.0 &&\n";
        source += "    " + ringShadowTexCoord + " >= 0.0 && " + ringShadowTexCoord + " <= 1.0)\n{\n";
        source += "shadow *= 1.0 - textureLod(ringTex, vec2(" + ringShadowTexCoord + ", 0.0), " +
                  IndexedParameter("ringShadowLOD", light) + ").a;\n";
        source += "}\n";
    }

    if (props.hasCloudShadowForLight(light))
    {
        source += "shadow *= 1.0 - texture(cloudShadowTex, " + CloudShadowTexCoord(light) + ").a * 0.75;\n";
    }

    return source;
}


// Calculate the depth of an eclipse shadow at the current fragment. Eclipse
// shadows are circular, decreasing in depth from maxDepth at the center to
// zero at the edge of the penumbra.
// Eclipse shadows are approximate. They assume that the both the sun and
// occluding body are spherical. An oblate planet is treated as if its polar
// radius were equal to its equatorial radius. This produces quite accurate
// eclipses for major moons around giant planets, which orbit close to the
// equatorial plane of the planet.
//
// The radius of the shadow umbra and penumbra are computed accurately
// (to the limit of the spherical approximation.) The maximum shadow depth
// is also calculated accurately. However, the shadow falloff from from
// the umbra to the edge of the penumbra is approximated as linear.
std::string
Shadow(unsigned int light,
       unsigned int shadow,
       std::string_view position = "position"sv,
       std::string_view visibility = "shadow"sv,
       std::string_view center = "shadowCenter"sv,
       std::string_view radius = "shadowR"sv)
{
    std::string source;

    source += std::string(center) + ".s = dot(vec4(" + std::string(position) + ", 1.0), " +
        IndexedParameter("shadowTexGenS", light, shadow) + ") - 0.5;\n";
    source += std::string(center) + ".t = dot(vec4(" + std::string(position) + ", 1.0), " +
        IndexedParameter("shadowTexGenT", light, shadow) + ") - 0.5;\n";

    // The shadow shadow consists of a circular region of constant depth (maxDepth),
    // surrounded by a ring of linear falloff from maxDepth to zero. For a total
    // eclipse, maxDepth is zero. In reality, the falloff function is much more complex:
    // to calculate the exact amount of sunlight blocked, we need to calculate the
    // a circle-circle intersection area.
    // (See http://mathworld.wolfram.com/Circle-CircleIntersection.html)

    // The code generated below will compute:
    // r = 2 * sqrt(dot(shadowCenter, shadowCenter));
    // shadowR = clamp((r - 1) * shadowFalloff, 0, shadowMaxDepth)
    source += std::string(radius) + " = clamp((2.0 * sqrt(dot(" + std::string(center) + ", " +
        std::string(center) + ")) - 1.0) * " +
        IndexedParameter("shadowFalloff", light, shadow) + ", 0.0, " +
        IndexedParameter("shadowMaxDepth", light, shadow) + ");\n";

    source += std::string(visibility) + " *= 1.0 - " + std::string(radius) + ";\n";

    return source;
}


std::string
ShadowsForLightSource(const ShaderProperties& props, unsigned int light)
{
    std::string source = BeginLightSourceShadows(props, light);

    for (unsigned int i = 0; i < props.getEclipseShadowCountForLight(light); i++)
        source += Shadow(light, i);

    return source;
}


std::string
ScatteringPhaseFunctions(const ShaderProperties& /*unused*/)
{
    std::string source;

    // Evaluate the Mie and Rayleigh phase functions; both are functions of the cosine
    // of the angle between the view vector and light vector
    source += "    float phMie = (1.0 - mieK * mieK) / ((1.0 + mieK * cosTheta) * (1.0 + mieK * cosTheta));\n";
    source += "    float phRayleigh = 0.75 * (1.0 + cosTheta * cosTheta);\n";

    return source;
}


std::string
ChapmanOpticalDepth()
{
    return R"glsl(float chapmanToSpaceWithScaleHeight(float r, float mu, float inverseScaleHeight)
{
    mu = clamp(mu, -1.0, 1.0);
    float X = r * inverseScaleHeight;
    float density = exp(min((atmosphereRadius.z - r) * inverseScaleHeight, 0.0));
    float grazing = sqrt(1.5707963267948966 * X);
    if (mu >= 0.0)
        return (density / inverseScaleHeight) * grazing / ((grazing - 1.0) * mu + 1.0);
    float sinZenith = sqrt(max(1.0e-8, 1.0 - mu * mu));
    float tangentDensity = exp(min((atmosphereRadius.z - r * sinZenith) * inverseScaleHeight, 0.0));
    float upward = (density / inverseScaleHeight) * grazing / ((grazing - 1.0) * (-mu) + 1.0);
    return max(0.0, (2.0 * tangentDensity / inverseScaleHeight) * sqrt(1.5707963267948966 * X * sinZenith) - upward);
}

float chapmanToSpace(float r, float mu)
{
    return chapmanToSpaceWithScaleHeight(r, mu, mieH);
}

float chapmanFromAtmosphereBoundary(float mu, float grazing)
{
    return (atmosphereExtinctionThreshold / mieH) * grazing /
           ((grazing - 1.0) * clamp(mu, 0.0, 1.0) + 1.0);
}

float chapmanFromAtmosphereBoundaryWithScaleHeight(float mu, float columnScale, float grazing)
{
    return columnScale / ((grazing - 1.0) * clamp(mu, 0.0, 1.0) + 1.0);
}
)glsl";
}

constexpr std::string_view
RayleighViewOpticalDepth()
{
    return R"glsl(
        float rayleighViewStartColumn;
        if (rayleighViewColumnValid && viewInward == cachedViewInward)
            rayleighViewStartColumn = cachedRayleighViewColumn;
        else
            rayleighViewStartColumn = chapmanToSpaceWithScaleHeight(viewStartRadius, viewInward ? -viewStartMu : viewStartMu, rayleighH);
        float rayleighViewEndColumn = chapmanToSpaceWithScaleHeight(viewEndRadius, viewInward ? -viewEndMu : viewEndMu, rayleighH);
        rayleighSegmentDepth = max(0.0, viewInward ? rayleighViewEndColumn - rayleighViewStartColumn : rayleighViewStartColumn - rayleighViewEndColumn);
        cachedRayleighViewColumn = rayleighViewEndColumn;
        rayleighViewColumnValid = true;
)glsl";
}

constexpr std::string_view
SeparateAtmosphereScatteringAccumulation()
{
    return R"glsl(
            float rayleighToSpace = chapmanToSpaceWithScaleHeight(sampleRadius, sampleMu, rayleighH);
            float rayleighOdSun = max(0.0, rayleighToSpace - chapmanFromAtmosphereBoundaryWithScaleHeight(boundaryMu, rayleighBoundaryColumnScale, rayleighBoundaryGrazing));
            odSun = max(0.0, mieToSpace - chapmanFromAtmosphereBoundaryWithScaleHeight(boundaryMu, mieBoundaryColumnScale, atmosphereBoundaryGrazing));
            vec3 sampleTransmittance = exp(-mieExtinctionCoeff * (odAtm + odSun) - rayleighExtinctionCoeff * (rayleighOdAtm + rayleighOdSun));
            vec3 segmentTau = mieExtinctionCoeff * segmentDepth + rayleighExtinctionCoeff * rayleighSegmentDepth;
            vec3 segmentIntegral = (vec3(1.0) - exp(-segmentTau)) / max(segmentTau, vec3(1.0e-18));
            vec3 thinIntegral = vec3(1.0) - segmentTau * 0.5 + segmentTau * segmentTau * (1.0 / 6.0);
            vec3 thinMask = vec3(1.0) - step(vec3(1.0e-3), segmentTau);
            segmentIntegral = mix(segmentIntegral, thinIntegral, thinMask);
            mieScatteredLight += sampleTransmittance * (segmentDepth * segmentIntegral * sunVisibility);
            rayleighScatteredLight += sampleTransmittance * (rayleighSegmentDepth * segmentIntegral * sunVisibility);
)glsl";
}

constexpr std::string_view
LegacyAtmosphereScatteringAccumulation()
{
    return R"glsl(
            odSun = max(0.0, mieToSpace - chapmanFromAtmosphereBoundary(boundaryMu, atmosphereBoundaryGrazing));
            vec3 segmentTau = extinctionCoeff * segmentDepth;
            vec3 segmentIntegral = (vec3(1.0) - exp(-segmentTau)) * invExtinction;
            vec3 thinIntegral = segmentDepth * (vec3(1.0) - segmentTau * 0.5 + segmentTau * segmentTau * (1.0 / 6.0));
            vec3 thinMask = vec3(1.0) - step(vec3(1.0e-3), segmentTau);
            segmentIntegral = mix(segmentIntegral, thinIntegral, thinMask);
            vec3 sampleTransmittance = exp(-extinctionCoeff * (odAtm + odSun));
            scatteredLight += sampleTransmittance * (segmentIntegral * sunVisibility);
)glsl";
}


std::string
AtmosphericEffects(const ShaderProperties& props, unsigned int lightIndex)
{
    std::string source;

    source += "{\n";
    // Compute the intersection of the view direction and the cloud layer (currently assumed to be a sphere)
    source += "    float rq = dot(eyePosition, eyeDir);\n";
    source += "    float qq = dot(eyePosition, eyePosition) - atmosphereRadius.y;\n";
    source += "    float d = sqrt(max(rq * rq - qq, 0.0));\n";
    source += "    vec3 atmEnter = eyePosition + min(0.0, (-rq + d)) * eyeDir;\n";
    source += "    vec3 atmLeave = nposition;\n";

    source += "    float distAtm = length(atmEnter - atmLeave);\n";
    source += "    vec3 viewDirection = -eyeDir;\n";
    source += "    float inverseSegmentCount = 1.0 / float(atmosphereSegmentCount);\n";
    source += "    vec3 atmStep = (atmLeave - atmEnter) * inverseSegmentCount;\n";
    source += "    float stepLength = distAtm * inverseSegmentCount;\n";
    source += "    vec3 segmentStart = atmEnter;\n";
    source += "    float odAtm = 0.0;\n";
    if (props.separateRayleighMieScaleHeights)
    {
        source += "    float rayleighOdAtm = 0.0;\n";
        source += "    vec3 mieScatteredLight = vec3(0.0);\n";
        source += "    vec3 rayleighScatteredLight = vec3(0.0);\n";
    }
    else
    {
        source += "    vec3 scatteredLight = vec3(0.0);\n";
    }
    source += "    float planetRadiusSq = atmosphereRadius.z * atmosphereRadius.z;\n";
    source += "    float shadowAngularWidth = min(0.2, sqrt(2.0 / (atmosphereRadius.z * mieH)));\n";
    source += "    float shadowWidth = (atmosphereRadius.x + atmosphereRadius.z) * 0.5 * shadowAngularWidth;\n";
    source += "    float atmosphereBoundaryGrazing = sqrt(1.5707963267948966 * atmosphereRadius.x * mieH);\n";
    source += "    float viewStartRadiusSq = dot(segmentStart, segmentStart);\n";
    source += "    float viewStartProjection = dot(segmentStart, viewDirection);\n";
    source += "    float cachedViewColumn = 0.0;\n";
    source += "    bool cachedViewInward = false;\n";
    source += "    bool viewColumnValid = false;\n";
    if (props.separateRayleighMieScaleHeights)
    {
        source += "    float cachedRayleighViewColumn = 0.0;\n";
        source += "    bool rayleighViewColumnValid = false;\n";
    }
    source += "    float viewStartRadius = sqrt(viewStartRadiusSq);\n";
    source += "    float viewStartMu = viewStartProjection / viewStartRadius;\n";
    if (props.separateRayleighMieScaleHeights)
    {
        source += "    vec3 mieExtinctionCoeff;\n";
        source += "    vec3 rayleighExtinctionCoeff;\n";
        source += "    float mieBoundaryColumnScale;\n";
        source += "    float rayleighBoundaryGrazing;\n";
        source += "    float rayleighBoundaryColumnScale;\n";
        source += "    mieExtinctionCoeff = max(vec3(mieCoeff), vec3(0.0));\n";
        source += "    rayleighExtinctionCoeff = max(extinctionCoeff - mieExtinctionCoeff, vec3(0.0));\n";
        source += "    float mieBoundaryDensity = exp(min((atmosphereRadius.z - atmosphereRadius.x) * mieH, 0.0));\n";
        source += "    mieBoundaryColumnScale = (mieBoundaryDensity / mieH) * atmosphereBoundaryGrazing;\n";
        source += "    rayleighBoundaryGrazing = sqrt(1.5707963267948966 * atmosphereRadius.x * rayleighH);\n";
        source += "    float rayleighBoundaryDensity = exp(min((atmosphereRadius.z - atmosphereRadius.x) * rayleighH, 0.0));\n";
        source += "    rayleighBoundaryColumnScale = (rayleighBoundaryDensity / rayleighH) * rayleighBoundaryGrazing;\n";
    }
    else
    {
        source += "    vec3 invExtinction = 1.0 / max(extinctionCoeff, vec3(1.0e-18));\n";
    }
    if (props.getEclipseShadowCountForLight(lightIndex) > 0)
    {
        source += "    float atmosphereShadow;\n";
        source += "    vec2 atmosphereShadowCenter;\n";
        source += "    float atmosphereShadowR;\n";
    }
    if (lightIndex == 0 && props.hasRingShadowForLight(0))
    {
        source += "    vec3 atmosphereRingShadowPosition;\n";
        source += "    vec3 atmosphereRingShadowProjection;\n";
        source += "    float atmosphereRingShadowTexCoord;\n";
    }
    source += "    for (int i = 0; i < atmosphereSegmentCount; ++i)\n";
    source += "    {\n";
    source += "        float viewEndProjection = viewStartProjection + stepLength;\n";
    source += "        float viewEndRadiusSq = max(1.0e-12, viewStartRadiusSq + stepLength * (2.0 * viewStartProjection + stepLength));\n";
    source += "        float viewEndRadius = sqrt(viewEndRadiusSq);\n";
    source += "        float viewEndMu = viewEndProjection / viewEndRadius;\n";
    source += "        bool viewInward = viewEndRadiusSq < viewStartRadiusSq;\n";
    source += "        float viewStartColumn;\n";
    source += "        if (viewColumnValid && viewInward == cachedViewInward)\n";
    source += "            viewStartColumn = cachedViewColumn;\n";
    source += "        else\n";
    source += "            viewStartColumn = chapmanToSpace(viewStartRadius, viewInward ? -viewStartMu : viewStartMu);\n";
    source += "        float viewEndColumn = chapmanToSpace(viewEndRadius, viewInward ? -viewEndMu : viewEndMu);\n";
    source += "        float segmentDepth = max(0.0, viewInward ? viewEndColumn - viewStartColumn : viewStartColumn - viewEndColumn);\n";
    if (props.separateRayleighMieScaleHeights)
    {
        source += "        float rayleighSegmentDepth;\n";
        source += RayleighViewOpticalDepth();
    }
    source += "        cachedViewColumn = viewEndColumn;\n";
    source += "        cachedViewInward = viewInward;\n";
    source += "        viewColumnValid = true;\n";
    source += "        vec3 atmSamplePointSun = segmentStart + atmStep * 0.5;\n";
    source += "        rq = dot(atmSamplePointSun, " + LightProperty(lightIndex, "direction") + ");\n";
    source += "        float sampleRadiusSq = dot(atmSamplePointSun, atmSamplePointSun);\n";
    source += "        float horizonDistance = sqrt(max(0.0, sampleRadiusSq - planetRadiusSq));\n";
    source += "        float sunVisibility = smoothstep(-shadowWidth, shadowWidth, rq + horizonDistance);\n";
    if (props.getEclipseShadowCountForLight(lightIndex) > 0)
    {
        source += "        atmosphereShadow = sunVisibility;\n";
        for (unsigned int i = 0; i < props.getEclipseShadowCountForLight(lightIndex); ++i)
        {
            source += Shadow(lightIndex,
                             i,
                             "atmSamplePointSun"sv,
                             "atmosphereShadow"sv,
                             "atmosphereShadowCenter"sv,
                             "atmosphereShadowR"sv);
        }
        source += "        sunVisibility = atmosphereShadow;\n";
    }
    if (lightIndex == 0 && props.hasRingShadowForLight(0))
    {
        source += "        atmosphereRingShadowPosition = atmSamplePointSun * ringScale;\n";
        source += "        float atmosphereRingPlaneDistance = -(dot(atmosphereRingShadowPosition, ringPlane.xyz) + ringPlane.w);\n";
        source += "        float atmosphereRingShadowDistance = atmosphereRingPlaneDistance / dot("
                  + LightProperty(lightIndex, "direction") + ", ringPlane.xyz);\n";
        source += "        if (atmosphereRingShadowDistance >= 0.0)\n";
        source += "        {\n";
        source += "            atmosphereRingShadowProjection = atmosphereRingShadowPosition + "
                  + LightProperty(lightIndex, "direction") + " * atmosphereRingShadowDistance;\n";
        source += "            atmosphereRingShadowTexCoord = (length(atmosphereRingShadowProjection - ringCenter) - ringRadius) * ringWidth;\n";
        source += "            if (atmosphereRingShadowTexCoord >= 0.0 && atmosphereRingShadowTexCoord <= 1.0)\n";
        source += "                sunVisibility *= 1.0 - textureLod(ringTex, vec2(atmosphereRingShadowTexCoord, 0.0), ringShadowLOD0).a;\n";
        source += "        }\n";
    }
    source += "        if (sunVisibility > 0.0)\n";
    source += "        {\n";
    source += "            qq = sampleRadiusSq - atmosphereRadius.y;\n";
    source += "            d = sqrt(max(rq * rq - qq, 0.0));\n";
    source += "            float sampleRadius = sqrt(sampleRadiusSq);\n";
    source += "            float sampleMu = rq / sampleRadius;\n";
    source += "            float boundaryMu = d / atmosphereRadius.x;\n";
    source += "            float mieToSpace = chapmanToSpace(sampleRadius, sampleMu);\n";
    source += "            float odSun;\n";
    source += props.separateRayleighMieScaleHeights
        ? SeparateAtmosphereScatteringAccumulation()
        : LegacyAtmosphereScatteringAccumulation();
    source += "        }\n";
    source += "        odAtm += segmentDepth;\n";
    if (props.separateRayleighMieScaleHeights)
        source += "        rayleighOdAtm += rayleighSegmentDepth;\n";
    source += "        segmentStart += atmStep;\n";
    source += "        viewStartRadiusSq = viewEndRadiusSq;\n";
    source += "        viewStartProjection = viewEndProjection;\n";
    source += "        viewStartRadius = viewEndRadius;\n";
    source += "        viewStartMu = viewEndMu;\n";
    source += "    }\n";
    source += props.separateRayleighMieScaleHeights
        ? "    vec3 ex = exp(-mieExtinctionCoeff * odAtm - rayleighExtinctionCoeff * rayleighOdAtm);\n"
        : "    vec3 ex = exp(-extinctionCoeff * odAtm);\n";
    source += "    vec3 lightColor = " + LightProperty(lightIndex, "color") + ";\n";
    source += "    float cosTheta = dot(eyeDir, " + LightProperty(lightIndex, "direction") + ");\n";
    source += ScatteringPhaseFunctions(props);
    source += props.separateRayleighMieScaleHeights
        ? "    vec3 integratedLight = lightColor * (phRayleigh * rayleighCoeff * rayleighScatteredLight + phMie * mieCoeff * mieScatteredLight);\n"
        : "    vec3 integratedLight = (phRayleigh * rayleighCoeff + phMie * mieCoeff) * lightColor * scatteredLight;\n";
    source += "    scatterEx = ex;\n";

    if (props.lightModel == LightingModel::AtmosphereModel)
        source += "    " + ScatteredColor(lightIndex) + " = integratedLight;\n";
    else
        source += "    scatterColor = integratedLight;\n";

    // Optional exposure control
    //source += "    1.0 - (scatterIn * exp(-5.0 * max(scatterIn.x, max(scatterIn.y, scatterIn.z))));\n";

    source += "}\n";

    return source;
}


std::string
AtmosphericTransmission(const ShaderProperties& props)
{
    std::string source = R"glsl({
    float rq = dot(eyePosition, eyeDir);
    float qq = dot(eyePosition, eyePosition) - atmosphereRadius.y;
    float d = sqrt(max(rq * rq - qq, 0.0));
    vec3 atmEnter = eyePosition + min(0.0, -rq + d) * eyeDir;
    vec3 viewDirection = -eyeDir;
    float enterRadius = max(length(atmEnter), 1.0e-6);
    float leaveRadius = max(length(nposition), 1.0e-6);
    float enterColumn = chapmanToSpace(enterRadius,
                                       dot(atmEnter, viewDirection) / enterRadius);
    float leaveColumn = chapmanToSpace(leaveRadius,
                                       dot(nposition, viewDirection) / leaveRadius);
    float opticalDepth = max(0.0, enterColumn - leaveColumn);
)glsl";
    if (props.separateRayleighMieScaleHeights)
    {
        source += R"glsl(
        float rayleighEnterColumn = chapmanToSpaceWithScaleHeight(
            enterRadius, dot(atmEnter, viewDirection) / enterRadius, rayleighH);
        float rayleighLeaveColumn = chapmanToSpaceWithScaleHeight(
            leaveRadius, dot(nposition, viewDirection) / leaveRadius, rayleighH);
        float rayleighOpticalDepth = max(0.0, rayleighEnterColumn - rayleighLeaveColumn);
        vec3 mieExtinctionCoeff = max(vec3(mieCoeff), vec3(0.0));
        vec3 rayleighExtinctionCoeff = max(extinctionCoeff - mieExtinctionCoeff, vec3(0.0));
        scatterEx = exp(-mieExtinctionCoeff * opticalDepth -
                        rayleighExtinctionCoeff * rayleighOpticalDepth);
)glsl";
    }
    else
    {
        source += "    scatterEx = exp(-extinctionCoeff * opticalDepth);\n";
    }
    source += "}\n";
    return source;
}


std::string
ScatteringConstantDeclarations(const ShaderProperties& props)
{
    std::string source;

    source += DeclareUniform("atmosphereRadius", Shader_Vector3);
    source += DeclareUniform("atmosphereSegmentCount", Shader_Integer);
    source += DeclareUniform("atmosphereExtinctionThreshold", Shader_Float);
    source += DeclareUniform("mieCoeff", Shader_Float);
    source += DeclareUniform("mieH", Shader_Float);
    source += DeclareUniform("mieK", Shader_Float);
    source += DeclareUniform("rayleighCoeff", Shader_Vector3);
    if (props.separateRayleighMieScaleHeights)
        source += DeclareUniform("rayleighH", Shader_Float);
    source += DeclareUniform("extinctionCoeff", Shader_Vector3);
    source += ChapmanOpticalDepth();

    return source;
}


std::string
TextureSamplerDeclarations(const ShaderProperties& props)
{
    std::string source;

    // Declare texture samplers
    if (util::is_set(props.texUsage, TexUsage::DiffuseTexture))
    {
        source += DeclareUniform("diffTex", Shader_Sampler2D);
    }

    if (util::is_set(props.texUsage, TexUsage::NormalTexture))
    {
        source += DeclareUniform("normTex", Shader_Sampler2D);
    }

    if (util::is_set(props.texUsage, TexUsage::SpecularTexture))
    {
        source += DeclareUniform("specTex", Shader_Sampler2D);
    }

    if (util::is_set(props.texUsage, TexUsage::NightTexture))
    {
        source += DeclareUniform("nightTex", Shader_Sampler2D);
    }

    if (util::is_set(props.texUsage, TexUsage::EmissiveTexture))
    {
        source += DeclareUniform("emissiveTex", Shader_Sampler2D);
    }

    if (util::is_set(props.texUsage, TexUsage::OverlayTexture))
    {
        source += DeclareUniform("overlayTex", Shader_Sampler2D);
    }

    if (util::is_set(props.texUsage, TexUsage::ShadowMapTexture))
    {
        source += DeclareUniform("shadowMapTex0", Shader_Sampler2DShadow);
    }

    return source;
}


std::string
TextureCoordDeclarations(const ShaderProperties& props, ShaderInOut inOut)
{
    std::string source;

    auto declare = inOut == Shader_In ? DeclareInput : DeclareOutput;

    if (props.hasSharedTextureCoords())
    {
        // If the shared texture coords flag is set, use the diffuse texture
        // coordinate for sampling all the texture maps.
        if (util::is_set(props.texUsage, TexUsage::DiffuseTexture  |
                                         TexUsage::NormalTexture   |
                                         TexUsage::SpecularTexture |
                                         TexUsage::NightTexture    |
                                         TexUsage::EmissiveTexture |
                                         TexUsage::OverlayTexture))
        {
            source += declare("diffTexCoord", Shader_Vector2);
        }
    }
    else
    {
        if (util::is_set(props.texUsage, TexUsage::DiffuseTexture))
            source += declare("diffTexCoord", Shader_Vector2);
        if (util::is_set(props.texUsage, TexUsage::NormalTexture))
            source += declare("normTexCoord", Shader_Vector2);
        if (util::is_set(props.texUsage, TexUsage::SpecularTexture))
            source += declare("specTexCoord", Shader_Vector2);
        if (util::is_set(props.texUsage, TexUsage::NightTexture))
            source += declare("nightTexCoord", Shader_Vector2);
        if (util::is_set(props.texUsage, TexUsage::EmissiveTexture))
            source += declare("emissiveTexCoord", Shader_Vector2);
        if (util::is_set(props.texUsage, TexUsage::OverlayTexture))
            source += declare("overlayTexCoord", Shader_Vector2);
    }

    if (util::is_set(props.texUsage, TexUsage::ShadowMapTexture))
    {
        source += declare("shadowTexCoord0", Shader_Vector4);
        source += declare("cosNormalLightDir", Shader_Float);
    }

    return source;
}

std::string
PointSizeDeclaration()
{
    std::string source;
    source += DeclareOutput("pointFade", Shader_Float);
    source += DeclareUniform("pointScale", Shader_Float);
    source += DeclareAttribute("in_PointSize", Shader_Float, CelestiaGLProgram::PointSizeAttributeIndex);
    return source;
}

std::string
PointSizeCalculation()
{
    std::string source;
    source += "float ptSize = pointScale * in_PointSize / length(vec3(ModelViewMatrix * in_Position));\n";
    source += "pointFade = min(1.0, ptSize * ptSize);\n";
    source += "gl_PointSize = ptSize;\n";

    return source;
}

std::string
StaticPointSize()
{
    std::string source;
    source += "pointFade = 1.0;\n";
    source += "gl_PointSize = in_PointSize * pointScale;\n";
    return source;
}

std::string
LineDeclaration()
{
    std::string source;
    source += DeclareAttribute("in_PositionNext", Shader_Vector4, CelestiaGLProgram::NextVCoordAttributeIndex);
    source += DeclareAttribute("in_ScaleFactor", Shader_Float, CelestiaGLProgram::ScaleFactorAttributeIndex);
    source += DeclareAttribute("in_LineSide", Shader_Float, CelestiaGLProgram::LineSideAttributeIndex);
    source += DeclareUniform("lineWidthX", Shader_Float);
    source += DeclareUniform("lineWidthY", Shader_Float);
    source += DeclareOutput("lineU", Shader_Float);
    return source;
}

std::string
LineFragmentDeclaration()
{
    std::string source;
    source += DeclareInput("lineU", Shader_Float);
    source += DeclareUniform("lineEdge", Shader_Float);
    return source;
}

std::string
CalculateShadow()
{
    std::string source;
    source += R"glsl(
float calculateShadow()
{
    float texelSize = 1.0 / shadowMapSize;
    float s = 0.0;
    float bias = max(0.005 * (1.0 - cosNormalLightDir), 0.0005);
)glsl"sv;
    float boxFilterWidth = static_cast<float>(ShadowSampleKernelWidth) - 1.0f;
    float firstSample = -boxFilterWidth / 2.0f;
    float lastSample = firstSample + boxFilterWidth;
    float sampleWeight = 1.0f / static_cast<float>(ShadowSampleKernelWidth * ShadowSampleKernelWidth);
    source += fmt::format("    for (float y = {:f}; y <= {:f}; y += 1.0)\n", firstSample, lastSample);
    source += fmt::format("        for (float x = {:f}; x <= {:f}; x += 1.0)\n", firstSample, lastSample);
    // Modern GLSL hardware compare: returns single float (PCF result when
    // GL_LINEAR filter + GL_COMPARE_REF_TO_TEXTURE). The bias is subtracted
    // from the reference depth so a fragment stays lit when it is within
    // bias of the stored depth, matching the acne-avoidance direction.
    source += "            s += texture(shadowMapTex0, shadowTexCoord0.xyz + vec3(x * texelSize, y * texelSize, -bias));\n";
    source += fmt::format("    return s * {:f};\n", sampleWeight);
    source += "}\n";
    return source;
}

std::string
ReadShaderFile(const std::filesystem::path &path)
{
    std::error_code ec;
    std::uintmax_t size = std::filesystem::file_size(path, ec);
    if (ec)
    {
        GetLogger()->error("Failed to get file size of {}.\n", path);
        return {};
    }

    std::ifstream in(path);
    if (!in.good())
    {
        GetLogger()->error("Failed to open {}.\n", path);
        return {};
    }

    std::string s(size, '\0');
    in.read(&s[0], size); /* Flawfinder: ignore */

    if (!in.good() && !in.eof())
        return {};

    return s;
}

GLProgram
CreateErrorProgram(bool fisheyeEnabled, GLShaderStatus& status)
{
    auto builder = GLProgramBuilder::create(status);
    if (status != GLShaderStatus::OK)
        return GLProgram{};

    if (auto vs = GLVertexShader::create(fmt::format("{}{}{}{}{}\n",
                                                     VersionHeader,
                                                     CommonHeader,
                                                     VertexHeader,
                                                     VPFunction(fisheyeEnabled),
                                                     errorVertexShaderSource),
                                         status);
        status == GLShaderStatus::OK)
    {
        builder.attach(std::move(vs));
    }
    else
    {
        return GLProgram{};
    }

    if (auto fs = GLFragmentShader::create(fmt::format("{}{}{}{}\n",
                                                       VersionHeader,
                                                       CommonHeader,
                                                       FragmentHeader,
                                                       errorFragmentShaderSource),
                                           status);
        status == GLShaderStatus::OK)
    {
        builder.attach(std::move(fs));
    }
    else
    {
        return GLProgram{};
    }


    GLProgram program = builder.link(status);
    if (status != GLShaderStatus::OK)
    {
        if (g_shaderLogFile != nullptr)
            *g_shaderLogFile << "Failed to create error shader!\n";

        return GLProgram{};
    }

    return program;
}

constexpr std::string_view
InPrimitive(GLint prim)
{
    switch (prim)
    {
    case GL_POINTS:
        return "points"sv;
    case GL_LINES:
        return "lines"sv;
    case GL_LINES_ADJACENCY:
        return "lines_adjacency"sv;
    case GL_TRIANGLES:
        return "triangles"sv;
    case GL_TRIANGLES_ADJACENCY:
        return "triangles_adjacency"sv;
    default:
        return "invalid"sv;
    }
}

constexpr std::string_view
OutPrimitive(GLint prim)
{
    switch (prim)
    {
    case GL_POINTS:
        return "points"sv;
    case GL_LINE_STRIP:
        return "line_strip"sv;
    case GL_TRIANGLE_STRIP:
        return "triangle_strip"sv;
    default:
        return "invalid"sv;
    }
}

GLVertexShader
buildVertexShader(const ShaderProperties& props, bool fisheyeEnabled)
{
    std::string source(VersionHeader);
    source += "// buildVertexShader\n";
    source += "/***************************************************\n";
    source += fmt::format(
R"glsl(
    usesShadows = {}
    usesTangentSpaceLighting = {}
    hasEclipseShadows = {}
    hasRingShadows = {}
    hasSelfShadows = {}
    hasCloudShadows = {}
    hasSpecular = {}
    hasScattering = {}
    isViewDependent = {}
    lightModel = {:x}
)glsl",
    props.usesShadows(),
    props.usesTangentSpaceLighting(),
    props.hasEclipseShadows(),
    props.hasRingShadows(),
    props.hasSelfShadows(),
    props.hasCloudShadows(),
    props.hasSpecular(),
    props.hasScattering(),
    props.isViewDependent(),
    static_cast<std::uint16_t>(props.lightModel));
    source += "***************************************************/\n";
    source += CommonHeader;
    source += VertexHeader;
    source += CommonAttribs;
    if (props.hasTextureCoordTransform())
        source += TextureTransformUniforms;

    source += DeclareLights(props);
    source += TextureCoordDeclarations(props, Shader_Out);
    source += DeclareUniform("texCoordOffset", Shader_Float);

    if (props.usePointSize())
        source += PointSizeDeclaration();

    if (props.lightModel != LightingModel::ParticleDiffuseModel)
        source += DeclareOutput("normal", Shader_Vector3);

    if (props.usesTangentSpaceLighting())
    {
        source += DeclareAttribute("in_Tangent", Shader_Vector3, CelestiaGLProgram::TangentAttributeIndex);
        source += DeclareOutput("tangent", Shader_Vector3);
    }

    if (props.lightModel == LightingModel::UnlitModel && util::is_set(props.texUsage, TexUsage::VertexColors))
        source += DeclareOutput("diff", Shader_Vector4);

    if (props.isViewDependent() || props.hasScattering() ||
        props.hasEclipseShadows() || props.hasRingShadows())
        source += DeclareOutput("position", Shader_Vector3);

    // Shadow parameters
    if (props.hasRingShadows())
    {
        source += DeclareUniform("ringWidth", Shader_Float);
        source += DeclareUniform("ringRadius", Shader_Float);
        source += DeclareUniform("ringPlane", Shader_Vector4);
        source += DeclareUniform("ringCenter", Shader_Vector3);
        source += DeclareUniform("ringScale", Shader_Vector3);
        source += DeclareOutput("ringShadowTexCoord", Shader_Vector4);
    }

    if (props.hasCloudShadows())
    {
        source += DeclareUniform("cloudShadowTexOffset", Shader_Float);
        source += DeclareUniform("cloudHeight", Shader_Float);
        for (unsigned int i = 0; i < props.nLights; i++)
        {
            if (props.hasCloudShadowForLight(i))
                source += DeclareOutput(CloudShadowTexCoord(i), Shader_Vector2);
        }
    }

    if (props.hasShadowMap())
        source += DeclareUniform("ShadowMatrix0", Shader_Matrix4);

    if (util::is_set(props.texUsage, TexUsage::LineAsTriangles))
        source += LineDeclaration();

    source += VPFunction(props.fishEyeOverride != FisheyeOverrideMode::Disabled && fisheyeEnabled);

    // Begin main() function
    source += "\nvoid main(void)\n{\n";
    if (props.lightModel != LightingModel::ParticleDiffuseModel)
        source += "normal = in_Normal;\n";

    if (props.isViewDependent() || props.hasScattering() ||
        props.hasEclipseShadows() || props.hasRingShadows())
        source += "position = in_Position.xyz;\n";

    if (props.usesTangentSpaceLighting())
        source += "tangent = in_Tangent;\n";

    if (props.lightModel == LightingModel::UnlitModel && util::is_set(props.texUsage, TexUsage::VertexColors))
        source += "diff = in_Color;\n";

    if (props.hasShadowMap())
    {
        source += "cosNormalLightDir = dot(in_Normal, " + LightProperty(0, "direction") + ");\n";
        source += "shadowTexCoord0 = ShadowMatrix0 * vec4(in_Position.xyz, 1.0);\n";
    }

    unsigned int nTexCoords = 0;

    // Output the texture coordinates. Use just a single texture coordinate if all textures are mapped
    // identically. The texture offset is added for cloud maps; specular and night texture are not offset
    // because cloud layers never have these textures.
    bool hasTexCoordTransform = props.hasTextureCoordTransform();
    if (props.hasSharedTextureCoords())
    {
        if (util::is_set(props.texUsage, TexUsage::DiffuseTexture  |
                                         TexUsage::NormalTexture   |
                                         TexUsage::SpecularTexture |
                                         TexUsage::NightTexture    |
                                         TexUsage::EmissiveTexture |
                                         TexUsage::OverlayTexture))
        {
            source += "diffTexCoord = " + TexCoord2D(nTexCoords, hasTexCoordTransform) + ";\n";
            source += "diffTexCoord.x += texCoordOffset;\n";
        }
    }
    else
    {
        if (util::is_set(props.texUsage, TexUsage::DiffuseTexture))
        {
            source += "diffTexCoord = " + TexCoord2D(nTexCoords, hasTexCoordTransform) + " + vec2(texCoordOffset, 0.0);\n";
            nTexCoords++;
        }

        if (util::is_set(props.texUsage, TexUsage::NormalTexture))
        {
            source += "normTexCoord = " + TexCoord2D(nTexCoords, hasTexCoordTransform) + " + vec2(texCoordOffset, 0.0);\n";
            nTexCoords++;
        }

        if (util::is_set(props.texUsage, TexUsage::SpecularTexture))
        {
            source += "specTexCoord = " + TexCoord2D(nTexCoords, hasTexCoordTransform) + ";\n";
            nTexCoords++;
        }

        if (util::is_set(props.texUsage, TexUsage::NightTexture))
        {
            source += "nightTexCoord = " + TexCoord2D(nTexCoords, hasTexCoordTransform) + ";\n";
            nTexCoords++;
        }

        if (util::is_set(props.texUsage, TexUsage::EmissiveTexture))
        {
            source += "emissiveTexCoord = " + TexCoord2D(nTexCoords, hasTexCoordTransform) + ";\n";
            nTexCoords++;
        }
    }

    // Ring texture coordinates are smooth across the ring plane. The fragment
    // shader separately rejects intersections that point away from the light.
    if (props.hasRingShadows())
    {
        source += "vec3 ringShadowPosition = in_Position.xyz * ringScale;\n";
        source += "vec3 ringShadowProjection;\n";
        source += "float ringPlaneDistance = -(dot(ringShadowPosition, ringPlane.xyz) + ringPlane.w);\n";
        source += "float ringShadowDistance;\n";
        for (unsigned int j = 0; j < props.nLights; j++)
        {
            if (props.hasRingShadowForLight(j))
            {
                source += "ringShadowDistance = ringPlaneDistance / dot(" +
                  LightProperty(j, "direction") + ", ringPlane.xyz);\n";
                source += "ringShadowProjection = ringShadowPosition + " +
                  LightProperty(j, "direction") + " * ringShadowDistance;\n";
                source += RingShadowTexCoord(j) +
                  " = (length(ringShadowProjection - ringCenter) - ringRadius) * ringWidth;\n";
            }
        }
    }

    if (props.hasCloudShadows())
    {
        for (unsigned int j = 0; j < props.nLights; j++)
        {
            if (props.hasCloudShadowForLight(j))
            {
                source += "{\n";

                // A cheap way to calculate cloud shadow texture coordinates that doesn't correctly account
                // for sun angle.
                source += "    " + CloudShadowTexCoord(j) + " = vec2(diffTexCoord.x + cloudShadowTexOffset, diffTexCoord.y);\n";

                // Disabled: there are too many problems with this approach,
                // though it should theoretically work. The inverse trig
                // approximations produced by the shader compiler are crude
                // enough that visual anomalies are apparent. And in the current
                // GeForce 8800 driver, this shader produces an internal compiler
                // error. Cloud shadows are trivial if the cloud texture is a cube
                // map. Also, DX10 capable hardware could efficiently perform
                // the rect-to-spherical conversion in the pixel shader with an
                // fp32 texture serving as a lookup table.
#if 0
                // Compute the intersection of the sun direction and the cloud layer (currently assumed to be a sphere)
                source += "    float rq = dot(" + LightProperty(j, "direction") + ", in_Position.xyz);\n";
                source += "    float qq = dot(in_Position.xyz, in_Position.xyz) - cloudHeight * cloudHeight;\n";
                source += "    float d = sqrt(max(rq * rq - qq, 0.0));\n";
                source += "    vec3 cloudSpherePos = (in_Position.xyz + (-rq + d) * " + LightProperty(j, "direction") + ");\n";
                //source += "    vec3 cloudSpherePos = in_Position.xyz;\n";

                // Find the texture coordinates at this point on the sphere by converting from rectangular to spherical; this is an
                // expensive calculation to perform per vertex.
                source += "    float invPi = 1.0 / 3.1415927;\n";
                source += "    " + CloudShadowTexCoord(j) + ".y = 0.5 - asin(cloudSpherePos.y) * invPi;\n";
                source += "    float u = fract(atan(cloudSpherePos.x, cloudSpherePos.z) * (invPi * 0.5) + 0.75);\n";
                source += "    if (diffTexCoord.x < 0.25 && u > 0.5) u -= 1.0;\n";
                source += "    else if (diffTexCoord.x > 0.75 && u < 0.5) u += 1.0;\n";
                source += "    " + CloudShadowTexCoord(j) + ".x = u + cloudShadowTexOffset;\n";
#endif

                source += "}\n";
            }
        }
    }

    if (util::is_set(props.texUsage, TexUsage::OverlayTexture) && !props.hasSharedTextureCoords())
    {
        source += "overlayTexCoord = " + TexCoord2D(nTexCoords, hasTexCoordTransform) + ";\n";
        nTexCoords++;
    }

    if (util::is_set(props.texUsage, TexUsage::PointSprite))
        source += PointSizeCalculation();
    else if (util::is_set(props.texUsage, TexUsage::StaticPointSize))
        source += StaticPointSize();

    source += VertexPosition(props);
    source += "}\n";

    DumpVSSource(source);

    GLShaderStatus status = GLShaderStatus::OK;
    if (auto vs = GLVertexShader::create(source, status); status == GLShaderStatus::OK)
        return vs;

    return GLVertexShader{};
}


GLFragmentShader
buildFragmentShader(const ShaderProperties& props)
{
    const bool emitLineAA = util::is_set(props.texUsage, TexUsage::LineAsTriangles);

    std::string source(VersionHeader);
    source += CommonHeader;
    source += FragmentHeader;

    std::string diffTexCoord("diffTexCoord");
    std::string specTexCoord("specTexCoord");
    std::string nightTexCoord("nightTexCoord");
    std::string emissiveTexCoord("emissiveTexCoord");
    std::string normTexCoord("normTexCoord");
    if (props.hasSharedTextureCoords())
    {
        specTexCoord     = diffTexCoord;
        nightTexCoord    = diffTexCoord;
        normTexCoord     = diffTexCoord;
        emissiveTexCoord = diffTexCoord;
    }

    source += TextureSamplerDeclarations(props);
    source += TextureCoordDeclarations(props, Shader_In);

    if (props.hasScattering())
        source += ScatteringConstantDeclarations(props);

    source += DeclareUniform("eyePosition", Shader_Vector3);

    if (util::is_set(props.lightModel, LightingModel::LunarLambertModel))
        source += DeclareUniform("lunarLambert", Shader_Float);

    if (props.effects == LightingEffects::CloudLighting)
        source += DeclareUniform("cloudHorizon", Shader_Float);

    if (props.lightModel != LightingModel::ParticleDiffuseModel)
        source += DeclareInput("normal", Shader_Vector3);

    if (props.isViewDependent() || props.hasScattering() ||
        props.hasEclipseShadows() || props.hasRingShadows())
        source += DeclareInput("position", Shader_Vector3);

    if (props.lightModel != LightingModel::UnlitModel)
    {
        source += DeclareUniform("ambientColor", Shader_Vector3);
        source += DeclareUniform("opacity", Shader_Float);
    }
    else
    {
        if (util::is_set(props.texUsage, TexUsage::VertexColors))
            source += DeclareInput("diff", Shader_Vector4);
    }

    // Declare lighting parameters
    if (props.usesTangentSpaceLighting())
        source += DeclareInput("tangent", Shader_Vector3);

    if (props.hasSpecular())
        source += DeclareUniform("shininess", Shader_Float);

    // Declare shadow parameters
    if (props.hasEclipseShadows())
    {
        for (unsigned int i = 0; i < props.nLights; i++)
        {
            for (unsigned int j = 0; j < props.getEclipseShadowCountForLight(i); j++)
            {
                source += DeclareUniform(IndexedParameter("shadowTexGenS", i, j), Shader_Vector4);
                source += DeclareUniform(IndexedParameter("shadowTexGenT", i, j), Shader_Vector4);
                source += DeclareUniform(IndexedParameter("shadowFalloff", i, j), Shader_Float);
                source += DeclareUniform(IndexedParameter("shadowMaxDepth", i, j), Shader_Float);
            }
        }
    }

    if (props.hasRingShadows())
    {
        source += DeclareUniform("ringTex", Shader_Sampler2D);
        source += DeclareUniform("ringPlane", Shader_Vector4);
        source += DeclareUniform("ringCenter", Shader_Vector3);
        source += DeclareUniform("ringRadius", Shader_Float);
        source += DeclareUniform("ringWidth", Shader_Float);
        source += DeclareUniform("ringScale", Shader_Vector3);
        source += DeclareInput("ringShadowTexCoord", Shader_Vector4);
        for (unsigned int i = 0; i < props.nLights; i++)
        {
            if (props.hasRingShadowForLight(i))
                source += DeclareUniform(IndexedParameter("ringShadowLOD", i), Shader_Float);
        }
    }

    if (props.hasCloudShadows())
    {
        source += DeclareUniform("cloudShadowTex", Shader_Sampler2D);
        for (unsigned int i = 0; i < props.nLights; i++)
            source += DeclareInput(CloudShadowTexCoord(i), Shader_Vector2);
    }

    if (props.usePointSize())
        source += DeclareInput("pointFade", Shader_Float);

    if (emitLineAA)
        source += LineFragmentDeclaration();

    if (props.hasShadowMap())
    {
        source += DeclareUniform("shadowMapSize", Shader_Float);
        source += CalculateShadow();
    }

    source += DeclareLights(props);

    source += "\nvoid main(void)\n{\n";
    source += "vec4 color;\n";

    if (props.lightModel != LightingModel::ParticleDiffuseModel)
        source += "vec3 N = normalize(normal);\n";

    if (props.isViewDependent() || props.hasScattering() || props.hasEclipseShadows())
        source += "vec3 nposition = normalize(position);\n";

    if (props.lightModel != LightingModel::UnlitModel)
        source += "vec4 diff = vec4(ambientColor, opacity);\n";
    else if (!util::is_set(props.texUsage, TexUsage::VertexColors))
        source += "vec4 diff = vec4(1.0);\n";

    if (props.usesTangentSpaceLighting())
    {
        source += "vec3 T = normalize(tangent);\n";
        if (props.isViewDependent())
            source += DeclareLocal("eyeDir_tan", Shader_Vector3); // tangent space eye vector
        for (unsigned int i = 0; i < props.nLights; i++)
            source += DeclareLocal(LightDir_tan(i), Shader_Vector3);
    }

    if (props.hasSpecular())
        source += "vec4 spec = vec4(0.0);\n";

    bool useSeparateDiffuse = !props.usesTangentSpaceLighting() &&
                              (props.hasSpecular() ||
                               props.usesShadows() ||
                               props.isViewDependent() ||
                               props.hasScattering());

    if (useSeparateDiffuse)
        source += DeclareLocal("diffFactors", Shader_Vector4);

    if (util::is_set(props.texUsage, TexUsage::NightTexture))
        source += "float totalLight = 0.0;\n";

    if (props.isViewDependent() || props.hasScattering())
            source += "vec3 eyeDir = normalize(eyePosition - nposition);\n";

    if (props.usesTangentSpaceLighting())
    {
        source += "vec3 bitangent = cross(N, T);\n";
        if (props.isViewDependent())
            source += TangentSpaceTransform("eyeDir_tan", "eyeDir");
    }
    else if (props.isViewDependent() || props.hasScattering())
    {
        source += "float NV = dot(N, eyeDir);\n";
    }

    if (props.lightModel != LightingModel::UnlitModel)
    {
        source += DeclareLocal("NL", Shader_Float);
        if (util::is_set(props.lightModel, LightingModel::LunarLambertModel))
        {
            source += DeclareLocal("cosPhaseAngle", Shader_Float);
            source += DeclareLocal("phaseAngle", Shader_Float);
            source += DeclareLocal("lunarLimbDarkening", Shader_Float);
            source += DeclareLocal("lunarWeight", Shader_Float);
        }
        for (unsigned int i = 0; i < props.nLights; i++)
            source += AddDirectionalLightContrib(i, props);
    }

    if (props.usesShadows())
    {
        // Temporaries required for shadows
        source += "float shadow;\n";
        if (props.hasEclipseShadows())
        {
            source += "vec2 shadowCenter;\n";
            source += "float shadowR;\n";
        }
    }

    // Sum the illumination from each light source, computing a total diffuse and specular
    // contributions from all sources.
    if (props.usesTangentSpaceLighting())
    {
        // Get the normal in tangent space. Ordinarily it comes from the normal texture, but if one
        // isn't provided, we'll simulate a smooth surface by using a constant (in tangent space)
        // normal of [ 0 0 1 ]
        if (util::is_set(props.texUsage, TexUsage::NormalTexture))
        {
            if (util::is_set(props.texUsage, TexUsage::CompressedNormalTexture))
            {
                source += "vec3 n;\n";
                source += "n.xy = texture(normTex, " + normTexCoord + ".st).ag * 2.0 - vec2(1.0);\n";
                source += "n.z = sqrt(1.0 - n.x * n.x - n.y * n.y);\n";
            }
            else
            {
                // TODO: normalizing the filtered normal texture value noticeably improves the appearance; add
                // an option for this.
                //source += "vec3 n = normalize(texture(normTex, " + normTexCoord + ".st).xyz * 2.0 - vec3(1.0));\n";
                source += "vec3 n = texture(normTex, " + normTexCoord + ".st).xyz * 2.0 - vec3(1.0);\n";
            }
        }
        else
        {
            source += "vec3 n = vec3(0.0, 0.0, 1.0);\n";
        }

        source += "float l;\n";

        if (props.isViewDependent())
        {
            source += "vec3 V = normalize(eyeDir_tan);\n";

            if (props.hasSpecular())
            {
                source += "vec3 H;\n";
                source += "float NH;\n";
            }
            if (util::is_set(props.lightModel, LightingModel::LunarLambertModel))
                source += "float NV = dot(n, V);\n";
        }

        for (unsigned i = 0; i < props.nLights; i++)
        {
            // Bump mapping with self shadowing
            source += "NL = dot(" + LightDir_tan(i) + ", n);\n";
            if (util::is_set(props.lightModel, LightingModel::LunarLambertModel))
            {
                source += "NL = max(0.0, NL);\n";
                source += "cosPhaseAngle = dot(eyeDir_tan, " + LightDir_tan(i) + ");\n";
                source += LunarLambert();
                source += "l = mix(NL, 2.0 * NL / (max(NV, 0.001) + NL), lunarWeight) * clamp(" + LightDir_tan(i) + ".z * 8.0, 0.0, 1.0);\n";
            }
            else
            {
                source += "l = max(0.0, dot(" + LightDir_tan(i) + ", n)) * clamp(" + LightDir_tan(i) + ".z * 8.0, 0.0, 1.0);\n";
            }
            if (props.effects == LightingEffects::CloudLighting)
                source += "l = max(l, " + IndexedParameter("cloudTwilight", i) + ");\n";

            if (util::is_set(props.texUsage, TexUsage::NightTexture))
                source += "totalLight += l * " + LightProperty(i, "brightness") + ";\n";

            if (props.hasShadowsForLight(i))
                source += ShadowsForLightSource(props, i);

            std::string illum(props.hasShadowsForLight(i) ? "l * shadow" : "l");
            source += "diff.rgb += " + illum + " * " + LightProperty(i, "diffuse") + ";\n";

            if (props.hasSpecular())
            {
                source += "H = normalize(eyeDir_tan + " + LightDir_tan(i) + ");\n";
                source += "NH = max(0.0, dot(n, H));\n";
                source += "spec.rgb += " + illum + " * pow(NH, shininess) * " + LightProperty(i, "specular") + ";\n";
            }
        }
    }
    else if (props.hasSpecular())
    {
        source += "float NH;\n";
        source += "float shadowMapCoeff = 1.0;\n";

        // Sum the contributions from each light source
        for (unsigned i = 0; i < props.nLights; i++)
        {
            if (props.hasShadowsForLight(i))
                source += ShadowsForLightSource(props, i);

            std::string illum(props.hasShadowsForLight(i) ? "shadow" : SeparateDiffuse(i));
            source += "diff.rgb += " + illum + " * " + LightProperty(i, "diffuse") + ";\n";
            source += "NH = max(0.0, dot(N, normalize(" + LightProperty(i, "halfVector") + ")));\n";
            source += "spec.rgb += " + illum + " * pow(NH, shininess) * " + LightProperty(i, "specular") + ";\n";
            if (props.hasShadowMap() && i == 0)
                source += ApplyShadow(true);
        }
    }
    else if (props.usesShadows())
    {
        source += "float shadowMapCoeff = 1.0;\n";

        // Sum the contributions from each light source
        for (unsigned i = 0; i < props.nLights; i++)
        {
            source += ShadowsForLightSource(props, i);
            source += "diff.rgb += shadow * " + LightProperty(i, "diffuse") + ";\n";

            if (props.hasShadowMap() && i == 0)
                ApplyShadow(false);
        }
    }

    if (util::is_set(props.texUsage, TexUsage::DiffuseTexture))
    {
        if (props.usePointSize())
            source += "color = texture(diffTex, gl_PointCoord);\n";
        else
            source += "color = texture(diffTex, " + diffTexCoord + ".st);\n";
    }
    else
    {
        source += "color = vec4(1.0);\n";
    }

#if POINT_FADE
    if (props.usePointSize())
    {
        source += "color.a *= pointFade;\n";
    }
#endif

    // Mix in the overlay color with the base color
    if (util::is_set(props.texUsage, TexUsage::OverlayTexture))
    {
        source += "vec4 overlayColor = texture(overlayTex, overlayTexCoord.st);\n";
        source += "color.rgb = mix(color.rgb, overlayColor.rgb, overlayColor.a);\n";
    }

    if (props.hasSpecular())
    {
        // Add in the specular color
        if (util::is_set(props.texUsage, TexUsage::SpecularInDiffuseAlpha))
            source += "fragColor = vec4(color.rgb, 1.0) * diff + float(color.a) * spec;\n";
        else if (util::is_set(props.texUsage, TexUsage::SpecularTexture))
            source += "fragColor = color * diff + texture(specTex, " + specTexCoord + ".st) * spec;\n";
        else
            source += "fragColor = color * diff + spec;\n";
    }
    else
    {
        source += "fragColor = color * diff;\n";
    }

    if (util::is_set(props.texUsage, TexUsage::NightTexture))
    {
        if (useSeparateDiffuse)
        {
            for (int k = 0; k < props.nLights - 1; k++)
                source += SeparateDiffuse(k) + " + ";
            source += SeparateDiffuse(props.nLights - 1) + ";\n";
        }

        source += NightTextureBlend();
        source += "fragColor += texture(nightTex, " + nightTexCoord + ".st) * totalLight;\n";
    }

    // Add in the emissive color
    // TODO: support a constant emissive color, not just an emissive texture
    if (util::is_set(props.texUsage, TexUsage::EmissiveTexture))
    {
        source += "fragColor += texture(emissiveTex, " + emissiveTexCoord + ".st);\n";
    }

    // Include the effect of atmospheric scattering.
    if (props.hasScattering())
    {
        source += DeclareLocal("scatterEx", Shader_Vector3);
        source += DeclareLocal("scatterColor", Shader_Vector3);
        source += AtmosphericEffects(props, 0);
        source += "fragColor.rgb = fragColor.rgb * scatterEx + scatterColor;\n";
    }

    // Limb darkening
    if (props.lightModel == LightingModel::StarModel)
    {
        source += "fragColor.rgb = fragColor.rgb * (1.0 - vec3(1.0 - NV) * vec3(0.56, 0.61, 0.72));\n";
    }

    if (emitLineAA)
    {
        // Analytical edge anti-aliasing. Geometry is inflated 1px past the
        // visible edge (lineEdge < 1.0). Feather is 1px wide centered on the
        // visible edge — fwidth(lineU) is ~2/inflatedPx, so 0.5*fwidth gives
        // a half-pixel half-width.
        source += "float lineHalfPx = fwidth(lineU) * 0.5;\n";
        source += "fragColor.a *= 1.0 - smoothstep(lineEdge - lineHalfPx, lineEdge + lineHalfPx, abs(lineU));\n";
    }

    source += "}\n";

    DumpFSSource(source);

    GLShaderStatus status = GLShaderStatus::OK;
    if (auto fs = GLFragmentShader::create(source, status); status == GLShaderStatus::OK)
        return fs;

    return GLFragmentShader{};
}

GLVertexShader
buildRingsVertexShader(const ShaderProperties& props, bool fisheyeEnabled)
{
    std::string source(VersionHeader);
    source += CommonHeader;
    source += VertexHeader;
    source += CommonAttribs;

    source += DeclareUniform("ringWidth", Shader_Float);
    source += DeclareUniform("ringRadius", Shader_Float);

    source += DeclareLights(props);

    source += DeclareOutput("position", Shader_Vector3);
    if (props.hasEclipseShadows())
    {
        source += DeclareOutput("shadowDepths", Shader_Vector4);
    }

    if (util::is_set(props.texUsage, TexUsage::DiffuseTexture))
        source += DeclareOutput("diffTexCoord", Shader_Vector2);

    source += VPFunction(props.fishEyeOverride != FisheyeOverrideMode::Disabled && fisheyeEnabled);

    source += "\nvoid main(void)\n{\n";

    if (util::is_set(props.texUsage, TexUsage::DiffuseTexture))
        source += "diffTexCoord = " + TexCoord2D(0, false) + ";\n";

    source += "position = in_Position.xyz * (ringRadius + ringWidth * in_TexCoord0.s);\n";
    if (props.hasEclipseShadows())
    {
        for (unsigned int i = 0; i < props.nLights; i++)
        {
            source += ShadowDepth(i) + " = dot(position, " +
                       LightProperty(i, "direction") + ");\n";
        }
    }

    source += "set_vp(vec4(position.xyz, 1.0));\n";
    source += "}\n";

    DumpVSSource(source);

    GLShaderStatus status = GLShaderStatus::OK;
    if (auto vs = GLVertexShader::create(source, status); status == GLShaderStatus::OK)
        return vs;

    return GLVertexShader{};
}


GLFragmentShader
buildRingsFragmentShader(const ShaderProperties& props)
{
    std::string source(VersionHeader);
    source += CommonHeader;
    source += FragmentHeader;

    source += DeclareUniform("ambientColor", Shader_Vector3);
    source += DeclareUniform("ringHalf", Shader_Float);
    source += DeclareUniform("ringAtmosphereRadius", Shader_Float);

    source += DeclareLights(props);

    source += DeclareUniform("eyePosition", Shader_Vector3);
    source += DeclareInput("position", Shader_Vector3);

    if (util::is_set(props.texUsage, TexUsage::DiffuseTexture))
    {
        source += DeclareInput("diffTexCoord", Shader_Vector2);
        source += DeclareUniform("diffTex", Shader_Sampler2D);
    }

    if (props.hasEclipseShadows())
    {
        source += DeclareInput("shadowDepths", Shader_Vector4);

        for (unsigned int i = 0; i < props.nLights; i++)
        {
            for (unsigned int j = 0; j < props.getEclipseShadowCountForLight(i); j++)
            {
                source += DeclareUniform(IndexedParameter("shadowTexGenS", i, j), Shader_Vector4);
                source += DeclareUniform(IndexedParameter("shadowTexGenT", i, j), Shader_Vector4);
                source += DeclareUniform(IndexedParameter("shadowFalloff", i, j), Shader_Float);
                source += DeclareUniform(IndexedParameter("shadowMaxDepth", i, j), Shader_Float);
            }
        }
    }

    source += "\nvoid main(void)\n{\n";

    // When splitting the ring around the atmosphere, drop the half that belongs
    // to the other side. A fragment is behind the atmosphere when the eye->
    // fragment ray crosses the shell, i.e. a ray-sphere root lies in (0, 1).
    // The eye and ring are always outside the shell here (the split only runs
    // outside the rings, which dwarf the atmosphere), so the test needs no sqrt
    // or divide: b < 0 and a + b > 0 place the near root in (0, 1), b*b >= a*c
    // makes the roots real. The drop uses alpha 0, not discard, so every lane
    // stays alive and texture derivatives match an unsplit draw (no mip seam).
    source += "bool dropFragment = false;\n"
              "if (ringHalf != 0.0)\n{\n"
              "    vec3 ringDir = position - eyePosition;\n"
              "    float a = dot(ringDir, ringDir);\n"
              "    float b = dot(eyePosition, ringDir);\n"
              "    float c = dot(eyePosition, eyePosition) - ringAtmosphereRadius * ringAtmosphereRadius;\n"
              "    bool behind = b < 0.0 && a + b > 0.0 && b * b >= a * c;\n"
              "    dropFragment = (ringHalf > 0.0 && behind) || (ringHalf < 0.0 && !behind);\n"
              "}\n"
              "vec4 diff = vec4(ambientColor, 1.0);\n";

    // Get the normalized direction from the eye to the vertex
    source += "vec3 eyeDir = normalize(eyePosition - position);\n";

    source += DeclareLocal("color", Shader_Vector4);
    if (util::is_set(props.texUsage, TexUsage::DiffuseTexture))
        source += "color = texture(diffTex, diffTexCoord.st);\n";
    else
        source += "color = vec4(1.0);\n";
    source += DeclareLocal("opticalDepth", Shader_Float, "color.a");
    if (props.hasEclipseShadows())
    {
        // Temporaries required for shadows
        source += DeclareLocal("shadow",       Shader_Float);
        source += DeclareLocal("shadowCenter", Shader_Vector2);
        source += DeclareLocal("shadowR",      Shader_Float);
    }

    // Sum the contributions from each light source
    source += DeclareLocal("intensity", Shader_Float);
    source += DeclareLocal("litSide", Shader_Float);

    for (unsigned i = 0; i < props.nLights; i++)
    {
        // litSide is 1 when viewer and light are on the same side of the rings, 0 otherwise
        source += "litSide = 1.0 - step(0.0, " + LightProperty(i, "direction") + ".y * eyeDir.y);\n";
        //source += assign("litSide", 1.0f - step(0.0f, sh_vec3("eyePosition")["y"]));

        source += "intensity = (dot(" + LightProperty(i, "direction") + ", eyeDir) + 1.0) * 0.5;\n";
        source += "intensity = mix(intensity, intensity * (1.0 - opticalDepth), litSide);\n";
        if (props.getEclipseShadowCountForLight(i) > 0)
        {
            source += "shadow = 1.0;\n";
            source += Shadow(i, 0);
            source += "shadow = min(1.0, shadow + step(0.0, " + ShadowDepth(i) + "));\n";
#if 0
            source += "diff.rgb += (shadow * " + SeparateDiffuse(i) + ") * " +
                FragLightProperty(i, "color") + ";\n";
#endif
            source += "diff.rgb += (shadow * intensity) * " + LightProperty(i, "diffuse") + ";\n";
        }
        else
        {
            source += "diff.rgb += intensity * " + LightProperty(i, "diffuse") + ";\n";
#if 0
            source += SeparateDiffuse(i) + " = (dot(" +
                LightProperty(i, "direction") + ", eyeDir) + 1.0) * 0.5;\n";
#endif
#if 0
            source += "diff.rgb += " + SeparateDiffuse(i) + " * " +
                FragLightProperty(i, "color") + ";\n";
#endif
        }
    }

    source += "if (dropFragment) opticalDepth = 0.0;\n";
    source += "fragColor = vec4(color.rgb * diff.rgb, opticalDepth);\n";

    source += "}\n";

    DumpFSSource(source);

    GLShaderStatus status = GLShaderStatus::OK;
    if (auto fs = GLFragmentShader::create(source, status); status == GLShaderStatus::OK)
        return fs;

    return GLFragmentShader{};
}


GLVertexShader
buildAtmosphereVertexShader(const ShaderProperties& props, bool fisheyeEnabled)
{
    std::string source(VersionHeader);
    source += "// buildAtmosphereVertexShader\n";
    source += CommonHeader;
    source += VertexHeader;
    source += CommonAttribs;
    if (props.hasTextureCoordTransform())
        source += TextureTransformUniforms;

    source += DeclareLights(props);
    source += DeclareUniform("eyePosition", Shader_Vector3);
    // centroid avoids MSAA extrapolating position past the triangle at the shell
    // silhouette, which over-brightens edge samples into dashes along the limb.
    source += "centroid " + DeclareOutput("position", Shader_Vector3);

    source += VPFunction(props.fishEyeOverride != FisheyeOverrideMode::Disabled && fisheyeEnabled);

    // Begin main() function
    source += "\nvoid main(void)\n{\n";
    source += "    position = in_Position.xyz;\n";
    source += VertexPosition(props);
    source += "}\n";

    DumpVSSource(source);

    GLShaderStatus status = GLShaderStatus::OK;
    if (auto vs = GLVertexShader::create(source, status); status == GLShaderStatus::OK)
        return vs;

    return GLVertexShader{};
}


GLFragmentShader
buildAtmosphereFragmentShader(const ShaderProperties& props)
{
    bool transmissionOnly =
        props.effects == LightingEffects::AtmosphereTransmission;
    bool dualSource =
        props.effects == LightingEffects::AtmosphereDualSource;

    std::string source(VersionHeader);
#ifdef GL_ES
    if (dualSource)
        source += "#extension GL_EXT_blend_func_extended : require\n";
#endif
    source += CommonHeader;
    source += FragmentHeader;
    if (dualSource)
        source += DeclareOutput("atmosphereTransmission", Shader_Vector4);

    source += DeclareLights(props);
    source += DeclareUniform("eyePosition", Shader_Vector3);
    source += ScatteringConstantDeclarations(props);

    // Must match the centroid output in buildAtmosphereVertexShader.
    source += "centroid " + DeclareInput("position", Shader_Vector3);

    if (props.hasEclipseShadows())
    {
        for (unsigned int i = 0; i < props.nLights; ++i)
        {
            for (unsigned int j = 0; j < props.getEclipseShadowCountForLight(i); ++j)
            {
                source += DeclareUniform(IndexedParameter("shadowTexGenS", i, j), Shader_Vector4);
                source += DeclareUniform(IndexedParameter("shadowTexGenT", i, j), Shader_Vector4);
                source += DeclareUniform(IndexedParameter("shadowFalloff", i, j), Shader_Float);
                source += DeclareUniform(IndexedParameter("shadowMaxDepth", i, j), Shader_Float);
            }
        }
    }

    if (props.hasRingShadowForLight(0))
    {
        source += DeclareUniform("ringTex", Shader_Sampler2D);
        source += DeclareUniform("ringPlane", Shader_Vector4);
        source += DeclareUniform("ringCenter", Shader_Vector3);
        source += DeclareUniform("ringRadius", Shader_Float);
        source += DeclareUniform("ringWidth", Shader_Float);
        source += DeclareUniform("ringScale", Shader_Vector3);
        source += DeclareUniform("ringShadowLOD0", Shader_Float);
    }

    if (!transmissionOnly)
    {
        for (unsigned i = 0; i < props.nLights; i++)
            source += DeclareLocal(ScatteredColor(i), Shader_Vector3);
    }

    source += "\nvoid main(void)\n{\n";

    source += "vec3 nposition = normalize(position);\n";
    source += "vec3 eyeDir = normalize(eyePosition - nposition);\n";

    source += DeclareLocal("scatterEx", Shader_Vector3);
    if (transmissionOnly)
    {
        source += AtmosphericTransmission(props);
        source += "    fragColor = vec4(scatterEx, 1.0);\n";
    }
    else
    {
        source += AtmosphericEffects(props, 0);

        source += "vec3 color = vec3(0.0);\n";
        for (unsigned int i = 0; i < std::min(static_cast<unsigned int>(props.nLights), 1u); ++i)
            source += "    color += " + ScatteredColor(i) + ";\n";
        source += "    fragColor = vec4(color, 0.0);\n";
        if (dualSource)
            source += "    atmosphereTransmission = vec4(scatterEx, 1.0);\n";
    }
    source += "}\n";

    DumpFSSource(source);

    GLShaderStatus status = GLShaderStatus::OK;
    if (auto fs = GLFragmentShader::create(source, status); status == GLShaderStatus::OK)
        return fs;

    return GLFragmentShader{};
}


// The emissive shader ignores all lighting and uses the diffuse color
// as the final fragment color.
GLVertexShader
buildEmissiveVertexShader(const ShaderProperties& props, bool fisheyeEnabled)
{
    std::string source(VersionHeader);
    source += CommonHeader;
    source += VertexHeader;
    source += CommonAttribs;
    if (props.hasTextureCoordTransform())
        source += TextureTransformUniforms;

    source += DeclareUniform("opacity", Shader_Float);

    // There are no light sources used for the emissive light model, but
    // we still need the diffuse property of light 0. For other lighting
    // models, the material color is premultiplied with the light color.
    // Emissive shaders interoperate better with other shaders if they also
    // take the color from light source 0.
    source += DeclareUniform("light0_diffuse", Shader_Vector3);

    if (props.usePointSize())
        source += PointSizeDeclaration();

    source += DeclareOutput("v_Color", Shader_Vector4);
    source += DeclareOutput("v_TexCoord0", Shader_Vector2);

    source += VPFunction(props.fishEyeOverride != FisheyeOverrideMode::Disabled && fisheyeEnabled);

    // Begin main() function
    source += "\nvoid main(void)\n{\n";

    // Optional texture coordinates (generated automatically for point
    // sprites.)
    if (util::is_set(props.texUsage, TexUsage::DiffuseTexture) && !props.usePointSize())
        source += "    v_TexCoord0.st = " + TexCoord2D(0, props.hasTextureCoordTransform()) + ";\n";

    // Set the color.

    source += fmt::format("    v_Color = vec4({}, opacity);\n",
                          util::is_set(props.texUsage, TexUsage::VertexColors)
                              ? "in_Color.rgb"
                              : LightProperty(0, "diffuse"));

    // Optional point size
    if (util::is_set(props.texUsage, TexUsage::PointSprite))
        source += PointSizeCalculation();
    else if (util::is_set(props.texUsage, TexUsage::StaticPointSize))
        source += StaticPointSize();

    source += VertexPosition(props);
    source += "}\n";
    // End of main()

    DumpVSSource(source);

    GLShaderStatus status = GLShaderStatus::OK;
    if (auto vs = GLVertexShader::create(source, status); status == GLShaderStatus::OK)
        return vs;

    return GLVertexShader{};
}


GLFragmentShader
buildEmissiveFragmentShader(const ShaderProperties& props)
{
    std::string source(VersionHeader);
    source += CommonHeader;
    source += FragmentHeader;

    if (util::is_set(props.texUsage, TexUsage::DiffuseTexture))
        source += DeclareUniform("diffTex", Shader_Sampler2D);

    if (props.usePointSize())
        source += DeclareInput("pointFade", Shader_Float);

    source += DeclareInput("v_Color", Shader_Vector4);
    source += DeclareInput("v_TexCoord0", Shader_Vector2);

    // Begin main()
    source += "\nvoid main(void)\n{\n";

    std::string colorSource = "v_Color";
    if (props.usePointSize())
    {
        source += "    vec4 color = v_Color;\n";
#if POINT_FADE
        source += "    color.a *= pointFade;\n";
#endif
        colorSource = "color";
    }

    if (util::is_set(props.texUsage, TexUsage::DiffuseTexture))
    {
        if (props.usePointSize())
            source += "    fragColor = " + colorSource + " * texture(diffTex, gl_PointCoord);\n";
        else
            source += "    fragColor = " + colorSource + " * texture(diffTex, v_TexCoord0.st);\n";
    }
    else
    {
        source += "    fragColor = " + colorSource + " ;\n";
    }

    source += "}\n";
    // End of main()

    DumpFSSource(source);

    GLShaderStatus status = GLShaderStatus::OK;
    if (auto fs = GLFragmentShader::create(source, status); status == GLShaderStatus::OK)
        return fs;

    return GLFragmentShader{};
}


// Build the vertex shader used for rendering particle systems.
GLVertexShader
buildParticleVertexShader(const ShaderProperties& props, bool fisheyeEnabled)
{
    std::string source;
    source += VersionHeader;
    source += CommonHeader;
    source += VertexHeader;
    source += CommonAttribs;
    if (props.hasTextureCoordTransform())
        source += TextureTransformUniforms;

    source += "// PARTICLE SHADER\n";
    fmt::format_to(std::back_inserter(source), "// shadow count: {:x}\n", static_cast<std::uint32_t>(props.shadowCounts));

    source += DeclareLights(props);

    source += DeclareUniform("eyePosition", Shader_Vector3);

    // TODO: scattering constants

    if (props.usePointSize())
        source += PointSizeDeclaration();

     source += DeclareOutput("v_Color", Shader_Vector4);

    // Shadow parameters
    if (props.usesShadows())
        source += DeclareOutput("position", Shader_Vector3);

    source += VPFunction(props.fishEyeOverride != FisheyeOverrideMode::Disabled && fisheyeEnabled);

    // Begin main() function
    source += "\nvoid main(void)\n{\n";

#define PARTICLE_PHASE_PARAMETER 0
#if PARTICLE_PHASE_PARAMETER
    float g = -0.4f;
    float miePhaseAsymmetry = 1.55f * g - 0.55f * g * g * g;

    fmt::format_to(std::back_inserter(source), "    float mieK = {};\n", miePhaseAsymmetry);

    source += "    vec3 eyeDir = normalize(eyePosition - in_Position.xyz);\n";
    source += "    float brightness = 0.0;\n";
    for (unsigned int i = 0; i < std::min(1u, props.nLights); i++)
    {
        source += "    {\n";
        fmt::format_to(std::back_inserter(source), "         float cosTheta = dot({}, eyeDir);\n", LightProperty(i, "direction"));
        source += "         float phMie = (1.0 - mieK * mieK) / ((1.0 + mieK * cosTheta) * (1.0 + mieK * cosTheta));\n";
        source += "         brightness += phMie;\n";
        source += "    }\n";
    }
#else
    source += "    float brightness = 1.0;\n";
#endif

    // Set the color. Should *always* use vertex colors for color and opacity.
    source += "    v_Color = in_Color * brightness;\n";

    // Optional point size
    if (util::is_set(props.texUsage, TexUsage::PointSprite))
        source += PointSizeCalculation();
    else if (util::is_set(props.texUsage, TexUsage::StaticPointSize))
        source += StaticPointSize();

    source += VertexPosition(props);
    source += "}\n";
    // End of main()

    DumpVSSource(source);

    GLShaderStatus status = GLShaderStatus::OK;
    if (auto vs = GLVertexShader::create(source, status); status == GLShaderStatus::OK)
        return vs;

    return GLVertexShader{};
}

GLFragmentShader
buildParticleFragmentShader(const ShaderProperties& props)
{
    std::string source;

    source += VersionHeader;
    source += CommonHeader;
    source += FragmentHeader;

    if (util::is_set(props.texUsage, TexUsage::DiffuseTexture))
        source += DeclareUniform("diffTex", Shader_Sampler2D);

    if (props.usePointSize())
        source += DeclareInput("pointFade", Shader_Float);

    if (props.usesShadows())
    {
        source += DeclareUniform("ambientColor", Shader_Vector3);
    }

    // Declare shadow parameters
    if (props.usesShadows())
    {
        source += DeclareInput("position", Shader_Vector3);
        for (unsigned int i = 0; i < props.nLights; i++)
        {
            for (unsigned int j = 0; j < props.getEclipseShadowCountForLight(i); j++)
            {
                source += DeclareUniform(IndexedParameter("shadowTexGenS", i, j), Shader_Vector4);
                source += DeclareUniform(IndexedParameter("shadowTexGenT", i, j), Shader_Vector4);
                source += DeclareUniform(IndexedParameter("shadowFalloff", i, j), Shader_Float);
                source += DeclareUniform(IndexedParameter("shadowMaxDepth", i, j), Shader_Float);
            }
        }
    }

    source += DeclareInput("v_Color", Shader_Vector4);

    // Begin main()
    source += "\nvoid main(void)\n{\n";

    if (util::is_set(props.texUsage, TexUsage::DiffuseTexture))
        source += "    fragColor = v_Color * texture(diffTex, gl_PointCoord);\n";
    else
        source += "    fragColor = v_Color;\n";

    source += "}\n";
    // End of main()

    DumpFSSource(source);

    GLShaderStatus status = GLShaderStatus::OK;
    if (auto fs = GLFragmentShader::create(source, status); status == GLShaderStatus::OK)
        return fs;

    return GLFragmentShader{};
}

std::shared_ptr<CelestiaGLProgram>
buildProgram(const ShaderProperties& props, bool fisheyeEnabled)
{
    GLVertexShader vs;
    GLFragmentShader fs;

    if (props.lightModel == LightingModel::RingIllumModel)
    {
        vs = buildRingsVertexShader(props, fisheyeEnabled);
        fs = buildRingsFragmentShader(props);
    }
    else if (props.lightModel == LightingModel::AtmosphereModel)
    {
        vs = buildAtmosphereVertexShader(props, fisheyeEnabled);
        fs = buildAtmosphereFragmentShader(props);
    }
    else if (props.lightModel == LightingModel::EmissiveModel)
    {
        vs = buildEmissiveVertexShader(props, fisheyeEnabled);
        fs = buildEmissiveFragmentShader(props);
    }
    else if (props.lightModel == LightingModel::ParticleModel)
    {
        vs = buildParticleVertexShader(props, fisheyeEnabled);
        fs = buildParticleFragmentShader(props);
    }
    else
    {
        vs = buildVertexShader(props, fisheyeEnabled);
        fs = buildFragmentShader(props);
    }

    GLShaderStatus status = GLShaderStatus::OK;
    GLProgram program;
    if (vs.isValid() && fs.isValid())
    {
        auto builder = GLProgramBuilder::create(status);
        if (status == GLShaderStatus::OK)
        {
            builder.attach(std::move(vs));
            builder.attach(std::move(fs));
            if (props.effects == LightingEffects::AtmosphereDualSource)
            {
                builder.bindFragmentOutput(0, 0, "fragColor");
                builder.bindFragmentOutput(0, 1, "atmosphereTransmission");
            }
            program = builder.link(status);
        }
    }
    else
    {
        status = GLShaderStatus::CompileError;
    }

    if (status != GLShaderStatus::OK)
        return nullptr;

    return std::make_shared<CelestiaGLProgram>(std::move(program), props);
}

std::shared_ptr<CelestiaGLProgram>
buildProgram(std::string_view vs, std::string_view fs, bool fisheyeEnabled,
             StaticShaderOptions options = StaticShaderOptions::None)
{
    std::string_view toneMapDefine =
        util::is_set(options, StaticShaderOptions::ToneMap) ? "#define TONE_MAP\n"sv : ""sv;
    std::string vsSrc = fmt::format("{}{}{}{}{}\n", VersionHeader, CommonHeader, VertexHeader, VPFunction(fisheyeEnabled), vs);
    std::string fsSrc = fmt::format("{}{}{}{}{}\n", VersionHeader, CommonHeader, FragmentHeader, toneMapDefine, fs);

    DumpVSSource(vsSrc);
    DumpFSSource(fsSrc);

    GLShaderStatus status = GLShaderStatus::OK;
    auto _vs = GLVertexShader::create(vsSrc, status);
    auto _fs = GLFragmentShader::create(fsSrc, status);

    GLProgram program;
    if (_vs.isValid() && _fs.isValid())
    {
        auto builder = GLProgramBuilder::create(status);
        if (status == GLShaderStatus::OK)
        {
            builder.attach(std::move(_vs));
            builder.attach(std::move(_fs));
            program = builder.link(status);
        }
    }
    else
    {
        status = GLShaderStatus::CompileError;
    }

    if (status != GLShaderStatus::OK)
        return nullptr;

    return std::make_unique<CelestiaGLProgram>(std::move(program));
}

} // end unnamed namespace

bool
ShaderProperties::usesShadows() const
{
    return shadowCounts != ShadowMask::None;
}

unsigned int
ShaderProperties::getEclipseShadowCountForLight(unsigned int lightIndex) const
{
    return static_cast<unsigned int>((shadowCounts >> lightIndex * ShadowBitsPerLight) & ShadowMask::Eclipse);
}

bool
ShaderProperties::hasEclipseShadows() const
{
    return util::is_set(shadowCounts, ShadowMask::AnyEclipse);
}

void
ShaderProperties::setEclipseShadowCountForLight(unsigned int lightIndex, unsigned int shadowCount)
{
    assert(shadowCount <= MaxShaderEclipseShadows);
    assert(lightIndex < MaxShaderLights);
    if (shadowCount <= MaxShaderEclipseShadows && lightIndex < MaxShaderLights)
    {
        shadowCounts &= ~(ShadowMask::Eclipse << lightIndex * ShadowBitsPerLight);
        shadowCounts |= static_cast<ShadowMask>(shadowCount) << lightIndex * ShadowBitsPerLight;
    }
}

bool
ShaderProperties::hasRingShadowForLight(unsigned int lightIndex) const
{
    return util::is_set(shadowCounts, ShadowMask::Ring << lightIndex * ShadowBitsPerLight);
}

bool
ShaderProperties::hasRingShadows() const
{
    return util::is_set(shadowCounts, ShadowMask::AnyRing);
}

void
ShaderProperties::setRingShadowForLight(unsigned int lightIndex, bool enabled)
{
    assert(lightIndex < MaxShaderLights);
    if (lightIndex < MaxShaderLights)
        util::set_or_unset(shadowCounts, ShadowMask::Ring << lightIndex * ShadowBitsPerLight, enabled);
}

bool
ShaderProperties::hasSelfShadowForLight(unsigned int lightIndex) const
{
    return util::is_set(shadowCounts, ShadowMask::Self << lightIndex * ShadowBitsPerLight);
}

bool
ShaderProperties::hasSelfShadows() const
{
    return util::is_set(shadowCounts, ShadowMask::AnySelf);
}

void
ShaderProperties::setSelfShadowForLight(unsigned int lightIndex, bool enabled)
{
    assert(lightIndex < MaxShaderLights);
    if (lightIndex < MaxShaderLights)
        util::set_or_unset(shadowCounts, ShadowMask::Self << lightIndex * ShadowBitsPerLight, enabled);
}

bool
ShaderProperties::hasCloudShadowForLight(unsigned int lightIndex) const
{
    return util::is_set(shadowCounts, ShadowMask::Cloud << lightIndex * ShadowBitsPerLight);
}

bool
ShaderProperties::hasCloudShadows() const
{
    return util::is_set(shadowCounts, ShadowMask::AnyCloud);
}

bool
ShaderProperties::hasShadowMap() const
{
    return util::is_set(texUsage, TexUsage::ShadowMapTexture);
}

void
ShaderProperties::setCloudShadowForLight(unsigned int lightIndex, bool enabled)
{
    assert(lightIndex < MaxShaderLights);
    if (lightIndex < MaxShaderLights)
        util::set_or_unset(shadowCounts, ShadowMask::Cloud << lightIndex * ShadowBitsPerLight, enabled);
}

bool
ShaderProperties::hasShadowsForLight(unsigned int light) const
{
    assert(light < MaxShaderLights);
    return getEclipseShadowCountForLight(light) != 0 ||
           hasRingShadowForLight(light)    ||
           hasSelfShadowForLight(light)    ||
           hasCloudShadowForLight(light);
}

// Returns true if diffuse, specular, bump, and night textures all use the
// same texture coordinate set.
bool
ShaderProperties::hasSharedTextureCoords() const
{
    return util::is_set(texUsage, TexUsage::SharedTextureCoords);
}

bool
ShaderProperties::hasTextureCoordTransform() const
{
    return util::is_set(texUsage, TexUsage::TextureCoordTransform);
}

bool
ShaderProperties::hasSpecular() const
{
    return util::is_set(lightModel, LightingModel::PerPixelSpecularModel);
}

bool
ShaderProperties::hasScattering() const
{
    return util::is_set(texUsage, TexUsage::Scattering);
}

bool
ShaderProperties::isViewDependent() const
{
    switch (lightModel)
    {
    case LightingModel::DiffuseModel:
    case LightingModel::ParticleDiffuseModel:
    case LightingModel::EmissiveModel:
    case LightingModel::UnlitModel:
        return false;
    default:
        return true;
    }
}

bool
ShaderProperties::usesTangentSpaceLighting() const
{
    return util::is_set(texUsage, TexUsage::NormalTexture);
}

bool ShaderProperties::usePointSize() const
{
    return util::is_set(texUsage, TexUsage::PointSprite | TexUsage::StaticPointSize);
}

bool operator==(const ShaderProperties& lhs, const ShaderProperties& rhs)
{
    return lhs.texUsage == rhs.texUsage &&
           lhs.nLights == rhs.nLights &&
           lhs.shadowCounts == rhs.shadowCounts &&
           lhs.effects == rhs.effects &&
           lhs.fishEyeOverride == rhs.fishEyeOverride &&
           lhs.lightModel == rhs.lightModel &&
           lhs.separateRayleighMieScaleHeights == rhs.separateRayleighMieScaleHeights;
}

std::size_t
std::hash<ShaderProperties>::operator()(const ShaderProperties& props) const
{
    std::size_t seed = 0;
    boost::hash_combine(seed, props.texUsage);
    boost::hash_combine(seed, props.nLights);
    boost::hash_combine(seed, props.shadowCounts);
    boost::hash_combine(seed, props.effects);
    boost::hash_combine(seed, props.fishEyeOverride);
    boost::hash_combine(seed, props.lightModel);
    boost::hash_combine(seed, props.separateRayleighMieScaleHeights);
    return seed;
}

bool operator==(const StaticShaderProperties& lhs, const StaticShaderProperties& rhs)
{
    return lhs.shader == rhs.shader && lhs.options == rhs.options;
}

std::size_t
std::hash<StaticShaderProperties>::operator()(const StaticShaderProperties& props) const
{
    std::size_t seed = 0;
    boost::hash_combine(seed, props.shader);
    boost::hash_combine(seed, props.options);
    return seed;
}

ShaderManager::ShaderManager()
{
#if defined(_DEBUG) || defined(DEBUG) || 1
    // Only write to shader log file if this is a debug build

    if (g_shaderLogFile == nullptr)
#ifdef _WIN32
        g_shaderLogFile = new std::ofstream("shaders.log");
#else
        g_shaderLogFile = new std::ofstream("/tmp/celestia-shaders.log");
#endif

#endif
}

CelestiaGLProgram*
ShaderManager::getShader(const ShaderProperties& props)
{
    auto [it, inserted] = m_dynamicShaders.try_emplace(props);
    if (inserted)
    {
        it->second = buildProgram(props, m_fisheyeEnabled);
        if (!it->second)
            it->second = getErrorProgram();
    }

    return it->second.get();
}

CelestiaGLProgram*
ShaderManager::getShader(StaticShader staticShader, StaticShaderOptions options)
{
    StaticShaderProperties props{ staticShader, options };
    auto [it, inserted] = m_staticShaders.try_emplace(props);
    if (inserted)
        it->second = loadShader(props);

    return it->second.get();
}

std::shared_ptr<CelestiaGLProgram>
ShaderManager::loadShader(StaticShaderProperties props)
{
    auto name = StaticShaderBaseNames[static_cast<std::size_t>(props.shader)];
    auto vs = ReadShaderFile(ShaderDirectory / std::filesystem::u8path(fmt::format("{}_vert.glsl", name)));
    if (vs.empty())
        return getErrorProgram();

    auto fs = ReadShaderFile(ShaderDirectory / std::filesystem::u8path(fmt::format("{}_frag.glsl", name)));
    if (fs.empty())
        return getErrorProgram();

    std::shared_ptr<CelestiaGLProgram> result;
    result = buildProgram(vs, fs, m_fisheyeEnabled, props.options);

    return result ? result : getErrorProgram();
}

std::shared_ptr<CelestiaGLProgram>
ShaderManager::getErrorProgram()
{
    if (!m_errorProgram)
    {
        GLShaderStatus status;
        auto program = CreateErrorProgram(m_fisheyeEnabled, status);
        if (status == GLShaderStatus::OK)
            m_errorProgram = std::make_shared<CelestiaGLProgram>(std::move(program));
    }

    return m_errorProgram;
}

void ShaderManager::setFisheyeEnabled(bool enabled)
{
    m_fisheyeEnabled = enabled;
}

CelestiaGLProgram::CelestiaGLProgram(GLProgram&& _program,
                                     const ShaderProperties& _props) :
    program(std::move(_program)),
    props(_props)
{
    initParameters();
    initSamplers();
}

CelestiaGLProgram::CelestiaGLProgram(GLProgram&& _program) :
    program(std::move(_program))
{
    initCommonParameters();
}

FloatShaderParameter
CelestiaGLProgram::floatParam(const char *paramName)
{
    return FloatShaderParameter(program.getID(), paramName);
}

IntegerShaderParameter
CelestiaGLProgram::intParam(const char *paramName)
{
    return IntegerShaderParameter(program.getID(), paramName);
}

IntegerShaderParameter
CelestiaGLProgram::samplerParam(const char *paramName)
{
    return IntegerShaderParameter(program.getID(), paramName);
}

Vec2ShaderParameter
CelestiaGLProgram::vec2Param(const char *paramName)
{
    return Vec2ShaderParameter(program.getID(), paramName);
}

Vec3ShaderParameter
CelestiaGLProgram::vec3Param(const char *paramName)
{
    return Vec3ShaderParameter(program.getID(), paramName);
}

Vec4ShaderParameter
CelestiaGLProgram::vec4Param(const char *paramName)
{
    return Vec4ShaderParameter(program.getID(), paramName);
}

Mat3ShaderParameter
CelestiaGLProgram::mat3Param(const char *paramName)
{
    return Mat3ShaderParameter(program.getID(), paramName);
}

Mat4ShaderParameter
CelestiaGLProgram::mat4Param(const char *paramName)
{
    return Mat4ShaderParameter(program.getID(), paramName);
}

int
CelestiaGLProgram::attribIndex(const char *paramName) const
{
    return glGetAttribLocation(program.getID(), paramName);
}

void
CelestiaGLProgram::initCommonParameters()
{
    ModelViewMatrix = mat4Param("ModelViewMatrix");
    ProjectionMatrix = mat4Param("ProjectionMatrix");
    MVPMatrix = mat4Param("MVPMatrix");
    lineWidthX = floatParam("lineWidthX");
    lineWidthY = floatParam("lineWidthY");
}

void
CelestiaGLProgram::initAtmosphereParameters()
{
    mieCoeff = floatParam("mieCoeff");
    mieScaleHeight = floatParam("mieH");
    miePhaseAsymmetry = floatParam("mieK");
    rayleighCoeff = vec3Param("rayleighCoeff");
    if (props.separateRayleighMieScaleHeights)
        rayleighScaleHeight = floatParam("rayleighH");
    atmosphereRadius = vec3Param("atmosphereRadius");
    atmosphereSegmentCount = intParam("atmosphereSegmentCount");
    atmosphereExtinctionThreshold = floatParam("atmosphereExtinctionThreshold");
    extinctionCoeff = vec3Param("extinctionCoeff");
}

void
CelestiaGLProgram::initParameters()
{
    initCommonParameters();
    for (unsigned int i = 0; i < props.nLights; i++)
    {
        lights[i].direction  = vec3Param(LightProperty(i, "direction").c_str());
        lights[i].diffuse    = vec3Param(LightProperty(i, "diffuse").c_str());
        lights[i].specular   = vec3Param(LightProperty(i, "specular").c_str());
        lights[i].halfVector = vec3Param(LightProperty(i, "halfVector").c_str());
        if (util::is_set(props.texUsage, TexUsage::NightTexture))
            lights[i].brightness = floatParam(LightProperty(i, "brightness").c_str());
        if (props.lightModel == LightingModel::AtmosphereModel || props.hasScattering())
            lights[i].color = vec3Param(LightProperty(i, "color").c_str());

        if (props.hasRingShadowForLight(i))
            ringShadowLOD[i] = floatParam(IndexedParameter("ringShadowLOD", i).c_str());
        for (unsigned int j = 0; j < props.getEclipseShadowCountForLight(i); j++)
        {
            shadows[i][j].texGenS =
                vec4Param(IndexedParameter("shadowTexGenS", i, j).c_str());
            shadows[i][j].texGenT =
                vec4Param(IndexedParameter("shadowTexGenT", i, j).c_str());
            shadows[i][j].falloff =
                floatParam(IndexedParameter("shadowFalloff", i, j).c_str());
            shadows[i][j].maxDepth =
                floatParam(IndexedParameter("shadowMaxDepth", i, j).c_str());
        }
    }

    if (props.hasTextureCoordTransform())
    {
        for (unsigned int i = 0; i < texCoordTransforms.size(); ++i)
        {
            texCoordTransforms[i].base = vec2Param(fmt::format("texCoordBase{}", i).c_str());
            texCoordTransforms[i].delta = vec2Param(fmt::format("texCoordDelta{}", i).c_str());
        }
    }

    if (props.hasSpecular())
    {
        shininess            = floatParam("shininess");
    }

    if (props.isViewDependent() || props.hasScattering())
    {
        eyePosition          = vec3Param("eyePosition");
    }

    opacity      = floatParam("opacity");
    ambientColor = vec3Param("ambientColor");

    if (util::is_set(props.texUsage, TexUsage::RingShadowTexture))
    {
        ringWidth            = floatParam("ringWidth");
        ringRadius           = floatParam("ringRadius");
        ringPlane            = vec4Param("ringPlane");
        ringCenter           = vec3Param("ringCenter");
        ringScale            = vec3Param("ringScale");
    }
    else if (props.lightModel == LightingModel::RingIllumModel)
    {
        ringWidth            = floatParam("ringWidth");
        ringRadius           = floatParam("ringRadius");
        ringHalf             = floatParam("ringHalf");
        ringAtmosphereRadius = floatParam("ringAtmosphereRadius");
    }

    textureOffset = floatParam("texCoordOffset");

    if (util::is_set(props.texUsage, TexUsage::CloudShadowTexture))
    {
        cloudHeight         = floatParam("cloudHeight");
        shadowTextureOffset = floatParam("cloudShadowTexOffset");
    }

    if (props.effects == LightingEffects::CloudLighting)
        cloudHorizon = floatParam("cloudHorizon");

    if (util::is_set(props.texUsage, TexUsage::ShadowMapTexture))
    {
        ShadowMatrix0       = mat4Param("ShadowMatrix0");
    }

    if (props.hasScattering())
        initAtmosphereParameters();

    if (util::is_set(props.lightModel, LightingModel::LunarLambertModel))
    {
        lunarLambert         = floatParam("lunarLambert");
    }

    if (props.usePointSize())
    {
        pointScale           = floatParam("pointScale");
    }

    if (util::is_set(props.texUsage, TexUsage::LineAsTriangles))
    {
        lineWidthX           = floatParam("lineWidthX");
        lineWidthY           = floatParam("lineWidthY");
        lineEdge             = floatParam("lineEdge");
    }
}

void
CelestiaGLProgram::initSamplers()
{
    program.use();

    unsigned int nSamplers = 0;

    if (util::is_set(props.texUsage, TexUsage::DiffuseTexture))
    {
        if (GLint slot = glGetUniformLocation(program.getID(), "diffTex"); slot != -1)
            glUniform1i(slot, nSamplers);
        nSamplers++;
    }

    if (util::is_set(props.texUsage, TexUsage::NormalTexture))
    {
        if (GLint slot = glGetUniformLocation(program.getID(), "normTex"); slot != -1)
            glUniform1i(slot, nSamplers);
        nSamplers++;
    }

    if (util::is_set(props.texUsage, TexUsage::SpecularTexture))
    {
        if (GLint slot = glGetUniformLocation(program.getID(), "specTex"); slot != -1)
            glUniform1i(slot, nSamplers);
        nSamplers++;
    }

    if (util::is_set(props.texUsage, TexUsage::NightTexture))
    {
        if (GLint slot = glGetUniformLocation(program.getID(), "nightTex"); slot != -1)
            glUniform1i(slot, nSamplers);
        nSamplers++;
    }

    if (util::is_set(props.texUsage, TexUsage::EmissiveTexture))
    {
        if (GLint slot = glGetUniformLocation(program.getID(), "emissiveTex"); slot != -1)
            glUniform1i(slot, nSamplers);
        nSamplers++;
    }

    if (util::is_set(props.texUsage, TexUsage::OverlayTexture))
    {
        if (GLint slot = glGetUniformLocation(program.getID(), "overlayTex"); slot != -1)
            glUniform1i(slot, nSamplers);
        nSamplers++;
    }

    if (util::is_set(props.texUsage, TexUsage::CloudShadowTexture))
    {
        if (GLint slot = glGetUniformLocation(program.getID(), "cloudShadowTex"); slot != -1)
            glUniform1i(slot, nSamplers);
        nSamplers++;
    }

    if (util::is_set(props.texUsage, TexUsage::RingShadowTexture))
    {
        if (GLint slot = glGetUniformLocation(program.getID(), "ringTex"); slot != -1)
            glUniform1i(slot, nSamplers);
        nSamplers++;
    }

    if (util::is_set(props.texUsage, TexUsage::ShadowMapTexture))
    {
        if (GLint slot = glGetUniformLocation(program.getID(), "shadowMapTex0"); slot != -1)
            glUniform1i(slot, nSamplers);
    }
}

void
CelestiaGLProgram::setLightParameters(const LightingState& ls,
                                      Color materialDiffuse,
                                      Color materialSpecular,
                                      Color materialEmissive)
{
    unsigned int nLights = std::min(MaxShaderLights, ls.nLights);

    Eigen::Vector3f diffuseColor = materialDiffuse.toVector3();
    Eigen::Vector3f specularColor = materialSpecular.toVector3();

    for (unsigned int i = 0; i < nLights; i++)
    {
        const DirectionalLight& light = ls.lights[i];

        Eigen::Vector3f lightColor = light.color.toVector3() * light.irradiance;
        lights[i].color = light.color.toVector3();
        lights[i].direction = light.direction_obj;
        lights[i].diffuse = lightColor.cwiseProduct(diffuseColor);
        lights[i].brightness = lightColor.maxCoeff();
        lights[i].specular = lightColor.cwiseProduct(specularColor);

        Eigen::Vector3f halfAngle_obj = ls.eyeDir_obj + light.direction_obj;
        if (halfAngle_obj.norm() != 0.0f)
            halfAngle_obj.normalize();
        lights[i].halfVector = halfAngle_obj;
    }

    eyePosition = ls.eyePos_obj;
    ambientColor = ls.ambientColor.cwiseProduct(diffuseColor) + materialEmissive.toVector3();
    opacity = materialDiffuse.alpha();
}

/** Set GLSL shader constants for shadows from ellipsoid occluders; shadows from
 *  irregular objects are not handled yet.
 *  \param scaleFactors the scale factors of the object being shadowed
 *  \param orientation orientation of the object being shadowed
 */
void
CelestiaGLProgram::setEclipseShadowParameters(const LightingState& ls,
                                              const Eigen::Vector3f& scaleFactors,
                                              const Eigen::Quaternionf& orientation)
{
    // Compute the transformation from model to world coordinates
    Eigen::Affine3f rotation(orientation.conjugate());
    Eigen::Matrix4f modelToWorld = (rotation * Eigen::Scaling(scaleFactors)).matrix();

    // The shadow bias matrix maps from
    Eigen::Matrix4f shadowBias;
    shadowBias << 0.5f, 0.0f, 0.0f, 0.5f,
                  0.0f, 0.5f, 0.0f, 0.5f,
                  0.0f, 0.0f, 0.5f, 0.5f,
                  0.0f, 0.0f, 0.0f, 1.0f;

    for (unsigned int li = 0;
         li < std::min(ls.nLights, MaxShaderLights);
         li++)
    {
        if (ls.shadows[li] != nullptr)
        {
            auto nShadows = std::min(MaxShaderEclipseShadows, static_cast<unsigned int>(ls.shadows[li]->size()));

            for (unsigned int i = 0; i < nShadows; i++)
            {
                EclipseShadow& shadow = ls.shadows[li]->at(i);
                CelestiaGLProgramShadow& shadowParams = shadows[li][i];

                // Compute shadow parameters: max depth of at the center of the shadow
                // (always 1 if an eclipse is total) and the linear falloff
                // rate from the center to the outer endge of the penumbra.
                float umbra = shadow.umbraRadius / shadow.penumbraRadius;
                shadowParams.falloff = -shadow.maxDepth / std::max(0.001f, 1.0f - std::fabs(umbra));
                shadowParams.maxDepth = shadow.maxDepth;

                // Compute a transformation that will rotate points in world space to shadow space
                Eigen::Vector3f u = shadow.direction.unitOrthogonal();
                Eigen::Vector3f v = u.cross(shadow.direction);
                Eigen::Matrix4f shadowRotation;
                shadowRotation << u.transpose(),                0.0f,
                                  v.transpose(),                0.0f,
                                  shadow.direction.transpose(), 0.0f,
                                  0.0f, 0.0f, 0.0f,             1.0f;

                // Compose the world-to-shadow matrix
                Eigen::Matrix4f worldToShadow = shadowRotation *
                                                Eigen::Affine3f(Eigen::Scaling(1.0f / shadow.penumbraRadius)).matrix() *
                                                Eigen::Affine3f(Eigen::Translation3f(-shadow.origin)).matrix();

                // Finally, multiply all the matrices together to get the mapping from
                // object space to shadow map space.
                Eigen::Matrix4f m = shadowBias * worldToShadow * modelToWorld;

                shadowParams.texGenS = m.row(0);
                shadowParams.texGenT = m.row(1);
            }
        }
    }
}

void
CelestiaGLProgram::setRingShadowParameters(const LightingState& ls,
                                           const Eigen::Vector3f& scale)
{
    const RingSystem& rings = *ls.shadowingRingSystem;
    ringRadius = rings.innerRadius;
    ringWidth = 1.0f / (rings.outerRadius - rings.innerRadius);
    ringPlane = Eigen::Hyperplane<float, 3>(ls.ringPlaneNormal, ls.ringCenter).coeffs();
    ringCenter = ls.ringCenter;
    ringScale = scale;

    for (unsigned int lightIndex = 0; lightIndex < props.nLights; ++lightIndex)
    {
        if (props.hasRingShadowForLight(lightIndex))
            ringShadowLOD[lightIndex] = ls.ringShadows[lightIndex].texLod;
    }
}

// Set the scattering and absorption shader parameters for atmosphere simulation.
// They are from standard units to the normalized system used by the shaders.
// atmPlanetRadius - the radius in km of the planet with the atmosphere
// objRadius - the radius in km of the object we're rendering
// skySphereRadius - the finite outer radius of the atmosphere in km
void
CelestiaGLProgram::setAtmosphereParameters(const Atmosphere& atmosphere,
                                           float atmPlanetRadius,
                                           float objRadius,
                                           float skySphereRadius,
                                           unsigned int segmentCount,
                                           float extinctionThreshold)
{
    float sourceMieCoeff = props.separateRayleighMieScaleHeights
        ? atmosphere.mieCoeff : atmosphere.getLegacyMieCoeff();
    float sourceMieScaleHeight = props.separateRayleighMieScaleHeights
        ? atmosphere.mieScaleHeight : atmosphere.getLegacyScaleHeight();
    float tMieCoeff                  = sourceMieCoeff * objRadius;
    Eigen::Vector3f tRayleighCoeff   = atmosphere.rayleighCoeff * objRadius;
    Eigen::Vector3f tAbsorptionCoeff = atmosphere.absorptionCoeff * objRadius;

    float r = skySphereRadius / objRadius;
    atmosphereRadius = Eigen::Vector3f(r, r * r, atmPlanetRadius / objRadius);
    atmosphereSegmentCount = static_cast<int>(segmentCount);
    atmosphereExtinctionThreshold = extinctionThreshold;

    mieCoeff = tMieCoeff;
    mieScaleHeight = objRadius / sourceMieScaleHeight;

    // The scattering shaders use the Schlick approximation to the
    // Henyey-Greenstein phase function because it's slightly faster
    // to compute. Convert the HG asymmetry parameter to the Schlick
    // parameter.
    float g = atmosphere.miePhaseAsymmetry;
    miePhaseAsymmetry = 1.55f * g - 0.55f * g * g * g;

    rayleighCoeff = tRayleighCoeff;
    if (props.separateRayleighMieScaleHeights)
        rayleighScaleHeight = objRadius / atmosphere.rayleighScaleHeight;

    Eigen::Vector3f tScatterCoeffSum = tRayleighCoeff.array() + tMieCoeff;
    extinctionCoeff = tScatterCoeffSum + tAbsorptionCoeff;
}

void
CelestiaGLProgram::setMVPMatrices(const Eigen::Matrix4f& p, const Eigen::Matrix4f& m)
{
    ProjectionMatrix = p;
    ModelViewMatrix = m;
    MVPMatrix = p * m;
}
