t2-mapper/reference/Tribes2_Fog_System.md

32 KiB

Tribes 2 Engine Fog System

This document describes the fog rendering system used in Tribes 2 (based on the V12/Torque engine circa 2001). It provides enough detail to reimplement the system in another renderer such as Three.js.

Important Note: The version of Torque used by Tribes 2 does NOT use fogDensity or the later VolumetricFog object. Instead, it uses a combination of distance-based "haze" and height-based "fog volumes."

Table of Contents

  1. Overview
  2. Mission File Parameters
  3. Haze (Distance-Based Fog)
  4. Fog Volumes (Height-Based Fog)
  5. Combined Fog Calculation
  6. Fog Application by Object Type
  7. The Fog Texture
  8. Three.js Implementation Guide
  9. Additional Notes
  10. The $specialFog Console Variable

Overview

The Tribes 2 fog system has two independent components that are combined:

  1. Haze: Distance-based fog with a quadratic falloff curve. Applies uniformly regardless of height.
  2. Fog Volumes: Up to three height-bounded fog layers that add additional fog based on how much of the ray from camera to object passes through each volume.

The final fog value is the sum of haze and fog volume contributions, clamped to [0, 1].


Mission File Parameters

These parameters are defined on the Sky object in .mis files:

Core Parameters

Parameter Type Description
visibleDistance Float Maximum view distance in world units. Objects beyond this are fully fogged.
fogDistance Float Distance where fog begins. No fog closer than this.
fogColor ColorF (RGBA) Color of the distance-based haze (0.0-1.0 per channel).

Fog Volume Parameters

Each volume is defined by two fields:

Parameter Type Description
fogVolume1 Point3F (visibleDistance, minHeight, maxHeight) for volume 1
fogVolume2 Point3F (visibleDistance, minHeight, maxHeight) for volume 2
fogVolume3 Point3F (visibleDistance, minHeight, maxHeight) for volume 3
fogVolumeColor1 ColorF Color for fog volume 1 (see note below)
fogVolumeColor2 ColorF Color for fog volume 2 (see note below)
fogVolumeColor3 ColorF Color for fog volume 3 (see note below)

⚠️ IMPORTANT: Per-Volume Colors Are Ignored by Default

Despite being defined in mission files, fogVolumeColor1/2/3 are NOT used by the default renderer. The engine has two fog texture building code paths controlled by the $specialFog console variable (which maps to SceneGraph::useSpecial):

  • useSpecial = false (default): Uses buildFogTexture() which ignores per-volume colors and only uses the global fogColor for all fog rendering.
  • useSpecial = true: Uses buildFogTextureSpecial() which blends per-volume colors based on camera height and distance.

Since $specialFog defaults to false and is never enabled by Tribes 2 game scripts, the per-volume colors are loaded from mission files but have no effect on rendering. All fog (both haze and fog volumes) is rendered using the global fogColor.

See The $specialFog Console Variable for details.

Example from Tribes 2 Mission (BeggarsRun.mis)

new Sky(Sky) {
    visibleDistance = "500";
    fogDistance = "225";
    fogColor = "0.257800 0.125000 0.097700 1.000000";
    fogVolume1 = "500 1 300";      // visDist=500, minHeight=1, maxHeight=300
    fogVolume2 = "1000 301 500";   // visDist=1000, minHeight=301, maxHeight=500
    fogVolume3 = "17000 501 1000"; // visDist=17000, minHeight=501, maxHeight=1000
    fogVolumeColor1 = "128.000000 128.000000 128.000000 0.000000";
    fogVolumeColor2 = "128.000000 128.000000 128.000000 0.000000";
    fogVolumeColor3 = "128.000000 128.000000 128.000000 0.000000";
};

Note that the fogVolumeColor values above use 0-255 range instead of the expected 0-1 range for TypeColorF. Many community maps have similar issues, sometimes with garbage alpha values from uninitialized memory. Since per-volume colors are unused by default (see $specialFog), this has no visual effect.


Haze (Distance-Based Fog)

