Files
settled-reach/docs/architecture/fog-shader-spec.md
T
jpmschweitzerandClaude Opus 4.6 3100190b40 docs(docs): add frontmatter to architecture docs
Standardized YAML frontmatter on all 10 docs/architecture/ files with
title, description, type, status, ticket, decision_refs, and author
fields. Enables context-aware document loading.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 15:22:03 +01:00

14 KiB
Raw Blame History

title, description, type, status, ticket, decision_refs, author, created, updated
title description type status ticket decision_refs author created updated
Fog Shader Architecture — #430 (D-059) Shader-driven fog system architecture — FogState autoload, fragment shader spec, texture pipeline (CPU blur + RGBA8), fog entities, and zone temperature tinting architecture active #430
D-059
D-049
D-046
D-077
D-015
Tyre YYYY-MM-DD YYYY-MM-DD

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 CPU Gaussian blur (sigma 2.0) → 4× bilinear upscale → RGBA8. Smooth sub-tile gradients.
2. Light fog (cone gradient) Simplified — merged into gradient Peripheral sector removed from server (#569); CPU blur + bilinear upscale provides soft transition
3. Deep fog (previously explored) Simplified — D-015 light fog Alpha 0.25-0.35 with zone temperature tint. Explored tiles show through light haze.
4. Unexplored + maps app Deferred v0.1.2+, requires mapped_tiles in ObserverSnapshot
5. Unexplored (no maps) Implemented Solid near-black #12141a

Texture pipeline (fog_state.gd): Binary 0/255 data at 1× tile resolution → CPU Gaussian blur (sigma 2.0, radius 4) → Image.resize() 4× bilinear upscale → Image.convert() RGBA8. GL compat mode doesn't bilinear-filter R8 textures; RGBA8 at 4× resolves this. Exploration data is binarized (0/128/255 → 0/255) before blur to avoid a second gradient at the explored/visible boundary.

Alpha values (Sprint 22, D-015):

  • Light fog zone (explored, out of cone): alpha 0.25-0.35, breathing ±0.05 (8-10s)
  • Zone temperature tint active in explored zone (D-059/D-046/D-077)
  • Explored/unexplored boundary: squared fade keeps fog opaque at tile content edge

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, forward cone only)
       └─ visible_tiles: Array<{x, y, z, type, zone_id}>  (known map extent + zone metadata)

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 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 (grow-only)
    # 2. Clear visibility_image to 0 (black)
    # 3. Write visible_positions from GameState → red channel = 255 (forward cone only)
    # 4. Update exploration_image: visible pixels → 255,
    #    tiles leaving LOS decay to 128 (EXP_EXPLORED)
    # 5. Update zone_tint_image: write zone_id → temperature color per tile
    # 6. Upload changed 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 reads pre-smoothed RGBA8 textures (CPU blur + 4× bilinear upscale) and determines fog state per pixel. No GPU-side blur — 2 texture reads per pixel.

// Fog noise — 8-10s breathe cycle, ±0.05 symmetric around baseline
float noise_val = texture(noise_tex, tile * 0.03 + vec2(time * 0.11, time * 0.07)).r;
float fog_alpha = 0.30 + (noise_val * 2.0 - 1.0) * 0.05;  // 0.25-0.35

// Zone temperature tint (D-046/D-077): subtle warm/cool/neutral per zone
vec3 zone_tint = texture(zone_tint_tex, tex_uv).rgb;

// Clarity ramp: transparent inside cone, light fog at edges and beyond
float clarity = smoothstep(0.0, 0.85, vis);
float alpha = mix(fog_alpha, 0.0, clarity);
vec3 color = mix(zone_tint, vec3(0.0), clarity);

// Explored/unexplored boundary: squared fade hides tile content edges
float exp_fade = smoothstep(0.3, 1.0, explored);
exp_fade *= exp_fade;  // Steeper: fog stays opaque near content edge
alpha = mix(1.0, alpha, exp_fade);
color = mix(UNEXPLORED_COLOR, color, exp_fade);

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_from_state()
     → Grow bounds if new tiles visible
     → Write visibility from GameState.visible_positions (forward cone)
     → Decay exploration: tiles leaving LOS → EXP_EXPLORED (128)
     → Write zone tint from visible_tiles[].zone_id
     → Upload changed textures
  2. FogOverlay._process():
     → Update shader uniforms (visibility_tex, exploration_tex, zone_tint_tex, time)
     → Update fog entity positions/states from ObserverSnapshot fog entity data

Performance Budget

Component Budget Estimate Notes
CPU Gaussian blur (1×, 40×40) 0.2ms ~0.1ms Separable, sigma=2.0, radius=4 (vis: 1 pass, exp: 2 passes)
Image.resize C++ (40→160) 0.1ms ~0.05ms INTERPOLATE_BILINEAR, 2× textures
Image.convert R8→RGBA8 0.1ms ~0.02ms GL compat bilinear requires RGBA8
Texture upload (RGBA8 160×160) 0.2ms ~0.1ms 2× textures, ~200KB total
Fragment shader (1080p) 0.2ms ~0.05ms 2 texture reads/px (was 98 with GPU blur)
Fog entity sprites 0.2ms ~0.05ms 0-15 sprites, trivial draw calls
Total <1ms ~0.37ms 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 3-state fog (clear / explored / unexplored)
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, 3-4 tile radius via 7x7 Gaussian) 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 3-state 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.
  • Resolved (Sprint 22, #563): Zone temperature tints come from zone_id field on visible_tiles[] in ObserverSnapshot (D-077). fog_state.gd maps zone_id strings to ZONE_TINTS color dictionary. Without zone metadata, defaults to neutral dark #1a1a1a.