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_EXPLORED: int = 128 # Previously seen, now out of LOS — deep fog
const EXP_VISIBLE: int = 255 # Currently in LOS — clear (written each frame) 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 map_bounds: Rect2i = Rect2i(0, 0, 1, 1)
var visibility_texture: ImageTexture var visibility_texture: ImageTexture
var exploration_texture: ImageTexture var exploration_texture: ImageTexture
@@ -25,6 +39,8 @@ var _exp_bytes: PackedByteArray
var _vis_image: Image var _vis_image: Image
var _exp_image: Image var _exp_image: Image
var _tint_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 _width: int = 1
var _height: int = 1 var _height: int = 1
var _prev_visible: Dictionary = {} # Tiles visible last frame (for incremental decay) var _prev_visible: Dictionary = {} # Tiles visible last frame (for incremental decay)
@@ -37,6 +53,7 @@ func _ready() -> void:
func _resize(bounds: Rect2i) -> void: func _resize(bounds: Rect2i) -> void:
var old_bounds := map_bounds var old_bounds := map_bounds
var old_exp := _exp_bytes var old_exp := _exp_bytes
var old_tint := _tint_bytes
var old_w := _width var old_w := _width
var old_h := _height 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) _exp_image = Image.create_from_data(_width, _height, false, Image.FORMAT_R8, _exp_bytes)
exploration_texture = ImageTexture.create_from_image(_exp_image) exploration_texture = ImageTexture.create_from_image(_exp_image)
# Zone tint — neutral dark for Sprint 6 (zone metadata deferred) # Zone tint — 3 bytes per pixel (RGB), default neutral dark
_tint_image = Image.create(_width, _height, false, Image.FORMAT_RGB8) var tint_sz := sz * 3
_tint_image.fill(Color(0.05, 0.05, 0.08)) _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) zone_tint_texture = ImageTexture.create_from_image(_tint_image)
_prev_visible.clear() _prev_visible.clear()
@@ -126,6 +169,35 @@ func update_from_state() -> void:
_exp_image.set_data(_width, _height, false, Image.FORMAT_R8, _exp_bytes) _exp_image.set_data(_width, _height, false, Image.FORMAT_R8, _exp_bytes)
exploration_texture.update(_exp_image) 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 # Shallow copy — correct for Dictionary<Vector2i, bool/String> values
_prev_visible = positions.duplicate() _prev_visible = positions.duplicate()
+40 -16
View File
@@ -1,9 +1,14 @@
shader_type canvas_item; shader_type canvas_item;
// D-059: 3-layer fog shader. Composites over world content. // D-059: 3-state fog shader (simplified from 5-layer by #569).
// Layer 1: Clear (forward cone) — transparent, soft gradient edge // State 1: Clear (forward cone) — transparent, soft Gaussian gradient edge (6-8 tile spread)
// Layer 2: Explored fog — light overlay indicating "not fresh", art preserved // State 2: Explored (out of cone, EXP_EXPLORED) — two sub-zones by vis proximity:
// Layer 3: Unexplored — solid near-black #12141a // 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 visibility_tex : filter_linear, repeat_disable;
uniform sampler2D exploration_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 uniform float time; // Seconds since start
const vec3 UNEXPLORED_COLOR = vec3(0.071, 0.078, 0.102); // #12141a 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). // 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) { float sample_visibility(vec2 uv) {
vec2 t = 1.0 / map_size; vec2 t = 1.0 / map_size;
float sum = 0.0; float sum = 0.0;
@@ -49,27 +54,46 @@ void fragment() {
float vis = sample_visibility(tex_uv); float vis = sample_visibility(tex_uv);
float explored = texture(exploration_tex, tex_uv).r; 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) { if (explored < 0.01 && vis_raw < 0.01) {
vis = 0.0; vis = 0.0;
} }
if (explored < 0.01 && vis < 0.01) { if (explored < 0.01 && vis < 0.01) {
// Unexplored: solid near-black // Unexplored: solid near-black — information zero
COLOR = vec4(UNEXPLORED_COLOR, 1.0); COLOR = vec4(UNEXPLORED_COLOR, 1.0);
} else { } else {
// Continuous blend: clear vision (vis=1) -> light fog (vis=0). // Light fog noise — fast cycle (~9s), ±0.05 breathing
// The Gaussian blur creates a smooth 3-4 tile soft gradient float noise_fast = texture(noise_tex, tile * 0.03 + vec2(time * 0.11, time * 0.07)).r;
// at the cone edge — no hard boundary. // 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 // Light fog: alpha 0.25-0.35 (centered at 0.30, ±0.05 breathe)
float noise_val = texture(noise_tex, tile * 0.03 + vec2(time * 0.11, time * 0.07)).r; // Noise remapped to [-1,+1] for symmetric breathing around baseline.
float fog_alpha = 0.28 + noise_val * 0.04; 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 clarity = smoothstep(0.0, 0.85, vis);
float alpha = mix(fog_alpha, 0.0, clarity); 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 // Soft edge between explored and unexplored
float exp_fade = smoothstep(0.0, 0.3, explored); float exp_fade = smoothstep(0.0, 0.3, explored);
+42 -67
View File
@@ -2,10 +2,28 @@
Ticket: #430 | Decision: D-059 | Sprint: 6 Ticket: #430 | Decision: D-059 | Sprint: 6
Author: Tyre (architecture) | Implementer: Stig 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 ## 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." 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` 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 ```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; // Light fog: alpha 0.26-0.34 (near cone gradient)
uniform sampler2D exploration_tex : filter_nearest; float light_fog_alpha = 0.30 + noise_fast * 0.04;
uniform sampler2D zone_tint_tex : filter_nearest; // Deep fog: alpha 0.54-0.70 (far from cone, EXP_EXPLORED)
uniform vec2 map_offset; // World position of texture origin (in pixels) float deep_fog_alpha = 0.62 + noise_slow * 0.08;
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 // Blend deep <-> light fog by proximity to cone:
const vec4 FOG_UNEXPLORED = vec4(0.071, 0.078, 0.102, 1.0); // #12141a // vis=0 (far from cone) → deep_factor=1 → deep fog color + alpha
const vec4 FOG_WIREFRAME = vec4(0.2, 0.2, 0.251, 1.0); // #333340 // vis=0.3 (cone gradient) → deep_factor=0 → light fog color + alpha
const float LIGHT_FOG_DESAT = 0.45; // 40-50% desaturation // vis=0.85+ (inside cone) → clarity=1 → transparent (clear)
const float LIGHT_FOG_DIM = 0.7; // brightness -30% float deep_factor = 1.0 - smoothstep(0.0, 0.30, vis);
const float DEEP_FOG_DESAT = 0.9; // near-monochrome float fog_alpha = mix(light_fog_alpha, deep_fog_alpha, deep_factor);
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) // Zone temperature tint (D-059/D-046/D-077): deep fog color = zone tint
// Alternatively: pass a pre-generated noise texture as another uniform. vec3 zone_tint = texture(zone_tint_tex, tex_uv).rgb;
vec3 fog_color = mix(DARK_OVERLAY, zone_tint, deep_factor);
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: **Zone temperature palette (D-046):**
- A proper noise function or noise texture uniform (Godot's `NoiseTexture2D` resource) - `hub` / `workplace`: `#1a1f2e` — cool blue-dark (institutional/terminal)
- Correct world-to-UV coordinate mapping using `SCREEN_UV`, `CANVAS_MATRIX`, or vertex-passed world coords - `bar`: `#2a1f15` — warm amber-dark (social/inhabited)
- The gradient edge for Layer 1 should span 6-8 sim tiles (D-059/D-066) - `corridor`: `#1a1a1a` — neutral dark (transitional/maintenance)
- Layer 4 wireframe can use `mod()` on world coords for grid lines
### Coordinate Mapping ### Coordinate Mapping