Haze is a simple distance-based fog with a quadratic falloff curve. It creates a smooth transition from clear visibility to full fog.

Algorithm

// From sceneState.h:247-256
inline F32 SceneState::getHaze(F32 dist)
{
   // No fog if distance is less than fogDistance
   if (dist <= mFogDistance)
      return 0;

   // Full fog beyond visibleDistance
   if (dist > mVisibleDistance)
      return 1.0;

   // Quadratic falloff between fogDistance and visibleDistance
   F32 fogScale = 1.0 / (mVisibleDistance - mFogDistance);
   F32 distFactor = (dist - mFogDistance) * fogScale - 1.0;
   return 1.0 - distFactor * distFactor;
}

Explanation

  1. If dist <= fogDistance: No haze (return 0)
  2. If dist > visibleDistance: Full haze (return 1)
  3. Otherwise: Apply quadratic curve

The formula 1.0 - (distFactor)^2 where distFactor = (dist - fogDistance) / (visibleDistance - fogDistance) - 1.0 creates an inverted parabola that:

  • Starts at 0 when dist = fogDistance
  • Reaches 1 when dist = visibleDistance
  • Has a smooth acceleration (slow at first, faster later)

Visualization

Haze
1.0 |                    *****
    |                 ***
    |               **
    |             **
    |           **
    |         **
    |       **
    |     **
0.0 |****
    +-------------------------->
    0    fogDist    visibleDist    Distance

JavaScript Implementation

function getHaze(dist, fogDistance, visibleDistance) {
  if (dist <= fogDistance) return 0;
  if (dist > visibleDistance) return 1;

  const fogScale = 1.0 / (visibleDistance - fogDistance);
  const distFactor = (dist - fogDistance) * fogScale - 1.0;
  return 1.0 - distFactor * distFactor;
}

Fog Volumes (Height-Based Fog)

Fog volumes are horizontal fog layers that exist between specified height ranges. Each volume has its own visibility distance and color.

Data Structures

struct FogVolume {
    float visibleDistance;  // Visibility within this fog layer
    float minHeight;        // Bottom of fog layer (world Z)
    float maxHeight;        // Top of fog layer (world Z)
    float percentage;       // Fog intensity multiplier (default 1.0)
    ColorF color;           // Fog color (RGBA)
};

// Maximum of 3 fog volumes
const int MaxFogVolumes = 3;

Fog Band Processing

The engine pre-processes fog volumes into "fog bands" relative to the camera's height. This creates two lists:

  • Positive fog bands: For points above the camera
  • Negative fog bands: For points below the camera

Each band stores:

struct FogBand {
    bool isFog;     // true if this is a fog volume, false if clear air
    float cap;      // Height extent of this band
    float factor;   // Fog factor = (1 / (visibleDistance * visFactor)) * percentage
    ColorF color;   // Band color
};

Setup Algorithm

The setup happens each frame in SceneState::setupFog():

void SceneState::setupFog()
{
    // Calculate fog scale for haze
    if (mVisibleDistance == mFogDistance) {
        mFogScale = 1000.0f;  // Arbitrary large constant
    } else {
        mFogScale = 1.0 / (mVisibleDistance - mFogDistance);
    }

    // Build positive fog bands (above camera)
    mPosFogBands.clear();
    float camZ = mCamPosition.z;

    // Find first fog volume above or containing camera
    int i;
    for (i = 0; i < mNumFogVolumes; i++) {
        if (camZ < mFogVolumes[i].maxHeight)
            break;
    }

    if (i < mNumFogVolumes) {
        float prevHeight = camZ;
        for (; i < mNumFogVolumes; i++) {
            // Add clear band if there's a gap before this fog volume
            if (prevHeight < mFogVolumes[i].minHeight) {
                FogBand fb;
                fb.isFog = false;
                fb.cap = mFogVolumes[i].minHeight - prevHeight;
                fb.color = mFogVolumes[i].color;
                prevHeight = mFogVolumes[i].minHeight;
                mPosFogBands.push_back(fb);
            }

            // Add fog band for this volume
            FogBand fb;
            fb.isFog = true;
            fb.cap = mFogVolumes[i].maxHeight - prevHeight;
            fb.factor = (1 / (mFogVolumes[i].visibleDistance * mVisFactor))
                        * mFogVolumes[i].percentage;
            fb.color = mFogVolumes[i].color;
            prevHeight = mFogVolumes[i].maxHeight;
            mPosFogBands.push_back(fb);
        }
    }

    // Similar logic for negative fog bands (below camera)
    // ... (traverses volumes in reverse order)
}

