Files
settled-reach/docs/architecture/fog-shader-spec.md
T
jpmschweitzerandClaude Opus 4.6 dddfceeb5a fix(assets): tune fog shader alpha and add zone temperature tint per D-059
Ticket #563. Light fog alpha tuned to 0.25-0.35 range (was 0.25-0.55),
deep fog alpha set to 0.55-0.70 with zone temperature tint from
zone_tint_tex (bar=warm #2a1f15, hub=cool #1a1f2e, corridor=neutral
#1a1a1a). Two Perlin noise cycles: 8-10s light, 15-20s deep.
Zone tint texture now populated per-tile from server zone_id in
fog_state.gd with preservation across texture resizes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 14:11:56 +01:00

14 KiB

Fog Shader Architecture — #430 (D-059)

Ticket: #430 | Decision: D-059 | Sprint: 6 Author: Tyre (architecture) | Implementer: Stig Updated: Sprint 22 (#569 simplification + #563 alpha tuning) | Stig + Araminta

Current State (Sprint 22)

The fog system was simplified from the original 5-layer spec in Sprint 22 (#569):

Original D-059 Layer Sprint 22 Status Notes
1. Clear (forward cone) Implemented Soft Gaussian gradient (7x7, sigma 2.0)
2. Light fog (peripheral sector) Simplified — merged into gradient Peripheral sector removed from server; gradient provides soft transition
3. Deep fog (previously explored) Implemented — EXP_EXPLORED Alpha 0.54-0.70 with zone temperature tint (#563)
4. Unexplored + maps app Deferred v0.1.2+, requires mapped_tiles in ObserverSnapshot
5. Unexplored (no maps) Implemented Solid near-black #12141a

Alpha values (Sprint 22, #563):

  • Light fog zone (near cone gradient, vis 0.0-0.3): alpha 0.26-0.34, breathing ±0.04 (8-10s)
  • Deep fog zone (EXP_EXPLORED, vis≈0): alpha 0.54-0.70, breathing ±0.08 (15-20s)
  • Zone temperature tint active in deep fog zone (D-059/D-046/D-077)

Overview

Complete rewrite of the fog system. Delete fog_renderer.gd (TileMapLayer-based, 2-state binary fog) and replace with a shader-driven fog system on a CanvasGroup.

The fog is knowledge-graph-driven — the same fog shows different information per character based on their KG. "Fog is not darkness — it's the absence of your attention."

Scene Tree (D-049 Compliant)

After the z-layer restructure, the relevant scene tree is:

World (Node2D) [world_renderer.gd]
  FogGroup (CanvasGroup)              # Composites layers 0-4
    FloorTiles (TileMapLayer)         # z:0
    FloorObjects (Node2D)             # z:1
    YSortGroup (Node2D, y_sort)       # z:2-3
      Entities (Node2D, y_sort)       # z:3
    Overhead (Node2D)                 # z:4
  FogOverlay (Node2D) [fog_shader.gd] # z:10, replaces old TileMapLayer
    FogEntities (Node2D)              # Sound pings, entity ghosts (sprites)

Key: FogGroup (CanvasGroup) composites everything in layers 0-4 into a single texture. The fog shader does NOT go on FogGroup — instead, the FogOverlay node draws a full-screen fog quad whose fragment shader reads visibility data to determine what's clear, fogged, or hidden.

Architecture

Why NOT a CanvasGroup material shader?

The initial instinct is to put a fragment shader on the CanvasGroup itself (as material). However, this has a problem: the CanvasGroup shader can only darken/modify what's already rendered. For unexplored areas, we need to draw over the world content (solid near-black, or wireframe overlay). A CanvasGroup shader can desaturate and dim, but it can't add new visual content (wireframe outlines for layer 4).

Solution: FogOverlay is a Node2D with a ColorRect child (full-viewport size) that uses a ShaderMaterial. The shader reads uniform textures to determine per-pixel fog state. This overlay sits above the FogGroup in z-order and composites fog effects over the world.

Data Flow

Server (each tick)
  └─ ObserverSnapshot
       ├─ visible_positions: Dictionary<Vector2i, true>  (LOS result)
       ├─ visibility_sectors: Dictionary<Vector2i, "Forward"|"Peripheral">
       └─ visible_tiles: Array<{x, y, z, type}>  (known map extent)

GameState (autoload)
  └─ Stores all above

FogState (new autoload)
  ├─ visibility_texture: ImageTexture   # Updated every tick from visible_positions
  ├─ exploration_texture: ImageTexture  # Persistent — accumulates explored areas
  ├─ zone_tint_texture: ImageTexture    # Per-tile zone temperature tint
  └─ map_bounds: Rect2i                 # Known map extent for texture sizing

FogOverlay (Node2D) [fog_shader.gd]
  ├─ ColorRect with ShaderMaterial
  │    └─ Fragment shader reads:
  │         uniform sampler2D visibility_tex;    # Current LOS
  │         uniform sampler2D exploration_tex;   # Historical explored
  │         uniform sampler2D zone_tint_tex;     # Zone temperature colors
  │         uniform float time;                  # For noise animation
  │         uniform vec2 player_pos;             # Vision cone center
  │         uniform vec2 map_offset;             # World-to-texture mapping
  │         uniform vec2 map_size;               # Texture dimensions in tiles
  └─ FogEntities (Node2D)
       └─ Sound pings, recognized entities, grey blobs (sprites)

FogState Autoload

New autoload: client/scripts/autoloads/fog_state.gd

This manages fog-related game state that persists across frames. It is NOT a renderer — it's data.

class_name FogState
extends Node

# Texture dimensions match map bounds (1 pixel per sim tile)
var map_bounds: Rect2i = Rect2i()
var _visibility_image: Image        # Red channel: 0=not visible, 255=visible
var _exploration_image: Image       # Red channel: 0=unexplored, 128=explored (deep fog),
                                    #              255=currently visible (clear)
var _zone_tint_image: Image         # RGB: zone temperature tint color per tile
var visibility_texture: ImageTexture
var exploration_texture: ImageTexture
var zone_tint_texture: ImageTexture

func update_from_state() -> void:
    # Called every tick by fog_shader.gd
    # 1. Resize textures if map_bounds changed
    # 2. Clear visibility_image to 0 (black)
    # 3. Write visible_positions from GameState → red channel = 255
    # 4. Write visibility_sectors: Forward = 255, Peripheral = 180
    # 5. Update exploration_image: any currently-visible pixel → 255,
    #    previously-visible pixels decay toward 128 over time
    # 6. Upload images to textures

Performance note: Image.set_pixel() in a loop is ~0.05ms for 400 tiles. Acceptable. For larger maps, switch to Image.set_data() with a pre-built PackedByteArray.

Fragment Shader

File: client/shaders/fog.gdshader

The shader determines fog state per pixel based on the visibility and exploration textures. Two explored sub-zones are distinguished by the blurred vis value (proximity to the forward cone):

// Light fog noise — fast cycle (8-10s), subtle ±0.04 breathing
float noise_fast = texture(noise_tex, tile * 0.03 + vec2(time * 0.11, time * 0.07)).r;
// Deep fog noise — slow cycle (15-20s), more pronounced ±0.08 breathing
float noise_slow = texture(noise_tex, tile * 0.02 + vec2(time * 0.05, time * 0.035)).r;

// Light fog: alpha 0.26-0.34 (near cone gradient)
float light_fog_alpha = 0.30 + noise_fast * 0.04;
// Deep fog: alpha 0.54-0.70 (far from cone, EXP_EXPLORED)
float deep_fog_alpha = 0.62 + noise_slow * 0.08;

// Blend deep <-> light fog by proximity to cone:
//   vis=0   (far from cone) → deep_factor=1 → deep fog color + alpha
//   vis=0.3 (cone gradient) → deep_factor=0 → light fog color + alpha
//   vis=0.85+ (inside cone) → clarity=1     → transparent (clear)
float deep_factor = 1.0 - smoothstep(0.0, 0.30, vis);
float fog_alpha = mix(light_fog_alpha, deep_fog_alpha, deep_factor);

// Zone temperature tint (D-059/D-046/D-077): deep fog color = zone tint
vec3 zone_tint = texture(zone_tint_tex, tex_uv).rgb;
vec3 fog_color = mix(DARK_OVERLAY, zone_tint, deep_factor);

Zone temperature palette (D-046):

  • hub / workplace: #1a1f2e — cool blue-dark (institutional/terminal)
  • bar: #2a1f15 — warm amber-dark (social/inhabited)
  • corridor: #1a1a1a — neutral dark (transitional/maintenance)

Coordinate Mapping

The shader needs to map screen pixels → tile coordinates to sample the fog textures.

Approach: The ColorRect is sized to match the viewport. In _process(), update its position to track the camera so it covers the visible area. Pass camera offset as a uniform.

Alternatively: use SCREEN_UV with SCREEN_PIXEL_SIZE and the known camera transform. This avoids moving the ColorRect.

Recommended: Make the ColorRect a child of the Camera2D (so it moves with the camera) and pass the camera's world position as a uniform. The shader then computes world_pos = camera_offset + VERTEX to get the world coordinate per pixel.

Vision Cone Integration

The vision cone is already implemented as PointLight2D on the player entity (D-046). For the fog shader, the vision cone data comes through visibility_tex (populated from GameState.visible_positions). The PointLight2D continues to provide the visual lighting effect on layers 0-4 (inside the FogGroup). The fog shader reads the same LOS data but applies it as fog/no-fog rather than light/dark.

Important distinction:

  • PointLight2D = visual lighting (warm/cool, D-046 color temperature) on world content
  • Fog shader = information boundary (what the character knows vs doesn't know)

These are separate systems that happen to use the same LOS data. The vision cone PointLight2D should NOT be removed — it provides the Darkwood-style light pooling on the world layer.

Fog Entities (Sprites on Layer 5)

Fog entities are NOT shader effects — they're GDScript-spawned sprites under FogOverlay/FogEntities:

Sound pings:

  • Scene: fog_sound_ping.tscn — 2-3 concentric Line2D circles
  • Behavior: expand from center, fade over 1.5s, insert white-blue color
  • Loud: 3 rings, bright, fast expansion
  • Quiet: 1 ring, faint, slow expansion
  • Max simultaneous: 5

Recognized entity (in fog):

  • Scene: fog_entity_ghost.tscn — colored glow + silhouette feature
  • D-033 color glow (relationship color)
  • 0.8s breathing pulse (modulate alpha oscillation)
  • +/-0.5 tile position drift (not exact — information is approximate)
  • Recognition transition: grey blob → D-033 color over ~0.3s (within D-060 cognitive delay)

Unrecognized entity (in fog):

  • Scene: fog_entity_blob.tscn — neutral grey #555566 blob
  • No identifying features, no silhouette
  • Position drift same as recognized

Max simultaneous fog entities: 10 typical, 15 max. Each is 1-2 draw calls. Trivial.

FogState Update Lifecycle

Per tick (in _process or on snapshot signal):
  1. FogState.update_visibility(GameState.visible_positions, GameState.visibility_sectors)
     → Write visibility_image, upload to visibility_texture
  2. FogState.update_exploration(GameState.visible_positions)
     → Mark visible tiles as explored, apply decay to non-visible explored tiles
     → Upload to exploration_texture
  3. FogOverlay._process():
     → Update shader uniforms (visibility_tex, exploration_tex, time, player_pos)
     → Update fog entity positions/states from ObserverSnapshot fog entity data

Performance Budget

Component Budget Estimate Notes
Visibility texture upload 0.1ms ~0.05ms 400 pixels via set_pixel()
Exploration texture update 0.1ms ~0.05ms Incremental — only changed tiles
Fragment shader (1080p) 0.5ms ~0.2ms Single full-screen pass, simple math
Fog entity sprites 0.2ms ~0.05ms 0-15 sprites, trivial draw calls
Total <1ms ~0.35ms Well within D-059 budget

Files to Create

File Type Purpose
client/scripts/autoloads/fog_state.gd Autoload Fog texture management, exploration persistence
client/scripts/rendering/fog_shader.gd Script FogOverlay node controller, shader uniform updates
client/shaders/fog.gdshader Shader Fragment shader for 5-layer fog
client/scenes/fog_sound_ping.tscn Scene Sound ping rings (deferred to Sprint 7+, #431)
client/scenes/fog_entity_ghost.tscn Scene Recognized entity ghost (deferred to Sprint 7+, #431)
client/scenes/fog_entity_blob.tscn Scene Unrecognized entity blob (deferred to Sprint 7+, #431)

Files to Delete

File Reason
client/scripts/rendering/fog_renderer.gd Replaced entirely by fog_shader.gd + fog.gdshader

Files to Modify

File Change
client/scenes/main.tscn Replace FogOverlay TileMapLayer with Node2D + ColorRect
client/scripts/rendering/world_renderer.gd Update fog_renderer reference to fog_shader
project.godot Add FogState autoload

Implementation Notes for Stig

  1. Start with the shader. Get a basic 2-layer shader working (clear vs opaque) on a ColorRect, then incrementally add layers.
  2. Coordinate mapping is the hardest part. Getting screen pixels → world tiles → texture UVs correct requires careful math. Test with a known map layout.
  3. Use Godot's NoiseTexture2D resource for the Perlin noise rather than computing it in the shader. Pass it as a uniform. Scroll the UV offset with TIME for animation.
  4. The gradient edge (Layer 1, 6-8 sim tiles) is the most visible quality differentiator. Use smoothstep() with the distance from the nearest non-visible tile. This may require encoding distance-to-edge in the visibility texture rather than binary 0/255.
  5. Fog entities are Sprint 7+ (#431). For this sprint, just get the 5-layer fog shader working. The FogEntities node can be empty.
  6. Test with the existing sim_bridge test mode — it provides a visible_positions Dictionary with a 4-tile radius and Bresenham LOS. Good enough to validate the shader.

Open Questions

  • Q: How does the "maps app" data reach the client? Layer 4 (unexplored + maps) needs to know which unexplored tiles the character's insert has map data for. This likely requires a new field in ObserverSnapshot (e.g., mapped_tiles). For Sprint 6, treat all explored tiles as "has maps" and all unexplored as "no maps" (layers 3 and 5 only, skip layer 4). Layer 4 is a v0.1.2+ feature.
  • Q: Zone temperature tints — where do they come from? Currently no per-tile zone data in the snapshot. For Sprint 6, use a hardcoded default (neutral dark). Zone tints require server-side zone metadata.