Files
settled-reach/docs/architecture/fog-shader-spec.md
T
jpmschweitzerandClaude Opus 4.6 f08710fa61 docs(architecture): z-layer gap analysis, fog shader spec, flying taxi analysis
Three architecture documents from Sprint 6 design work:
- z-layer-gap-analysis.md: full three-scope rendering pipeline with
  reserved z-ranges, airborne treatment, cross-floor VFX, liquid depth
- fog-shader-spec.md: 5-layer fog shader architecture (D-059)
- flying-taxi-analysis.md: validates z-layer architecture supports
  flight scenarios with LOD tiers and performance budget

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:06:12 +01:00

15 KiB

Fog Shader Architecture — #430 (D-059)

Ticket: #430 | Decision: D-059 | Sprint: 6 Author: Tyre (architecture) | Implementer: Stig

Overview

Complete rewrite of the fog system. Delete fog_renderer.gd (TileMapLayer-based, 2-state binary fog) and replace with a shader-driven, 5-layer 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 layer per pixel based on the visibility and exploration textures.

shader_type canvas_item;

uniform sampler2D visibility_tex : filter_nearest;
uniform sampler2D exploration_tex : filter_nearest;
uniform sampler2D zone_tint_tex : filter_nearest;
uniform vec2 map_offset;      // World position of texture origin (in pixels)
uniform vec2 map_size;        // Texture size in tiles
uniform float tile_size;      // Pixels per tile
uniform float time;           // Engine TIME for noise animation

// Fog layer colors
const vec4 FOG_UNEXPLORED = vec4(0.071, 0.078, 0.102, 1.0);  // #12141a
const vec4 FOG_WIREFRAME = vec4(0.2, 0.2, 0.251, 1.0);        // #333340
const float LIGHT_FOG_DESAT = 0.45;   // 40-50% desaturation
const float LIGHT_FOG_DIM = 0.7;      // brightness -30%
const float DEEP_FOG_DESAT = 0.9;     // near-monochrome
const float DEEP_FOG_DIM = 0.25;      // heavy dimming
const float ZONE_TINT_STRENGTH = 0.1; // ~10% zone temperature tint

// Perlin noise (simplified — use Godot's NoiseTexture2D for production quality)
// Alternatively: pass a pre-generated noise texture as another uniform.

void fragment() {
    // Map screen pixel to tile coordinate
    vec2 world_pos = (SCREEN_UV * vec2(textureSize(visibility_tex, 0))) ;
    vec2 tile_uv = world_pos / map_size;

    // Sample textures
    float vis = texture(visibility_tex, tile_uv).r;     // 0-1: current visibility
    float explored = texture(exploration_tex, tile_uv).r; // 0-1: exploration state
    vec3 zone_tint = texture(zone_tint_tex, tile_uv).rgb;

    // Determine fog layer:
    // vis > 0.7 → Layer 1: Clear (vision cone) — soft gradient edge
    // vis > 0.3 → Layer 2: Light fog (peripheral) — desaturated, noise
    // explored > 0.4 → Layer 3: Deep fog (previously explored) — monochrome + tint
    // explored > 0.1 → Layer 4: Unexplored + maps — wireframe outlines
    // else → Layer 5: Unexplored, no maps — solid near-black

    if (vis > 0.7) {
        // Layer 1: Clear — soft gradient at edge
        float edge = smoothstep(0.7, 1.0, vis);
        COLOR = vec4(0.0, 0.0, 0.0, 1.0 - edge);  // Transparent in clear zone
    } else if (vis > 0.3) {
        // Layer 2: Light fog — desaturated + dim + animated noise
        float noise = _perlin(world_pos * 0.02 + vec2(time * 0.1, time * 0.05));
        float alpha = mix(0.4, 0.6, noise);  // Animated fog density
        COLOR = vec4(0.02, 0.02, 0.05, alpha);
    } else if (explored > 0.4) {
        // Layer 3: Deep fog — near-monochrome + zone tint + breathing noise
        float noise = _perlin(world_pos * 0.01 + vec2(time * 0.03, time * 0.02));
        vec3 tint = mix(vec3(0.05), zone_tint, ZONE_TINT_STRENGTH);
        float alpha = mix(0.75, 0.85, noise);  // Fog breathes
        COLOR = vec4(tint, alpha);
    } else if (explored > 0.1) {
        // Layer 4: Unexplored + maps app — geometric wireframe
        COLOR = FOG_WIREFRAME;
        // TODO: wireframe grid line overlay (1px every tile_size pixels)
    } else {
        // Layer 5: Unexplored, no maps — information zero
        COLOR = FOG_UNEXPLORED;
    }
}

Note: This is the architectural skeleton. The actual shader will need:

  • A proper noise function or noise texture uniform (Godot's NoiseTexture2D resource)
  • Correct world-to-UV coordinate mapping using SCREEN_UV, CANVAS_MATRIX, or vertex-passed world coords
  • The gradient edge for Layer 1 should span 6-8 sim tiles (D-059/D-066)
  • Layer 4 wireframe can use mod() on world coords for grid lines

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.