Skip to the content.

Sky System & Azeroth Astronomy

Overview

The sky rendering system in wowee follows World of Warcraft’s WotLK (3.3.5a) architecture, where skyboxes are authoritative and procedural elements serve as fallbacks only. This document explains the lore-accurate celestial system, implementation details, and critical anti-patterns to avoid.


Architecture

Component Hierarchy

SkySystem (coordinator)
├── Skybox (fullscreen gradient from the DBC sky colours - underlay)
├── StarField (procedural points: debug, "Sharp stars", or fallback when no sky M2)
├── Celestial (sun + White Lady + Blue Child)
├── Clouds (atmospheric layer)
└── LensFlare (sun glow effect)

Renderer
└── skyboxModelRenderer_ (M2Renderer in sky mode - the original client's sky M2,
                          AUTHORITATIVE - includes baked stars, clouds, planets)

Rendering Pipeline

LightingManager (DBC-driven)
  ↓ Light.dbc + LightParams.dbc + LightIntBand/LightFloatBand time-of-day bands
  ↓ produces: directionalDir, diffuseColor, skyColors, cloudDensity, fogDensity
  ↓ also resolves the active LightSkybox model path
  ↓
skyParamsFromLighting() → SkyParams (interface struct)
  ↓ adds: gameTime, weatherIntensity, useOriginalSkybox, sunOcclusion
  ↓
SkySystem::render(cmd, perFrameSet, camera, params)
  ├─→ Skybox (gradient, far plane)
  ├─→ if a sky M2 is active and no procedural stars: stop here
  ├─→ StarField (debug, "Sharp stars", or no sky M2)
  ├─→ Celestial (sun + 2 moons, uses directionalDir + gameTime)
  ├─→ Clouds (atmospheric layer, density raised by weather)
  └─→ LensFlare (screen-space sun glow, attenuated by sunOcclusion)
  ↓
Renderer draws the sky M2 (skyboxModelRenderer_) over the gradient

The sky is recorded after the terrain and before WMOs, doodads and characters. Every sky layer sits on the far plane and depth-tests without writing, so pixels a hill covers are skipped. Only the terrain goes first because it is the one fully opaque pass; blended windows and leaves leave no depth to test against.

LightIntBand channels read by LightingManager (LightParamsProfile::ColorChannel): 0 is the sunlight (diffuseColor), 1 the ambient, 2-5 the sky gradient (top, middle, band 1, band 2), 6 the sky’s smog layer and 7 the fog.


Celestial Bodies (Lore)

The Two Moons of Azeroth

Azeroth has two moons visible in the night sky, both significant to the world’s lore:

White Lady (Primary Moon)

Blue Child (Secondary Moon)

Visibility

The Sun


Deterministic Moon Phases

Server Time-Driven (NOT deltaTime)

Moon phases are computed from server game time, ensuring:

Calculation Formula

float computePhaseFromGameTime(float gameTime, float cycleDays) {
    constexpr float SECONDS_PER_GAME_DAY = 1440.0f;  // 1 game day = 24 real minutes
    float gameDays = gameTime / SECONDS_PER_GAME_DAY;
    float phase = fmod(gameDays / cycleDays, 1.0f);
    return (phase < 0.0f) ? phase + 1.0f : phase;  // Ensure positive
}

// Applied per moon
whiteLadyPhase = computePhaseFromGameTime(gameTime, 30.0f);  // 30 game days
blueChildPhase = computePhaseFromGameTime(gameTime, 27.0f);  // 27 game days

Units mismatch: the formula takes gameTime as seconds, but the value the renderer passes is GameHandler::getGameTime(), the server’s hour of day (0-24, set from the login time packet). Divided by 1440 and 30, a whole day moves the phase by under 0.001, so both moons stay at new moon.

Phase Representation

Fallback Mode (Development)

If gameTime < 0.0 (server time unavailable):


Sky Dome Rendering

Camera-Locked Behavior (WoW Standard)

The gradient is a fullscreen triangle with no mesh (skybox.vert.glsl); the fragment shader rebuilds each pixel’s view ray from the projection and the view’s rotation only:

// skybox.vert.glsl
gl_Position = vec4(TexCoord * 2.0 - 1.0, 1.0, 1.0);  // depth = 1.0 (far plane)