Ray-Marching Through Fog Bands

When calculating fog for a point, the engine traces a ray from the camera to the point and accumulates fog through each band it passes:

F32 SceneState::getFog(float dist, float deltaZ)
{
    float haze = 0;
    Vector<FogBand> *band;

    // Choose band list based on direction
    if (deltaZ < 0) {
        deltaZ = -deltaZ;
        band = &mNegFogBands;
    } else {
        band = &mPosFogBands;
    }

    float ht = deltaZ;  // Remaining height to traverse

    for (int i = 0; i < band->size(); i++) {
        FogBand &bnd = (*band)[i];

        if (ht < bnd.cap) {
            // Ray ends within this band
            if (bnd.isFog)
                haze += dist * bnd.factor;
            break;
        }

        // Ray passes through entire band
        // Calculate distance traveled in this band using similar triangles
        float subDist = dist * bnd.cap / ht;
        if (bnd.isFog)
            haze += subDist * bnd.factor;
        dist -= subDist;
        ht -= bnd.cap;
    }

    return haze;
}

Key Insight: Similar Triangles

The fog calculation uses similar triangles to determine how much distance the ray travels through each height band:

Camera -------- dist --------> Point
   |                            |
   |                            |
   ht (total deltaZ)            |
   |                            |
   v                            v

For a band of height 'cap':
    subDist / dist = cap / ht
    subDist = dist * cap / ht

Combined Fog Calculation

The main function getHazeAndFog() combines both haze and fog volumes:

F32 SceneState::getHazeAndFog(float dist, float deltaZ)
{
    float haze = 0;

    // Calculate distance-based haze
    if (dist > mFogDistance) {
        if (dist > mVisibleDistance)
            return 1.0;

        float distFactor = (dist - mFogDistance) * mFogScale - 1.0;
        haze = 1.0 - distFactor * distFactor;
    }

    // Add height-based fog from fog volumes
    Vector<FogBand> *band;
    if (deltaZ < 0) {
        deltaZ = -deltaZ;
        band = &mNegFogBands;
    } else {
        band = &mPosFogBands;
    }

    float ht = deltaZ;
    for (int i = 0; i < band->size(); i++) {
        FogBand &bnd = (*band)[i];

        if (ht < bnd.cap) {
            if (bnd.isFog)
                haze += dist * bnd.factor;
            break;
        }
        float subDist = dist * bnd.cap / ht;
        if (bnd.isFog)
            haze += subDist * bnd.factor;
        dist -= subDist;
        ht -= bnd.cap;
    }

    // Clamp to [0, 1]
    if (haze > 1)
        return 1;
    return haze;
}

Critical Implementation Note: Parameter Separation

IMPORTANT: Notice that haze uses mFogDistance and mVisibleDistance (global parameters), while fog bands use their own per-volume visibleDistance in the factor calculation.

When implementing in Three.js or other engines:

  1. Haze must use global fogDistance and visibleDistance
  2. Fog volumes use their own per-volume visibleDistance for factor calculation
  3. These are ADDED together, not blended

A common mistake is to adjust the haze near/far values based on which fog volume the camera is in. This is incorrect - the haze always uses global parameters regardless of camera height.


Fog Application by Object Type

Different object types apply fog differently:

1. Terrain

Terrain uses a pre-computed fog texture (64x64 RGBA). Each vertex samples this texture to get its fog value.

