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
- Overview
- Mission File Parameters
- Haze (Distance-Based Fog)
- Fog Volumes (Height-Based Fog)
- Combined Fog Calculation
- Fog Application by Object Type
- The Fog Texture
- Three.js Implementation Guide
- Additional Notes
- The
$specialFogConsole Variable
Overview
The Tribes 2 fog system has two independent components that are combined:
- Haze: Distance-based fog with a quadratic falloff curve. Applies uniformly regardless of height.
- 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/3are NOT used by the default renderer. The engine has two fog texture building code paths controlled by the$specialFogconsole variable (which maps toSceneGraph::useSpecial):
useSpecial = false(default): UsesbuildFogTexture()which ignores per-volume colors and only uses the globalfogColorfor all fog rendering.useSpecial = true: UsesbuildFogTextureSpecial()which blends per-volume colors based on camera height and distance.Since
$specialFogdefaults tofalseand 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 globalfogColor.See The
$specialFogConsole 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
- If
dist <= fogDistance: No haze (return 0) - If
dist > visibleDistance: Full haze (return 1) - 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:
- Haze must use global
fogDistanceandvisibleDistance - Fog volumes use their own per-volume
visibleDistancefor factor calculation - 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
Approach 1: Shader-Based (Recommended)
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 = 570mSkyBoxPt.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:
-
Distance scaling: Both
fogDistanceandvisibleDistanceare multiplied bysmVisibleDistanceMod, reducing effective visibility on lower quality settings. -
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
- Default is disabled:
SceneGraph::useSpecialinitializes tofalse - Scripts never enable it: A search of
GameData-baseshows no scripts set$specialFog = true - 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 functionstribes2-engine/sceneGraph/sceneGraph.cc- Fog texture buildingtribes2-engine/terrain/sky.cc- Sky object and fog volume configurationtribes2-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)