// skybox.frag.glsl
vec3 viewDir = vec3(ndcX / projection[0][0], ndcY / abs(projection[1][1]), -1.0);
vec3 worldDir = normalize(transpose(mat3(view)) * viewDir);  // rotation only
float elev = worldDir.z;  // zenith / mid / horizon bands blend on elevation

Stars, sun, moons and clouds put z = w in clip space for the same result. The original sky M2 is re-centred on the camera every frame (Renderer::update).

Why this works:

Time-Based Sky Drift (Optional)

Subtle rotation for atmospheric effect:

float skyYawRotation = gameTime * skyRotationRate;
skyDomeMatrix = rotate(skyDomeMatrix, skyYawRotation, vec3(0, 0, 1));  // Yaw only

Per-zone rotation rates:

Implementation status: Not implemented. The original sky M2s animate on their own clock.


Critical Anti-Patterns

❌ DO NOT: Latitude-Based Star Rotation

Why it’s wrong:

What happens if you do it anyway:

Correct approach:

// ✅ Per-zone artistic constants (NOT geography)
struct SkyProfile {
    float celestialTilt;      // Artistic pitch/roll (Outland = 15°, Azeroth = 0°)
    float skyYawOffset;       // Alignment offset for authored skybox
    float skyRotationRate;    // Time-based drift (0 = static)
};

❌ DO NOT: Always Render Procedural Stars

Why it’s wrong:

Correct gating logic (SkySystem::render):

bool renderProceduralStars = false;
if (debugSkyMode_) {
    renderProceduralStars = true;  // Debug: force for testing fog/cloud attenuation
} else if (proceduralStarsEnabled_) {
    renderProceduralStars = true;  // "Sharp stars": replace the sky model's star layer
} else if (!params.useOriginalSkybox) {
    renderProceduralStars = !params.skyboxHasStars;  // Fallback ONLY if skybox missing
}

skyboxHasStars flag:

Sharp stars (Graphics setting sharpstars, Renderer::setSharpStars) is the one sanctioned case of both: it turns procedural stars on and calls M2Renderer::setSuppressBakedStars, which skips batches M2ModelClassifier marks as a sky model’s star-point layer. The baked layer is one 256x256 DXT texture stretched over the dome and goes soft at high resolutions.

❌ DO NOT: Universal Dual Moon Setup

Why it’s wrong:

Correct approach:

struct SkyProfile {
    bool dualMoons;  // Azeroth = true, Outland = false
    // ... other per-map settings
};

// In Celestial::render()
if (dualMoonMode_ && mapUsesAzerothSky) {
    renderBlueChild(camera, timeOfDay);
}

Current state: Celestial::render checks dualMoonMode_ alone. It defaults to true and nothing calls setDualMoonMode, so whenever Celestial draws, it draws both moons, on every map.


Integration Points

SkyParams Struct (Interface)

struct SkyParams {
    // Sun/moon positioning
    glm::vec3 directionalDir;   // From LightingManager (sun direction)
    glm::vec3 sunColor;          // From LightingManager (DBC diffuse color)

    // Sky colors (gradient bands; top also gates moon visibility)
    glm::vec3 skyTopColor;
    glm::vec3 skyMiddleColor;
    glm::vec3 skyBand1Color;
    glm::vec3 skyBand2Color;

    // Atmospheric effects (star/moon occlusion)
    float cloudDensity;          // 0-1, from LightingManager
    float fogDensity;            // 0-1, from LightingManager
    float horizonGlow;           // 0-1, atmospheric scattering
    float weatherIntensity;      // 0-1, rain/snow (thickens clouds, attenuates lens flare)
    float sunOcclusion;          // 0 clear to 1 blocked, line of sight to the sun

    // Time
    float timeOfDay;             // 0-24 hours (for sun/moon visibility)
    float gameTime;              // Server hour of day, -1 = none (for moon phases)

    // Skybox control
    uint32_t skyboxModelId;      // Always 0; the model path comes from LightingManager
    bool skyboxHasStars;         // Does skybox include baked stars?
    bool useOriginalSkybox;      // Original camera-centered client M2 is active
};

The renderer fills it with skyParamsFromLighting() (include/rendering/sky_params_from_lighting.hpp), shared by the parallel and inline recording paths, then sets sunOcclusion.

Lens Flare Occlusion