// From terrRender.cc - allocating terrain points
ChunkCornerPoint *TerrainRender::allocPoint(Point2I pos)
{
    ChunkCornerPoint *ret = /* allocate */;
    ret->x = pos.x * mSquareSize + mBlockPos.x;
    ret->y = pos.y * mSquareSize + mBlockPos.y;
    ret->z = fixedToFloat(mCurrentBlock->getHeight(pos.x, pos.y));
    ret->distance = (*ret - mCamPos).len();

    // Get fog texture coordinates for this vertex
    gClientSceneGraph->getFogCoordPair(ret->distance, ret->z,
                                        ret->fogRed, ret->fogGreen);
    return ret;
}

The fog texture coordinates are computed as:

inline void SceneGraph::getFogCoordPair(F32 dist, F32 z, F32 &x, F32 &y)
{
    x = (getVisibleDistanceMod() - dist) * mInvVisibleDistance;
    y = (z - mHeightOffset) * mInvHeightRange;
}

2. Shapes (TSShapeInstance)

Shapes use textured fogging - a single uniform fog value for the entire shape, computed at the shape's center.

From the GarageGames forum:

"TS instances use textured fogging. Textured fogging calculates a single fog value based on the TS object's location, and uses that for the whole thing."

The fog value is obtained via:

float fogValue = state->getHazeAndFog(distance, height_difference);

This value is then used to blend the shape's color with the fog color.

3. Interiors

Interiors use vertex-based fogging - fog is calculated per-vertex.

From the GarageGames forum:

"Interiors use the vertex fog. Each vertex has a fog value placed into it during the render prep phase."


The Fog Texture

The engine builds a 64x64 RGBA texture that encodes fog values based on distance and height. This is rebuilt each frame.

Texture Layout

  • U-axis (horizontal): Distance from visibleDistance (left, u=0) to 0 (right, u=1)
  • V-axis (vertical): Height from terrain minimum (bottom, v=0) to terrain maximum (top, v=1)
  • Alpha channel: Fog amount (0-255)
  • RGB channels: Fog color

Building the Fog Texture

void SceneGraph::buildFogTexture(SceneState *pState)
{
    const Point3F &cp = pState->getCameraPosition();

    // Create texture if needed
    if (!bool(mFogTexture)) {
        mFogTexture = TextureHandle(NULL,
            new GBitmap(64, 64, false, GBitmap::RGBA), true);
    }

    TerrainBlock *block = getCurrentTerrain();
    if (block) {
        GridSquare *sq = block->findSquare(TerrainBlock::BlockShift, Point2I(0,0));
        F32 heightRange = fixedToFloat(sq->maxHeight - sq->minHeight);
        mHeightOffset = fixedToFloat(sq->minHeight);

        mInvHeightRange = 1 / heightRange;
        mInvVisibleDistance = 1 / getVisibleDistanceMod();

        GBitmap *fogBitmap = mFogTexture.getBitmap();
        F32 heightStep = heightRange / F32(fogBitmap->getHeight());

        // Distance starts at visibleDistance (inset by half texel)
        F32 distStart = getVisibleDistanceMod() -
                        (getVisibleDistanceMod() / (fogBitmap->getWidth() * 2));
        ColorI fogColor(mFogColor);

        // Pre-compute haze values for each distance column
        if (mHazeArrayDirty) {
            F32 distStep = -getVisibleDistanceMod() / F32(fogBitmap->getWidth());
            F32 dist = distStart;
            for (U32 i = 0; i < FogTextureDistSize; i++) {
                mHazeArray[i] = pState->getHaze(dist);
                F32 prevDist = dist;
                dist += distStep;
                mDistArray[i] = dist / prevDist;
            }
            mHazeArrayDirty = false;
        }

        // Build texture row by row (each row = different height)
        F32 ht = mHeightOffset + (heightStep / 2) - cp.z;  // Delta-Z from camera
        U32 fc = *((U32 *)&fogColor) & 0x00FFFFFF;  // RGB only

        for (U32 j = 0; j < fogBitmap->getHeight(); j++) {
            F32 dist = distStart;
            U32 *ptr = (U32 *)fogBitmap->getAddress(0, j);

            // Get initial fog value for this height
            F32 fogStart = pState->getFog(dist, ht);

            for (U32 i = 0; i < fogBitmap->getWidth(); i++) {
                // Combine fog volume contribution with haze
                U32 fog = (fogStart + mHazeArray[i]) * 255;
                fogStart *= mDistArray[i];  // Scale fog for shorter distance
                if (fog > 255)
                    fog = 255;
                *ptr++ = fc | (fog << 24);  // RGB | Alpha
            }
            ht += heightStep;
        }
        mFogTexture.refresh();
    }
}

