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>
This commit is contained in:
2026-02-28 14:11:56 +01:00
co-authored by Claude Opus 4.6
parent 073c5a317c
commit dddfceeb5a
3 changed files with 157 additions and 86 deletions
+75 -3
View File
@@ -15,6 +15,20 @@ 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.
# 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
@@ -25,6 +39,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)
@@ -37,6 +53,7 @@ func _ready() -> void:
func _resize(bounds: Rect2i) -> void:
var old_bounds := map_bounds
var old_exp := _exp_bytes
var old_tint := _tint_bytes
var old_w := _width
var old_h := _height
@@ -72,9 +89,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()
@@ -126,6 +169,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()
+40 -16
View File
@@ -1,9 +1,14 @@
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 indicating "not fresh", art 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 (6-8 tile spread)
// 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_linear, repeat_disable;
@@ -17,10 +22,10 @@ uniform float tile_size; // Pixels per sim tile
uniform float time; // Seconds since start
const vec3 UNEXPLORED_COLOR = vec3(0.071, 0.078, 0.102); // #12141a
const vec3 DARK_OVERLAY = vec3(0.02, 0.02, 0.05);
const vec3 DARK_OVERLAY = vec3(0.02, 0.02, 0.05); // neutral dark (light fog zone)
// Soft gradient via 7x7 Gaussian blur on visibility (sigma 2.0).
// Spreads the cone boundary into a 3-4 tile radius gradient for soft edges.
// Spreads the cone boundary into a 3-4 tile radius gradient — no hard tile-stepped edges.
float sample_visibility(vec2 uv) {
vec2 t = 1.0 / map_size;
float sum = 0.0;
@@ -49,27 +54,46 @@ void fragment() {
float vis = sample_visibility(tex_uv);
float explored = texture(exploration_tex, tex_uv).r;
// Don't bleed gradient into never-explored tiles
// Prevent gradient bleed into never-explored tiles
if (explored < 0.01 && vis_raw < 0.01) {
vis = 0.0;
}
if (explored < 0.01 && vis < 0.01) {
// Unexplored: solid near-black
// Unexplored: solid near-black — information zero
COLOR = vec4(UNEXPLORED_COLOR, 1.0);
} else {
// Continuous blend: clear vision (vis=1) -> light fog (vis=0).
// The Gaussian blur creates a smooth 3-4 tile soft gradient
// at the cone edge — no hard boundary.
// 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;
// Light fog: subtle animated overlay, preserves all art/info
float noise_val = texture(noise_tex, tile * 0.03 + vec2(time * 0.11, time * 0.07)).r;
float fog_alpha = 0.28 + noise_val * 0.04;
// 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;
// Clarity ramp from the blurred visibility
// 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;
// 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);
vec3 color = mix(DARK_OVERLAY, vec3(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);
+42 -67
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 (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, 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."
@@ -104,79 +122,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.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;
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.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;
// 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