Merge remote-tracking branch 'origin/visual'

# Conflicts:
#	CHANGELOG.md
#	client/scripts/autoloads/fog_state.gd
#	client/shaders/fog.gdshader
This commit is contained in:
2026-02-28 23:43:26 +01:00
4 changed files with 204 additions and 131 deletions
+3
View File
@@ -17,8 +17,11 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
### Fixed
- Fog system: blocky stair-stepped edges at vision cone boundary — doubled Gaussian blur step size for D-066 compliant 6-8 tile smooth gradient (#569)
- Fog system: zero visibility in explored areas — switched bounds calculation from visible_tiles (empty in live server mode) to visible_positions, and removed shader guard that cut off gradient bleed into unexplored tiles (#569)
- Fog shader alpha tuned to D-059 spec: light fog 0.25-0.35 (was 0.25-0.55), deep fog 0.55-0.70 (was 0.78-0.90) — world content now visible through fog instead of hidden behind it (#563)
### Changed
- Fog shader now distinguishes light fog (near cone, neutral dark) from deep fog (far from cone, zone temperature tint) with separate Perlin noise breathing cycles (8-10s / 15-20s)
- Zone temperature tint populated per-tile from server zone_id: bar=warm amber-dark, hub=cool blue-dark, corridor=neutral dark (D-059/D-046/D-077)
- Simplified vision cone from 3-sector (forward/peripheral/blind) to forward-only 120° arc — server sends only forward-cone tiles, client renders explored tiles behind the player with light fog overlay
- Simplified fog shader from 5-layer to 3-layer model (clear, explored, unexplored)
- Fog texture resize now preserves exploration data — tiles behind the player stay as light fog instead of reverting to unexplored black
+78 -3
View File
@@ -8,12 +8,29 @@ extends Node
# Used by fog shader to distinguish visual treatment per tile.
# Test assertions reference these: assert_that(byte).is_equal(FogState.VIS_FORWARD)
const VIS_HIDDEN: int = 0 # Not in LOS — fully fogged
const VIS_PERIPHERAL: int = 180 # DEPRECATED: peripheral sector removed in Sprint 22 (#569). Retained — tests still reference it.
const VIS_FORWARD: int = 255 # In LOS, forward sector — clear vision
const EXP_UNEXPLORED: int = 0 # Never seen — total darkness
const EXP_EXPLORED: int = 128 # Previously seen, now out of LOS — deep fog
const EXP_VISIBLE: int = 255 # Currently in LOS — clear (written each frame)
# Zone temperature tints (D-059 + D-046, Sprint 22) — keyed by zone_id string from server.
# Matches audio_manager.gd ZONE_ASSETS zone_id strings for consistent zone semantics.
# Low saturation is intentional (D-046): tints are subtle — distinguishable as warm/cool/neutral
# in side-by-side comparison, not garish. The "Hopper test" validates this.
# Colors are dark tints used as the fog overlay in the deep fog zone:
# hub/workplace: #1a1f2e (cool blue-dark — terminal, institutional)
# bar: #2a1f15 (warm amber-dark — social, inhabited)
# corridor: #1a1a1a (neutral dark — transitional, maintenance)
const ZONE_TINTS: Dictionary = {
"hub": Color(0.102, 0.122, 0.180), # #1a1f2e — cool blue-dark
"workplace": Color(0.102, 0.122, 0.180), # same as hub
"bar": Color(0.165, 0.122, 0.082), # #2a1f15 — warm amber-dark
"corridor": Color(0.102, 0.102, 0.102), # #1a1a1a — neutral dark
}
const ZONE_TINT_DEFAULT: Color = Color(0.102, 0.102, 0.102) # #1a1a1a neutral
var map_bounds: Rect2i = Rect2i(0, 0, 1, 1)
var visibility_texture: ImageTexture
var exploration_texture: ImageTexture
@@ -24,6 +41,8 @@ var _exp_bytes: PackedByteArray
var _vis_image: Image
var _exp_image: Image
var _tint_image: Image
# Zone tint stored as 3-channel RGB bytes (R, G, B per pixel) for preservation across resizes
var _tint_bytes: PackedByteArray
var _width: int = 1
var _height: int = 1
var _prev_visible: Dictionary = {} # Tiles visible last frame (for incremental decay)
@@ -41,6 +60,7 @@ func _ready() -> void:
func _resize(bounds: Rect2i) -> void:
var old_bounds := map_bounds
var old_exp := _exp_bytes
var old_tint := _tint_bytes # empty on first call (_ready); guard at line 104 skips copy
var old_w := _width
var old_h := _height
@@ -76,9 +96,35 @@ func _resize(bounds: Rect2i) -> void:
_exp_image = Image.create_from_data(_width, _height, false, Image.FORMAT_R8, _exp_bytes)
exploration_texture = ImageTexture.create_from_image(_exp_image)
# Zone tint — neutral dark for Sprint 6 (zone metadata deferred)
_tint_image = Image.create(_width, _height, false, Image.FORMAT_RGB8)
_tint_image.fill(Color(0.05, 0.05, 0.08))
# Zone tint — 3 bytes per pixel (RGB), default neutral dark
var tint_sz := sz * 3
_tint_bytes = PackedByteArray()
_tint_bytes.resize(tint_sz)
var default_r := int(ZONE_TINT_DEFAULT.r * 255.0)
var default_g := int(ZONE_TINT_DEFAULT.g * 255.0)
var default_b := int(ZONE_TINT_DEFAULT.b * 255.0)
for i in range(sz):
_tint_bytes[i * 3 + 0] = default_r
_tint_bytes[i * 3 + 1] = default_g
_tint_bytes[i * 3 + 2] = default_b
# Preserve zone tint data from old bounds (zone tints are stable — tile zone never changes)
if old_tint.size() > 0 and old_w > 0 and old_h > 0:
var dx: int = old_bounds.position.x - bounds.position.x
var dy: int = old_bounds.position.y - bounds.position.y
for oy in range(old_h):
var ny: int = oy + dy
if ny < 0 or ny >= _height:
continue
for ox in range(old_w):
var nx: int = ox + dx
if nx < 0 or nx >= _width:
continue
var old_idx := (oy * old_w + ox) * 3
var new_idx := (ny * _width + nx) * 3
_tint_bytes[new_idx + 0] = old_tint[old_idx + 0]
_tint_bytes[new_idx + 1] = old_tint[old_idx + 1]
_tint_bytes[new_idx + 2] = old_tint[old_idx + 2]
_tint_image = Image.create_from_data(_width, _height, false, Image.FORMAT_RGB8, _tint_bytes)
zone_tint_texture = ImageTexture.create_from_image(_tint_image)
_prev_visible.clear()
@@ -132,6 +178,35 @@ func update_from_state() -> void:
_exp_image.set_data(_width, _height, false, Image.FORMAT_R8, _exp_bytes)
exploration_texture.update(_exp_image)
# 3. Zone tint: write zone temperature color for currently visible tiles.
# Zone data is stable (tile zone never changes) so we only write on first sight.
# Data persists in _tint_bytes across frames and across resizes.
var tint_dirty := false
for tile in tiles:
if not tile is Dictionary or not tile.has("x") or not tile.has("y"):
continue
var zone_id: String = str(tile.get("zone_id", ""))
if zone_id.is_empty():
continue
var tint_color: Color = ZONE_TINTS.get(zone_id, ZONE_TINT_DEFAULT)
var px: int = int(tile.x) - ox
var py: int = int(tile.y) - oy
if px < 0 or py < 0 or px >= _width or py >= _height:
continue
var tint_idx := (py * _width + px) * 3
var new_r := int(tint_color.r * 255.0)
var new_g := int(tint_color.g * 255.0)
var new_b := int(tint_color.b * 255.0)
# Only update if different from current (avoid spurious texture uploads)
if _tint_bytes[tint_idx] != new_r or _tint_bytes[tint_idx + 1] != new_g or _tint_bytes[tint_idx + 2] != new_b:
_tint_bytes[tint_idx + 0] = new_r
_tint_bytes[tint_idx + 1] = new_g
_tint_bytes[tint_idx + 2] = new_b
tint_dirty = true
if tint_dirty:
_tint_image.set_data(_width, _height, false, Image.FORMAT_RGB8, _tint_bytes)
zone_tint_texture.update(_tint_image)
# Shallow copy — correct for Dictionary<Vector2i, bool/String> values
_prev_visible = positions.duplicate()
+61 -40
View File
@@ -1,13 +1,18 @@
shader_type canvas_item;
// D-059: 3-layer fog shader. Composites over world content.
// Layer 1: Clear (forward cone) — transparent, soft gradient edge
// Layer 2: Explored fog — light overlay (~25-30%), art fully preserved
// Layer 3: Unexplored — solid near-black #12141a
// D-059: 3-state fog shader (simplified from 5-layer by #569).
// State 1: Clear (forward cone) — transparent, soft Gaussian gradient edge (3-4 tile radius)
// State 2: Explored (out of cone, EXP_EXPLORED) — two sub-zones by vis proximity:
// a) Light fog (near cone gradient): alpha 0.25-0.35, neutral dark, 8-10s breathe
// b) Deep fog (far from cone, vis≈0): alpha 0.55-0.70, zone temperature tint, 15-20s breathe
// State 3: Unexplored — solid near-black #12141a
// D-033: Entity colors are NOT affected — they render above the fog overlay (z-layer 5).
// D-046: Zone temperature tint from zone_tint_tex — warm=bar, cool=hub, neutral=corridor.
// D-077: zone_tint_tex populated per-tile from server zone_id via fog_state.gd.
uniform sampler2D visibility_tex : filter_linear, repeat_disable;
uniform sampler2D exploration_tex : filter_nearest, repeat_disable;
uniform sampler2D zone_tint_tex : filter_nearest, repeat_disable;
uniform sampler2D exploration_tex : filter_linear, repeat_disable;
uniform sampler2D zone_tint_tex : filter_nearest, repeat_disable; // nearest: zones have hard boundaries (D-073)
uniform sampler2D noise_tex : filter_linear, repeat_enable;
uniform vec2 rect_pos; // World-space position of the ColorRect (pixels)
uniform vec2 rect_sz; // World-space size of the ColorRect (pixels)
@@ -18,12 +23,10 @@ uniform float time; // Seconds since start
uniform bool debug_exploration = false; // When true, render raw exploration texture
const vec3 UNEXPLORED_COLOR = vec3(0.071, 0.078, 0.102); // #12141a
const vec3 FOG_OVERLAY_COLOR = vec3(0.06, 0.07, 0.12); // Dark blue-grey fog tint
const vec3 DARK_OVERLAY = vec3(0.02, 0.02, 0.05); // neutral dark (light fog zone)
// D-066: 6-8 sim tile soft gradient at cone boundary.
// 7x7 Gaussian kernel sampling at 2-texel intervals spreads across ±6 tiles.
// Effective sigma = 4 tiles in world space (sigma_kernel=2.0 * step=2.0).
// At 2-sigma (8 tiles): weight drops to 0.14, giving ~6-8 tile visible gradient.
// Soft gradient via 7x7 Gaussian blur on visibility (sigma 2.0).
// Spreads the cone boundary into a 3-4 tile radius gradient — no hard tile-stepped edges.
float sample_visibility(vec2 uv) {
vec2 t = 2.0 / map_size;
float sum = 0.0;
@@ -50,12 +53,10 @@ void fragment() {
return;
}
float vis = sample_visibility(tex_uv);
float explored = texture(exploration_tex, tex_uv).r;
// Debug mode: render raw exploration texture (bypass fog rendering).
// Green = EXP_VISIBLE (255), blue = EXP_EXPLORED (128), red = EXP_UNEXPLORED (0).
if (debug_exploration) {
float explored = texture(exploration_tex, tex_uv).r;
if (explored > 0.9) {
COLOR = vec4(0.0, explored, 0.0, 0.8); // Green: currently visible
} else if (explored > 0.1) {
@@ -66,36 +67,56 @@ void fragment() {
return;
}
// D-066: 6-8 sim tile gradient at the cone boundary.
// smoothstep(0.02, 0.95, vis) maps the Gaussian-blurred visibility into a
// smooth clarity ramp spanning the full blur radius — no tile-stepping.
float clarity = smoothstep(0.02, 0.95, vis);
float vis_raw = texture(visibility_tex, tex_uv).r;
float vis = sample_visibility(tex_uv);
float explored = texture(exploration_tex, tex_uv).r;
if (explored < 0.01) {
// Unexplored: smooth fade from transparent (inside cone) to solid black (beyond).
// The Gaussian vis gradient drives alpha — same ramp as explored fog — so the
// cone edge looks seamless regardless of whether adjacent tiles are explored.
// World art is NOT preserved in this region: alpha approaches 1.0 outside the cone.
COLOR = vec4(UNEXPLORED_COLOR, 1.0 - clarity);
return;
// Prevent gradient bleed into never-explored tiles
if (explored < 0.01 && vis_raw < 0.01) {
vis = 0.0;
}
// Explored fog: light overlay preserving all art and information (D-059).
// fog_alpha ~0.27-0.31 -> world appears at ~70% brightness with blue-grey tint.
// 8-10s Perlin noise adds atmosphere without obscuring content.
float noise_val = texture(noise_tex, tile * 0.03 + vec2(time * 0.11, time * 0.07)).r;
float fog_alpha = 0.27 + noise_val * 0.04;
if (explored < 0.01 && vis < 0.01) {
// Unexplored: solid near-black — information zero
COLOR = vec4(UNEXPLORED_COLOR, 1.0);
} else {
// Light fog noise — fast cycle (~9s), ±0.05 breathing
float noise_fast = texture(noise_tex, tile * 0.03 + vec2(time * 0.11, time * 0.07)).r;
// Deep fog noise — slow cycle (~20s), ±0.075 breathing (more pronounced)
float noise_slow = texture(noise_tex, tile * 0.02 + vec2(time * 0.05, time * 0.035)).r;
// Blend from fog (clarity=0, out of cone) to clear (clarity=1, in cone)
float alpha = mix(fog_alpha, 0.0, clarity);
vec3 color = mix(FOG_OVERLAY_COLOR, vec3(0.0), clarity);
// Light fog: alpha 0.25-0.35 (centered at 0.30, ±0.05 breathe)
// Noise remapped to [-1,+1] for symmetric breathing around baseline.
float light_fog_alpha = 0.30 + (noise_fast * 2.0 - 1.0) * 0.05;
// Hard step at the explored/unexplored tile boundary.
// exploration_tex uses filter_nearest: explored is exactly 0.0, ~0.5, or 1.0.
// smoothstep maps these to 0.0 or 1.0 — no sub-tile blending.
float exp_fade = smoothstep(0.0, 0.2, explored);
alpha = mix(1.0, alpha, exp_fade);
color = mix(UNEXPLORED_COLOR, color, exp_fade);
// Deep fog: alpha 0.55-0.70 (centered at 0.625, ±0.075 breathe)
float deep_fog_alpha = 0.625 + (noise_slow * 2.0 - 1.0) * 0.075;
COLOR = vec4(color, alpha);
// Blend deep <-> light fog by proximity to cone:
// vis=0 (far from cone) → deep_factor=1 → deep fog
// vis=0.3 (cone gradient) → deep_factor=0 → light fog
// 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 uses zone_tint_tex; light fog uses neutral DARK_OVERLAY.
// zone_tint_tex is populated per-tile from server zone_id via fog_state.gd.
vec3 zone_tint = texture(zone_tint_tex, tex_uv).rgb;
// Clarity ramp: transparent inside cone, fogged at edges and beyond
float clarity = smoothstep(0.0, 0.85, vis);
float alpha = mix(fog_alpha, 0.0, clarity);
// Color: deep fog → zone temperature tint; light fog → neutral dark overlay
vec3 fog_color = mix(DARK_OVERLAY, zone_tint, deep_factor);
vec3 color = mix(fog_color, vec3(0.0), clarity);
// Soft edge between explored and unexplored
float exp_fade = smoothstep(0.0, 0.3, explored);
alpha = mix(1.0, alpha, exp_fade);
color = mix(UNEXPLORED_COLOR, color, exp_fade);
COLOR = vec4(color, alpha);
}
}
+62 -88
View File
@@ -2,10 +2,28 @@
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 (cone gradient) | Simplified — merged into gradient | Peripheral sector removed from server (#569); Gaussian blur provides soft transition |
| 3. Deep fog (previously explored) | Implemented — EXP_EXPLORED | Alpha 0.55-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.25-0.35, breathing ±0.05 (8-10s)
- Deep fog zone (EXP_EXPLORED, vis≈0): alpha 0.55-0.70, breathing ±0.075 (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, 5-layer fog system on a CanvasGroup.
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."
@@ -40,9 +58,8 @@ The initial instinct is to put a fragment shader on the CanvasGroup itself (as `
```
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)
├─ 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
@@ -60,7 +77,6 @@ FogOverlay (Node2D) [fog_shader.gd]
│ 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)
@@ -89,13 +105,13 @@ var zone_tint_texture: ImageTexture
func update_from_state() -> void:
# Called every tick by fog_shader.gd
# 1. Resize textures if map_bounds changed
# 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
# 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
# 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`.
@@ -104,79 +120,36 @@ func update_from_state() -> void:
File: `client/shaders/fog.gdshader`
The shader determines fog layer per pixel based on the visibility and exploration textures.
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):
```glsl
shader_type canvas_item;
// Light fog noise — fast cycle (8-10s), subtle ±0.05 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.075 breathing
float noise_slow = texture(noise_tex, tile * 0.02 + vec2(time * 0.05, time * 0.035)).r;
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
// Light fog: alpha 0.25-0.35 (near cone gradient)
float light_fog_alpha = 0.30 + (noise_fast * 2.0 - 1.0) * 0.05;
// Deep fog: alpha 0.55-0.70 (far from cone, EXP_EXPLORED)
float deep_fog_alpha = 0.625 + (noise_slow * 2.0 - 1.0) * 0.075;
// 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
// 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);
// 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;
}
}
// 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);
```
**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
**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
@@ -227,13 +200,14 @@ Fog entities are NOT shader effects — they're GDScript-spawned sprites under `
```
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)
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
```
@@ -253,7 +227,7 @@ Per tick (in _process or on snapshot signal):
|------|------|---------|
| `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/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) |
@@ -277,11 +251,11 @@ Per tick (in _process or on snapshot signal):
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.
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.
- **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.
- **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`.