Three.js Implementation Guide

Implement fog calculation in a custom shader:

// Vertex shader
varying float vFogFactor;

uniform vec3 cameraPosition;
uniform float fogDistance;
uniform float visibleDistance;
uniform int numFogVolumes;
uniform vec3 fogVolumes[3];  // (visibleDistance, minHeight, maxHeight)
uniform float fogPercentages[3];

void main() {
    vec4 worldPos = modelMatrix * vec4(position, 1.0);
    float dist = length(worldPos.xyz - cameraPosition);
    float deltaZ = worldPos.z - cameraPosition.z;

    // Calculate haze (distance-based fog)
    float haze = 0.0;
    if (dist > fogDistance && dist <= visibleDistance) {
        float fogScale = 1.0 / (visibleDistance - fogDistance);
        float distFactor = (dist - fogDistance) * fogScale - 1.0;
        haze = 1.0 - distFactor * distFactor;
    } else if (dist > visibleDistance) {
        haze = 1.0;
    }

    // Calculate fog volume contribution
    float volumeFog = calculateFogVolumes(dist, deltaZ,
                                          cameraPosition.z,
                                          numFogVolumes,
                                          fogVolumes,
                                          fogPercentages);

    vFogFactor = clamp(haze + volumeFog, 0.0, 1.0);

    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}

// Fragment shader
varying float vFogFactor;
uniform vec3 fogColor;

void main() {
    vec3 color = /* your base color calculation */;
    gl_FragColor = vec4(mix(color, fogColor, vFogFactor), 1.0);
}

Approach 2: Three.js FogExp2 Approximation

For a quick approximation, you could use Three.js's built-in fog, but note it won't match exactly:

// This is NOT an exact match but gives similar visual results
scene.fog = new THREE.Fog(fogColor, fogDistance, visibleDistance);

However, Three.js uses linear interpolation between near and far, while Tribes 2 uses quadratic. For accurate results, you need a custom shader.

JavaScript Helper Functions

// Haze calculation (distance-based fog)
function getHaze(dist, fogDistance, visibleDistance) {
  if (dist <= fogDistance) return 0;
  if (dist > visibleDistance) return 1;

  const fogScale = 1.0 / (visibleDistance - fogDistance);
  const distFactor = (dist - fogDistance) * fogScale - 1.0;
  return 1.0 - distFactor * distFactor;
}

// Fog volume structure
class FogVolume {
  constructor(
    visibleDistance,
    minHeight,
    maxHeight,
    percentage = 1.0,
    color = null,
  ) {
    this.visibleDistance = visibleDistance;
    this.minHeight = minHeight;
    this.maxHeight = maxHeight;
    this.percentage = percentage;
    this.color = color;
  }
}

// Fog band structure (precomputed relative to camera)
class FogBand {
  constructor(isFog, cap, factor, color) {
    this.isFog = isFog;
    this.cap = cap; // Height extent
    this.factor = factor; // Fog strength
    this.color = color;
  }
}