Renderer::sampleSunOcclusion() answers 1 (blocked) when the sun is below the horizon, when the camera is inside a WMO, when a WMO bounding-box raycast toward the sun hits within 600 yards, or when a geometric march of terrain heights (steps growing by 1.4x out to 600 yards) finds ground above the ray. Renderer::update eases sunOcclusion_ toward that answer over a quarter second so a hill edge does not snap the flare on and off. LensFlare::render also weakens the flare near the horizon and under fog, cloud and weather.

Star Occlusion by Weather

Clouds and fog affect star visibility:

// In StarField::render()
float intensity = getStarIntensity(timeOfDay);  // Time-based (night = 1.0, day = 0.0)
intensity *= (1.0f - glm::clamp(cloudDensity * 0.7f, 0.0f, 1.0f));  // Heavy clouds hide stars
intensity *= (1.0f - glm::clamp(fogDensity * 0.3f, 0.0f, 1.0f));    // Fog dims stars

if (intensity <= 0.01f) {
    return;  // Don't render invisible stars
}

Result: Cloudy/foggy nights have fewer visible stars (realistic behavior)


M2 Skybox System

LightSkybox.dbc Integration

DBC Chain:

Light.dbc (spatial volumes)
  ↓ lightParamsId (per weather condition)
LightParams.dbc (profile mapping)
  ↓ skyboxId
LightSkybox.dbc (model paths)
  ↓ M2 model name
Environments\Stars\*.m2 (actual sky dome models)

Skybox Loading Flow:

  1. LightingManager walks the in-range light volumes in weight order, picking each one’s LightParams row (by weather and underwater state), and takes the first LightSkybox model path; the current sky is kept while any volume naming it still has weight. Indoors there is no sky model
  2. LightingManager::getActiveSkyboxPath() returns that path
  3. Renderer::ensureSkyboxModel() (from Renderer::update) loads it, trying .m2 for a .mdx/.mdl name, plus its skin
  4. The old sky stays up until the new model has been read and validated; a path that fails is remembered and not retried
  5. skyboxModelRenderer_, an M2Renderer with setSkyMode(true), draws it (depth-tested, no depth writes, no collision)
  6. WOWEE_NO_SKY_M2=1 skips the sky model and leaves the procedural sky

Skybox Transition Blending (not implemented)

Sky models are swapped whole when the active path changes.

Problem: Hard swaps between skyboxes at zone boundaries look bad

Solution: Blend skyboxes using same volume weighting as lighting:

// In SkySystem::render() - Vulkan path
// Bind a sky pipeline whose VkPipelineColorBlendAttachmentState is
// configured for additive blending (srcColor=SRC_ALPHA, dstColor=ONE,
// blendOp=ADD), then render each skybox in weight order:
if (activeVolumes.size() >= 2) {
    // Primary skybox (alpha = volumes[0].weight)
    skybox1->render(camera, volumes[0].weight);
    // Secondary skybox blends additively on top
    skybox2->render(camera, volumes[1].weight);
}

Result: Smooth crossfade between zone skies, no popping

SkyProfile Configuration (not implemented)

Per-map/continent settings:

std::map<uint32_t, SkyProfile> skyProfiles = {
    // Azeroth (Eastern Kingdoms)
    {0, {
        .skyboxModelId = 123,
        .celestialTilt = 0.0f,           // No tilt, standard orientation
        .skyYawOffset = 0.0f,
        .skyRotationRate = 0.00001f,     // Very slow drift
        .dualMoons = true                // White Lady + Blue Child
    }},

    // Kalimdor
    {1, {
        .skyboxModelId = 124,
        .celestialTilt = 0.0f,
        .skyYawOffset = 0.0f,
        .skyRotationRate = 0.00001f,
        .dualMoons = true
    }},

    // Outland (Burning Crusade)
    {530, {
        .skyboxModelId = 456,
        .celestialTilt = 15.0f,          // Tilted, alien feel
        .skyYawOffset = 45.0f,           // Rotated alignment
        .skyRotationRate = 0.00005f,     // Faster, "weird" drift
        .dualMoons = false               // Different celestial setup
    }},

    // Northrend (Wrath of the Lich King)
    {571, {
        .skyboxModelId = 789,
        .celestialTilt = 0.0f,
        .skyYawOffset = 0.0f,
        .skyRotationRate = 0.00002f,     // Subtle aurora-like drift
        .dualMoons = true
    }}
};

Implementation Checklist

✅ Completed

🚧 Future Enhancements


Code References

Key Files:

Integration Points:


References