// Build fog bands for a given camera height
// Note: visibilityFactor defaults to 1.0 (full quality) matching Torque's smVisibleDistanceMod default
function setupFogBands(cameraZ, fogVolumes, visibilityFactor = 1.0) {
  const posBands = [];
  const negBands = [];

  // Sort volumes by minHeight
  const sortedVolumes = [...fogVolumes].sort(
    (a, b) => a.minHeight - b.minHeight,
  );

  // Build positive bands (above camera)
  let startIdx = sortedVolumes.findIndex((v) => cameraZ < v.maxHeight);
  if (startIdx >= 0) {
    let prevHeight = cameraZ;
    for (let i = startIdx; i < sortedVolumes.length; i++) {
      const vol = sortedVolumes[i];

      // Add clear band if gap exists
      if (prevHeight < vol.minHeight) {
        posBands.push(
          new FogBand(false, vol.minHeight - prevHeight, 0, vol.color),
        );
        prevHeight = vol.minHeight;
      }

      // Add fog band
      // factor = (1 / (volumeVisDist * visFactor)) * percentage
      // where visFactor = smVisibleDistanceMod (default 1.0)
      const factor =
        (1 / (vol.visibleDistance * visibilityFactor)) * vol.percentage;
      posBands.push(
        new FogBand(true, vol.maxHeight - prevHeight, factor, vol.color),
      );
      prevHeight = vol.maxHeight;
    }
  }

  // Build negative bands (below camera) - traverse in reverse
  startIdx = sortedVolumes.findLastIndex((v) => cameraZ > v.minHeight);
  if (startIdx >= 0) {
    let prevHeight = cameraZ;
    for (let i = startIdx; i >= 0; i--) {
      const vol = sortedVolumes[i];

      // Add clear band if gap exists
      if (prevHeight > vol.maxHeight) {
        negBands.push(
          new FogBand(false, prevHeight - vol.maxHeight, 0, vol.color),
        );
        prevHeight = vol.maxHeight;
      }

      // Add fog band
      const factor =
        (1 / (vol.visibleDistance * visibilityFactor)) * vol.percentage;
      negBands.push(
        new FogBand(true, prevHeight - vol.minHeight, factor, vol.color),
      );
      prevHeight = vol.minHeight;
    }
  }

  return { posBands, negBands };
}

// Calculate fog from volumes using fog bands
function getFogFromBands(dist, deltaZ, bands) {
  let fog = 0;
  const isNegative = deltaZ < 0;
  const bandList = isNegative ? bands.negBands : bands.posBands;
  deltaZ = Math.abs(deltaZ);

  let remainingHeight = deltaZ;
  let remainingDist = dist;

  for (const band of bandList) {
    if (remainingHeight < band.cap) {
      // Ray ends within this band
      if (band.isFog) {
        fog += remainingDist * band.factor;
      }
      break;
    }

    // Ray passes through entire band
    const subDist = (remainingDist * band.cap) / remainingHeight;
    if (band.isFog) {
      fog += subDist * band.factor;
    }
    remainingDist -= subDist;
    remainingHeight -= band.cap;
  }

  return fog;
}

// Complete fog calculation
function getHazeAndFog(dist, deltaZ, fogDistance, visibleDistance, fogBands) {
  const haze = getHaze(dist, fogDistance, visibleDistance);

  if (haze >= 1.0) return 1.0;

  const volumeFog = getFogFromBands(dist, deltaZ, fogBands);

  return Math.min(1.0, haze + volumeFog);
}

Usage Example

// Parse mission file parameters
const fogDistance = 225;
const visibleDistance = 500;
const fogColor = new THREE.Color(0.258, 0.125, 0.098);

const fogVolumes = [
  new FogVolume(500, 1, 300),
  new FogVolume(1000, 301, 500),
  new FogVolume(17000, 501, 1000),
];

// In render loop
function animate() {
  const cameraZ = camera.position.z;
  const fogBands = setupFogBands(cameraZ, fogVolumes);

  // For each object or vertex
  const objectPos = new THREE.Vector3(/* ... */);
  const dist = camera.position.distanceTo(objectPos);
  const deltaZ = objectPos.z - camera.position.z;

  const fogFactor = getHazeAndFog(
    dist,
    deltaZ,
    fogDistance,
    visibleDistance,
    fogBands,
  );

  // Apply fog to object color
  // objectColor.lerp(fogColor, fogFactor);
}

Additional Notes

Sky Fog Bans (Horizon Fog Gradient)

The Sky object renders "bans" (fog bands) directly onto the sky to create a smooth fog-to-sky gradient at the horizon. This is controlled by the noRenderBans property (default: false).

How Sky Fog Bans Work

When the camera is NOT inside a fog volume, the engine renders fog bands as geometry overlaid on the skybox:

// sky.cc - Key constants
#define HORIZON 0.0f       // Base height of lower fog band
#define OFFSET_HEIGHT 60.0f // Height of fog band above horizon (world units)

The fog bands are positioned relative to the skybox geometry:

// sky.cc:1137-1149 - Skybox corner calculation
// mRadius = visibleDistance * 0.95
// tpt = (1,1,1).normalize(mRadius) -> each component = mRadius / sqrt(3)
// mSkyBoxPt.x = mSkyBoxPt.z = mRadius / sqrt(3)

For a mission with visibleDistance = 600:

  • mRadius = 600 * 0.95 = 570
  • mSkyBoxPt.x = 570 / sqrt(3) ≈ 329 (skybox corner coordinate)

The fog band geometry spans from height 0 (HORIZON) to height 60 (OFFSET_HEIGHT) at the skybox distance. This creates a narrow fog gradient just above the terrain horizon.

Fog Ban Alpha Values

When NOT in a fog volume (depthInFog <= 0):

banHeights[0] = HORIZON;           // 0.0 - lower band at horizon
banHeights[1] = OFFSET_HEIGHT;     // 60.0 - upper band
alphaBan[0] = 0.0;                 // Upper band edge is fully transparent
alphaBan[1] = 0.0;                 // Center top is fully transparent

The fog bands render as triangle strips with linear vertex alpha interpolation:

  • Lower vertices (at horizon): full fog color (alpha = 1.0)
  • Upper vertices (at OFFSET_HEIGHT): transparent (alpha = 0.0)

This creates a gradient from solid fog color at the horizon to clear sky above.

Three.js Implementation

For a fullscreen sky shader, convert the height-based fog band to a direction-based calculation:

// Calculate the direction.y value where fog band ends
// horizonFogHeight = OFFSET_HEIGHT / sqrt(skyBoxPtX^2 + OFFSET_HEIGHT^2)
// For visibleDistance=600: horizonFogHeight ≈ 0.18

uniform float horizonFogHeight;

float baseFogFactor;
if (direction.y <= 0.0) {
  // At or below horizon: full fog
  baseFogFactor = 1.0;
} else if (direction.y >= horizonFogHeight) {
  // Above fog band: no fog (show sky)
  baseFogFactor = 0.0;
} else {
  // Within fog band: gradient
  // Use (1-t)^2 for a gentler falloff at the top, matching Torque's appearance
  float t = direction.y / horizonFogHeight;
  baseFogFactor = (1.0 - t) * (1.0 - t);
}

vec3 finalColor = mix(skyColor.rgb, fogColor, baseFogFactor);

The (1-t)² curve approximates the visual result of Torque's linear vertex interpolation when rendered through perspective projection on curved fog band geometry.

Visible Distance Culling

Objects at or beyond visibleDistance are not rendered in Tribes 2 (see sceneGraph.cc where the far plane is set to getVisibleDistanceMod()). For Three.js implementations, discard fragments at dist >= fogFar in the fog shader to prevent fully-fogged geometry from appearing as silhouettes against the sky gradient:

// In fog fragment shader
if (dist >= fogFar) {
  discard;
}

This ensures a seamless transition from fogged terrain to the sky's fog gradient.

Storm Fog

The engine supports dynamic fog changes via the stormFog scripting method, which fades fog layers in and out over time. The percentage field on fog volumes is modified during storms.

Visibility Distance Modifier (smVisibleDistanceMod)

The engine has a global smVisibleDistanceMod (range 0.5 to 1.0, default 1.0) that acts as a quality setting for draw distance. It affects fog calculation in two ways:

  1. Distance scaling: Both fogDistance and visibleDistance are multiplied by smVisibleDistanceMod, reducing effective visibility on lower quality settings.

  2. Fog volume factor: The fog factor formula uses this value:

    factor = (1 / (visibleDistance * mVisFactor)) * percentage;
    

    where mVisFactor = smVisibleDistanceMod.

Important for Three.js implementation: Since this is a quality preference (not a ratio of visibility distances), use visFactor = 1.0 for full quality:

float factor = (1.0 / volVisDist) * percentage;

A common mistake is computing visFactor = globalVisDist / volumeVisDist - this is incorrect. The Torque code passes smVisibleDistanceMod directly to SceneState, not a computed ratio.

Object Culling

Objects are culled if getHazeAndFog() returns 1.0 for their bounding box. The function isBoxFogVisible() is used for this check.


The $specialFog Console Variable

The engine contains two separate implementations for building the fog texture, controlled by the static variable SceneGraph::useSpecial which is exposed as the $specialFog console variable.

Declaration and Default Value

// sceneGraph.h:128
static bool useSpecial;

// sceneGraph.cc:96
bool SceneGraph::useSpecial = false;

// gameConnection.cc:1631
Con::addVariable("specialFog", TypeBool, &SceneGraph::useSpecial);

The Two Code Paths

// sceneGraph.cc:166-169
if(!useSpecial)
   buildFogTexture( pBaseState );
else
   buildFogTextureSpecial( pBaseState );

buildFogTexture() - Default Mode (useSpecial = false)

This is the standard fog texture builder. It computes fog values based on distance and height, but uses only the global mFogColor for the entire texture:

// sceneGraph.cc:198-265
void SceneGraph::buildFogTexture(SceneState *pState)
{
    // ...
    ColorI fogColor(mFogColor);  // Only global fog color used
    // ...
    for (U32 j = 0; j < fogBitmap->getHeight(); j++) {
        // ...
        for (U32 i = 0; i < fogBitmap->getWidth(); i++) {
            U32 fog = (fogStart + mHazeArray[i]) * 255;
            // ...
            *ptr++ = fc | (fog << 24);  // fc = global fogColor RGB
        }
    }
}

Per-volume colors (fogVolumeColor1/2/3) are completely ignored in this path.

buildFogTextureSpecial() - Special Mode (useSpecial = true)

This alternative implementation blends per-volume fog colors based on camera position:

// sceneGraph.cc:282-402
void SceneGraph::buildFogTextureSpecial(SceneState *pState)
{
    // ...
    // For single fog volume - blends volume color with global fog color
    ColorI c(hazePct * ffogColor.red + bandPct * (array[0].red * 255),
             hazePct * ffogColor.green + bandPct * (array[0].green * 255),
             hazePct * ffogColor.blue + bandPct * (array[0].blue * 255),
             (hazePct + bandPct) * 255);

    // For two fog volumes - blends both volume colors with global fog color
    ColorI c(hazePct * ffogColor.red + bandPct0 * array[0].red + bandPct1 * array[1].red,
             hazePct * ffogColor.green + bandPct0 * array[0].green + bandPct1 * array[1].green,
             hazePct * ffogColor.blue + bandPct0 * array[0].blue + bandPct1 * array[1].blue,
             (hazePct + bandPct0 + bandPct1) * 255);
    // ...
}

The volume colors are extracted via getFogs() which pulls from the FogBand structures that were populated from mFogVolumes[i].color during setupFog().

Why Per-Volume Colors Don't Work in Tribes 2

  1. Default is disabled: SceneGraph::useSpecial initializes to false
  2. Scripts never enable it: A search of GameData-base shows no scripts set $specialFog = true
  3. Result: The special fog path is never taken, so per-volume colors have no effect

Enabling Per-Volume Colors

To enable per-volume fog colors, you would need to run in the game console:

$specialFog = true;

This was likely a feature that was implemented but never shipped as the default, possibly due to performance concerns or visual issues.


References

  • tribes2-engine/sceneGraph/sceneState.cc - Core fog calculation functions
  • tribes2-engine/sceneGraph/sceneGraph.cc - Fog texture building
  • tribes2-engine/terrain/sky.cc - Sky object and fog volume configuration
  • tribes2-engine/terrain/terrRender.cc - Terrain fog application
  • GarageGames Forum Discussion on Fog Rendering
  • The Game Programmer's Guide to Torque, Chapter 8.4.5 (Fog)