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>
This commit is contained in:
@@ -0,0 +1,354 @@
|
||||
# Flying Taxi Over Cityscape — Architecture Analysis
|
||||
|
||||
Author: Tyre (architecture) | Sprint: 6 | Ref: D-049, z-layer-gap-analysis.md
|
||||
|
||||
## The Question
|
||||
|
||||
> Theoretically this will allow the player to take a flying taxi over a cityscape right?
|
||||
|
||||
Does the three-scope z-layer architecture support a gameplay scenario where the player is in a flying vehicle ascending over a city — seeing the cityscape below with buildings, streets, NPCs getting smaller and fading?
|
||||
|
||||
## Short Answer
|
||||
|
||||
**Yes — the architecture supports it.** The z-range allocation, CanvasGroup compositing, scale/alpha model, and negative-z lower floor system all generalize cleanly to a flight scenario. No architectural changes are needed. But the RENDERER needs a flight mode that doesn't exist yet — the architecture provides the skeleton, the renderer provides the muscle.
|
||||
|
||||
Tier 1 (architecture) — already solved. Tier 2 (renderer) — new systems needed, medium effort.
|
||||
|
||||
---
|
||||
|
||||
## What the Architecture Already Supports
|
||||
|
||||
### 1. The Floor Generalization
|
||||
|
||||
*cracks knuckles* — this is where it gets elegant.
|
||||
|
||||
The negative-z floor system was designed for "looking down through a hole" (balcony, catwalk, atrium). But there's nothing hole-specific about it. The system works like this:
|
||||
|
||||
- Player has a "current floor" (derived from z_step)
|
||||
- Floors below render at negative z: z:-100 per floor
|
||||
- Each lower floor gets scale < 1.0 (depth illusion) and modulate dimming
|
||||
- Parallax from scale-around-player during camera movement sells the depth
|
||||
|
||||
In a flying taxi, the player's "current floor" IS the taxi's altitude. As the taxi ascends from floor 0 to floor 5:
|
||||
|
||||
```
|
||||
Altitude 0 (ground): current floor = 0, nothing below
|
||||
Altitude 1: current floor = 1, floor 0 at z:-100 (scale 0.97)
|
||||
Altitude 3: current floor = 3, floors 0-2 at z:-100 to z:-300
|
||||
Altitude 5: current floor = 5, floors 0-4 at z:-100 to z:-500
|
||||
Altitude 10: current floor = 10, floors 0-9 at z:-100 to z:-1000
|
||||
```
|
||||
|
||||
The architecture doesn't care WHY the player is above other floors — it just renders lower floors at the appropriate depth. Flying taxi, balcony, jetpack, telekinetic levitation — same system.
|
||||
|
||||
### 2. Scale + Alpha for Depth
|
||||
|
||||
The existing depth treatment composes:
|
||||
|
||||
| Component | Per-floor effect | Source |
|
||||
|-----------|-----------------|--------|
|
||||
| Scale reduction | × 0.97 per floor distance | gap-analysis: Distance Scaling |
|
||||
| Modulate dimming | progressive desaturation + alpha | gap-analysis: Fog Interaction |
|
||||
| Parallax | content moves slower than camera | gap-analysis: Parallax Effect |
|
||||
| Occlusion | higher z draws over lower z | CanvasGroup z-ordering |
|
||||
|
||||
At altitude 5, the ground floor renders at:
|
||||
- Scale: 0.97^5 = 0.859 (86% size)
|
||||
- Modulate: heavily dimmed and desaturated
|
||||
- Parallax: content moves at 86% of camera speed
|
||||
|
||||
At altitude 10 (render ceiling):
|
||||
- Scale: 0.97^10 = 0.737 (74% size)
|
||||
- Modulate: barely visible, ghostly
|
||||
- Parallax: content moves at 74% of camera speed — clear depth separation
|
||||
|
||||
This is exactly the visual of "city getting smaller below you."
|
||||
|
||||
### 3. CanvasGroup Compositing
|
||||
|
||||
CanvasGroup collects ALL children, sorts by z_index (negative first), composites into a single texture. Then FogOverlay draws over it. Whether there are 2 lower floors or 10, the compositing pipeline is identical. No architectural limit.
|
||||
|
||||
Godot z_index range: -4096 to 4096. At z:-100 per floor, we support 40 floors of depth before hitting the limit. The render ceiling (10 floors) is well within range.
|
||||
|
||||
### 4. The Taxi Itself
|
||||
|
||||
The player's taxi is a vehicle entity. As it ascends, its z_step increases. From the rendering perspective:
|
||||
|
||||
- The taxi is on the "current floor" (always at the camera center)
|
||||
- The taxi does NOT get the ascending-object scale+alpha treatment (it's not flying AWAY from the player — the player IS in it)
|
||||
- Other flying objects at the same altitude render normally relative to the taxi
|
||||
- Objects at lower altitude render via the lower floor system
|
||||
|
||||
The architecture already handles this distinction: "current floor" content renders at z:0-400, lower floors render at negative z.
|
||||
|
||||
### 5. Render Ceiling as Flight Ceiling
|
||||
|
||||
The render ceiling (10 floors / 25m) serves double duty:
|
||||
|
||||
- For ground-based gameplay: maximum altitude for visible sprites (drones, ships)
|
||||
- For flight gameplay: maximum flight altitude with full rendering
|
||||
|
||||
Above the render ceiling, the game transitions to a different mode (narrative cutscene, map view, fast travel). This is a gameplay design constraint, not an architecture limitation.
|
||||
|
||||
---
|
||||
|
||||
## What the Architecture Does NOT Provide (Gaps)
|
||||
|
||||
These are renderer-level systems that need building. The architecture accommodates all of them — the z-ranges, compositing model, and scene tree structure are correct. But the scripts don't exist yet.
|
||||
|
||||
### Gap 1: Dynamic Visible Floor Window
|
||||
|
||||
**Current:** `VISIBLE_FLOOR_DEPTH = 2` (constant, ±2 floors)
|
||||
**Needed:** Dynamic window that expands with altitude
|
||||
|
||||
```gdscript
|
||||
# The visible floor window grows with altitude, capped at render ceiling
|
||||
func get_visible_depth(altitude_floors: int) -> int:
|
||||
return mini(altitude_floors, Constants.RENDER_CEILING_FLOORS)
|
||||
```
|
||||
|
||||
At altitude 2, you see 2 floors below (same as current). At altitude 8, you see 8 floors below. At altitude 10+, capped at 10.
|
||||
|
||||
**Effort: trivial.** Change one constant to one function call. The lower floor node creation is already designed to be dynamic (gap-analysis: "created dynamically when the player enters a location with vertical visibility").
|
||||
|
||||
### Gap 2: LOD Tiers for Distant Floors
|
||||
|
||||
**Current:** All visible floors render at full detail (tiles + entities + objects)
|
||||
**Needed:** Progressive LOD — distant floors simplify
|
||||
|
||||
Rendering 10 floors at full detail would cost ~1.6ms (10 × 0.16ms per floor budget). That's manageable but wasteful — floors 7-10 below are barely visible through heavy dimming. Three LOD tiers:
|
||||
|
||||
| Distance (floors) | LOD tier | Content | Cost estimate |
|
||||
|-------------------|----------|---------|---------------|
|
||||
| 0-2 | Full | Tiles + objects + entities + VFX | ~0.16ms/floor |
|
||||
| 3-5 | Reduced | Tiles + entity dots (no sprites) | ~0.08ms/floor |
|
||||
| 6-10 | Minimal | Colored rectangles per building/zone | ~0.02ms/floor |
|
||||
|
||||
Total at altitude 10: 2×0.16 + 3×0.08 + 5×0.02 = 0.66ms. Well within budget.
|
||||
|
||||
**Effort: medium.** Needs a LOD manager that switches floor group content based on distance. The z-range allocation doesn't change — only what gets placed INTO each floor's node group.
|
||||
|
||||
### Gap 3: Camera Zoom Transition
|
||||
|
||||
**Current:** Camera zoom fixed at Vector2(2, 2)
|
||||
**Needed:** Camera zooms out with altitude to show more of the city
|
||||
|
||||
```gdscript
|
||||
# Zoom decreases (shows more) as altitude increases
|
||||
var altitude_factor := float(altitude_floors) / Constants.RENDER_CEILING_FLOORS
|
||||
var zoom_level := lerpf(2.0, 0.8, altitude_factor) # 2.0 at ground, 0.8 at ceiling
|
||||
camera.zoom = Vector2(zoom_level, zoom_level)
|
||||
```
|
||||
|
||||
At ground level: zoom 2.0 (current, focused). At altitude 10: zoom 0.8 (wide, showing city below). The zoom transition should be smooth (tween over the ascent duration).
|
||||
|
||||
**Effort: trivial.** One zoom tween on Camera2D. No architecture impact.
|
||||
|
||||
### Gap 4: Empty Current Floor
|
||||
|
||||
**Current:** The "current floor" always renders ground tiles at z:0
|
||||
**Needed:** When airborne, the current floor is empty (you're in the sky)
|
||||
|
||||
The renderer needs to detect "player is airborne" and:
|
||||
- Skip rendering FloorTiles at z:0 (no ground beneath your feet)
|
||||
- Skip YSortGroup ground content (no furniture/walls at sky altitude)
|
||||
- Keep the Airborne (z:200) and HighAirborne (z:350) nodes active for other flying objects
|
||||
- The taxi entity renders as a special case (the vehicle you're in)
|
||||
|
||||
**Effort: low.** Conditional in world_renderer.update_from_state() — if player is airborne, don't populate current-floor ground content.
|
||||
|
||||
### Gap 5: Cityscape Composition
|
||||
|
||||
A city viewed from above shows a different visual than individual floors viewed through holes:
|
||||
|
||||
- **Rooftops** of buildings at various heights (not interior floors)
|
||||
- **Streets** visible between buildings (ground floor, open air)
|
||||
- **Parks/plazas** as ground-level open areas
|
||||
- **Building heights vary** — a 5-story building next to a 2-story building means different content at the same XY at different altitudes
|
||||
|
||||
The server needs to send appropriate "viewed from above" content per tile per floor. The ARCHITECTURE handles this (per-floor tile data, per-floor entity data), but the server's snapshot generation needs a flight mode that sends rooftop tiles instead of interior tiles for floors below the player's altitude.
|
||||
|
||||
**Effort: server-side, medium.** The client architecture is ready — it just renders whatever tiles the server provides at each floor level. The server needs to know the player is airborne and send rooftop/exterior content instead of interior content for visible floors.
|
||||
|
||||
### Gap 6: Flight Fog Treatment
|
||||
|
||||
**Current:** Fog shader applies current-floor visibility. Option 2 (modulate dimming) for lower floors.
|
||||
**Needed:** At altitude, fog treatment must adapt
|
||||
|
||||
Two approaches that work within the architecture:
|
||||
|
||||
**A. Modulate-only (v0.1, simple):** Keep using per-floor modulate for depth. Disable the current-floor fog shader (nothing to fog at sky altitude) or switch it to a "cloud/haze" mode. Lower floors are already dimmed by modulate — fog adds atmospheric depth.
|
||||
|
||||
**B. Per-floor fog (v0.2):** Each lower floor gets its own visibility texture from the server. The fog shader samples the correct texture per floor. This was already designed as Option 1 in the gap analysis — it generalizes to flight.
|
||||
|
||||
**Effort: low for A, medium for B.** The architecture supports both. The choice is visual quality vs. implementation cost.
|
||||
|
||||
---
|
||||
|
||||
## What the Full Flight Mode Renderer Looks Like
|
||||
|
||||
When the player enters a flying vehicle, the renderer switches to flight mode:
|
||||
|
||||
```
|
||||
Flight Mode Renderer State:
|
||||
current_floor = taxi.altitude_floor
|
||||
visible_depth = min(current_floor, RENDER_CEILING_FLOORS)
|
||||
camera.zoom = lerp(2.0, 0.8, altitude_factor)
|
||||
|
||||
FloorTiles z:0 = EMPTY (airborne, no ground)
|
||||
FloorObjects z:10 = taxi shadow on ground? (only if altitude < ceiling)
|
||||
YSortGroup z:100 = other flying objects at same altitude
|
||||
|
||||
LowerFloor[1..N] z:-100..-N*100 = city floors below
|
||||
LOD tier based on distance:
|
||||
1-2 floors: full tiles + entity sprites
|
||||
3-5 floors: tiles + entity dots
|
||||
6-10 floors: macro colored blocks
|
||||
Scale: 0.97^distance
|
||||
Modulate: progressive dim/desat
|
||||
Parallax: scale-around-player, slower movement
|
||||
```
|
||||
|
||||
### Scene Tree in Flight Mode
|
||||
|
||||
```
|
||||
FogGroup (CanvasGroup)
|
||||
LowerFloor10 (Node2D) z:-1000 [LOD: minimal, scale 0.74]
|
||||
LowerFloor9 (Node2D) z:-900 [LOD: minimal, scale 0.76]
|
||||
...
|
||||
LowerFloor6 (Node2D) z:-600 [LOD: minimal, scale 0.83]
|
||||
LowerFloor5 (Node2D) z:-500 [LOD: reduced, scale 0.86]
|
||||
LowerFloor4 (Node2D) z:-400 [LOD: reduced, scale 0.88]
|
||||
LowerFloor3 (Node2D) z:-300 [LOD: reduced, scale 0.91]
|
||||
LowerFloor2 (Node2D) z:-200 [LOD: full, scale 0.94]
|
||||
LowerFloor1 (Node2D) z:-100 [LOD: full, scale 0.97]
|
||||
FloorTiles z:0 [empty — sky]
|
||||
FloorObjects z:10 [taxi ground shadow]
|
||||
YSortGroup z:100 [other flying objects at altitude]
|
||||
Airborne z:200 [higher flying objects]
|
||||
Overhead z:300 [empty — no ceiling in sky]
|
||||
HighAirborne z:350 [objects above taxi altitude]
|
||||
FogOverlay z:900 [altitude haze / disabled]
|
||||
```
|
||||
|
||||
This is a direct extension of the existing gap-analysis architecture. No structural changes — just more LowerFloor nodes and conditional content.
|
||||
|
||||
---
|
||||
|
||||
## The Cityscape Visual
|
||||
|
||||
What does this actually look like at altitude 5 (roughly 12.5m, ~4th story height)?
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ │ Camera view (zoomed to ~1.2)
|
||||
│ ┌────┐ ┌──────────┐ │
|
||||
│ │roof│ │ rooftop │ │ Building rooftops (floor 5 tiles)
|
||||
│ │ 5F │ │ 3F │ │ Different heights visible
|
||||
│ └────┘ └──────────┘ │
|
||||
│ │
|
||||
│ ═══════════════════════════ │ Street (floor 0, dimmed, 86% scale)
|
||||
│ · · · · · · │ NPC dots on the street
|
||||
│ ┌──────┐ │
|
||||
│ │ roof │ │ Another building (floor 2, dimmer)
|
||||
│ │ 2F │ │
|
||||
│ └──────┘ │
|
||||
│ │
|
||||
│ ▓▓▓▓▓▓▓ │ Park (floor 0, green tint, 86% scale)
|
||||
│ │
|
||||
│ [taxi shadow] │ Your taxi's ground shadow
|
||||
│ │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Buildings taller than the taxi altitude show their rooftops at higher z (closer to current floor). Short buildings show rooftops that are further away (lower z, more dimmed, smaller scale). Streets and ground are the most distant layer — heavily dimmed, small scale, only entity dots visible.
|
||||
|
||||
The parallax sells it: when the taxi moves, ground-level content moves noticeably slower than rooftop-level content. You perceive DEPTH even in a 2D top-down view.
|
||||
|
||||
---
|
||||
|
||||
## Performance Budget
|
||||
|
||||
| Component | At altitude 5 | At altitude 10 (ceiling) |
|
||||
|-----------|--------------|-------------------------|
|
||||
| LOD Full floors (0-2) | 2 × 0.16ms = 0.32ms | 2 × 0.16ms = 0.32ms |
|
||||
| LOD Reduced floors (3-5) | 3 × 0.08ms = 0.24ms | 3 × 0.08ms = 0.24ms |
|
||||
| LOD Minimal floors (6-10) | — | 5 × 0.02ms = 0.10ms |
|
||||
| Camera zoom | ~0ms | ~0ms |
|
||||
| Scale transforms | ~0ms (GPU) | ~0ms (GPU) |
|
||||
| Compositing | ~0.1ms | ~0.15ms |
|
||||
| **Total flight overhead** | **~0.66ms** | **~0.81ms** |
|
||||
|
||||
Compared to ground-level rendering (~2ms total frame budget used): flight adds 0.66-0.81ms. Total stays under 3ms. Well within 16ms frame budget at 60fps.
|
||||
|
||||
The LOD system is the key performance enabler. Without it, 10 full floors would cost ~1.6ms — still feasible but wasteful.
|
||||
|
||||
---
|
||||
|
||||
## What the Server Needs to Provide
|
||||
|
||||
The architecture is client-ready. The server needs:
|
||||
|
||||
1. **Airborne player state:** The ObserverSnapshot needs to indicate the player is airborne (not on a surface). The client uses this to switch to flight rendering mode.
|
||||
|
||||
2. **Multi-floor visible tiles:** When airborne, the server sends tile data for ALL visible floors below (up to render ceiling), tagged with floor_index. Currently the server sends tiles for the current floor only.
|
||||
|
||||
3. **Rooftop vs. interior tiles:** For floors below the player, send exterior/rooftop tile data, not interior layouts. A building's 3rd floor seen from above shows the roof, not the rooms inside. This is a server-side content selection issue.
|
||||
|
||||
4. **Per-floor entity data:** Entities on visible floors below, grouped by floor_index. The existing entity data already includes z_step — the client can derive floor_index from it. But the server's visibility calculation needs to include ground-level entities visible from above (not just same-floor LOS).
|
||||
|
||||
5. **Building height data:** For LOD Minimal tier, the server could send simplified building footprints (position + height + color) instead of per-tile data. This is an optimization for high-altitude rendering, not a requirement.
|
||||
|
||||
None of these require protocol version changes — they're extensions to existing ObserverSnapshot fields. The `visible_tiles` and `visible_entities` arrays already support per-tile and per-entity floor data via z coordinates.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Roadmap
|
||||
|
||||
**Phase 0 — Already done (Sprint 6):**
|
||||
- Z-range allocation supports negative z to -4096
|
||||
- RENDER_CEILING_FLOORS = 10 defined
|
||||
- VISIBLE_FLOOR_DEPTH = 2 defined (becomes dynamic)
|
||||
- CanvasGroup compositing verified for multi-floor
|
||||
- Scale + modulate depth treatment designed
|
||||
|
||||
**Phase 1 — Basic flight (script work only, no server changes):**
|
||||
- Flight mode flag in world_renderer
|
||||
- Dynamic visible floor window
|
||||
- Camera zoom tween based on altitude
|
||||
- Empty current floor when airborne
|
||||
- Use existing ±2 floor rendering (limited but functional)
|
||||
- Can demo with hardcoded lower floor content
|
||||
|
||||
**Phase 2 — Full cityscape (requires server support):**
|
||||
- Server sends multi-floor tile data for airborne players
|
||||
- LOD tier system in floor renderer
|
||||
- Dynamic LowerFloor node creation/destruction
|
||||
- Rooftop tile selection on server side
|
||||
|
||||
**Phase 3 — Polish:**
|
||||
- Altitude fog/haze shader mode
|
||||
- Entity dot rendering for distant floors
|
||||
- Macro rendering for LOD Minimal tier
|
||||
- Taxi interior UI overlay (passenger view)
|
||||
- Ground shadow for taxi at z:10
|
||||
|
||||
---
|
||||
|
||||
## Verdict
|
||||
|
||||
The three-scope z-layer architecture **fully supports** the flying taxi scenario. Every component — negative z for lower floors, scale/alpha depth treatment, CanvasGroup compositing, render ceiling, dynamic floor groups — generalizes from "looking through a hole" to "flying above a city."
|
||||
|
||||
What exists: the skeleton (z-ranges, compositing, contracts).
|
||||
What needs building: the muscle (flight renderer, LOD, server multi-floor data).
|
||||
|
||||
The architecture made the right bets:
|
||||
- **Negative z was designed generously** (-4096 available, -1000 needed for 10 floors)
|
||||
- **Scale-around-player parallax** works identically for holes and flight
|
||||
- **CanvasGroup composites any number of floors** without structural changes
|
||||
- **Render ceiling defines the flight ceiling** — one constant, dual purpose
|
||||
- **Per-floor modulate** provides atmospheric depth without shader changes
|
||||
|
||||
Feasibility: **Challenging but doable.** The architecture is the easy part (done). The renderer flight mode is a medium-effort feature (Phase 1: ~1 sprint). Full cityscape with server support is larger (Phase 2: ~2 sprints). But there are no architecture blockers — no rethinking z-ranges, no CanvasGroup limitations, no compositing rewrites.
|
||||
|
||||
*The flying taxi just fell out of the architecture for free. That's what good range allocation gets you.*
|
||||
@@ -0,0 +1,287 @@
|
||||
# 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.
|
||||
|
||||
```gdscript
|
||||
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.
|
||||
|
||||
```glsl
|
||||
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.
|
||||
@@ -0,0 +1,758 @@
|
||||
# Z-Layer Rendering Pipeline — Gap Analysis
|
||||
|
||||
Author: Tyre (architecture) | Sprint: 6 | Decision: D-049 amendment
|
||||
|
||||
## Adjustments Incorporated
|
||||
|
||||
1. **Fog moves to z:900** — FogOverlay sits OUTSIDE FogGroup (the CanvasGroup), as a sibling under the World node. FogGroup draws at z:0 (default), FogOverlay draws at z:900. The CanvasGroup composites layers 0-300+ into a single texture, then FogOverlay draws the fog shader quad over that. Currently z_index=10 in scene tree — needs update to 900.
|
||||
|
||||
2. **Modal at CanvasLayer 30** — New CanvasLayer node in main.tscn at `layer = 30`. Pause menu, inventory modal, death screen. Empty for Sprint 6.
|
||||
|
||||
3. **Y-sort occlusion contract** — Formalized below.
|
||||
|
||||
4. **Airborne + upper floor rendering** — Proposed below.
|
||||
|
||||
---
|
||||
|
||||
## Y-Sort Occlusion Contract (Formal)
|
||||
|
||||
Most critical architectural constraint in the rendering pipeline. Getting this wrong breaks the entire top-down visual.
|
||||
|
||||
**The rule:** In Godot 4, within a y_sort_enabled parent, z_index is the PRIMARY sort key and y-position is SECONDARY. Confirmed via Godot issue #62715 and fix #62837.
|
||||
|
||||
**The contract:**
|
||||
|
||||
1. **All y-sorted content MUST share z_index = 0** within YSortGroup. Entities, furniture, wall faces — everything that participates in positional occlusion gets z:0.
|
||||
2. **Nested y_sort_enabled nodes flatten** their children into the parent's sort pool. Entities (y_sort=true, z:0) has its individual entity sprites participate directly in YSortGroup's y-sort alongside furniture.
|
||||
3. **D-044 entity-wins-ties:** Entities node added AFTER Furniture node in scene tree order. Same y-position means later sibling wins.
|
||||
4. **Items on surfaces** (cup on table): parent-child node relationship. Child draws after parent. No z_index needed.
|
||||
5. **Wall faces** (when added): in YSortGroup at z:0. Wall at y=5 occludes entity at y=4 but not y=6. Side/back walls use Overhead (z:300).
|
||||
|
||||
**CRITICAL BUG IN CURRENT SCENE TREE:** Entities currently has `z_index = 3`. Entity sprites will ALWAYS draw after furniture regardless of y-position. When furniture sprites are added, they'll be permanently hidden behind entities. **Must change to z_index = 0.**
|
||||
|
||||
---
|
||||
|
||||
## Airborne, Overhead, and Upper Content
|
||||
|
||||
Everything stays inside FogGroup — fog applies uniformly regardless of altitude.
|
||||
|
||||
### Fixed Top-Down Camera Constraint (D-019)
|
||||
|
||||
The camera is fixed at ~15-20° from vertical. The player never tilts the camera upward. This has major implications for vertical rendering:
|
||||
|
||||
- **You never "look up."** The camera is above everything. What you see is always the TOP of things.
|
||||
- **Upper floors occlude lower floors.** If you're on floor 1 of a 3-story building, the camera sees the roof of floor 3, NOT your floor 1 — unless the game selectively hides/transparentifies upper floors (like Rimworld, Prison Architect).
|
||||
- **The Overhead layer (z:300) handles everything above the player on the current floor.** Ceiling edges, overhead pipes, upper floor platforms — all rendered as semi-transparent occlusion from directly above.
|
||||
- **Upper floor entities (z:400+) are only relevant when visible through horizontal offset or floor transparency.** A catwalk extending to the side (you see it because it's offset from your position, not because you looked up) or content on a floor above you where that floor has been made transparent by the game. This is a niche case, not the primary use.
|
||||
|
||||
This significantly simplifies the upper range compared to the original proposal.
|
||||
|
||||
### Revised Upper Range
|
||||
|
||||
- **z:400 UpperContent** — Single range for any upper-floor content visible through horizontal offset or floor transparency. One y-sort group, not per-floor stacking.
|
||||
- **z:500-899 — Reserved but likely unused.** The fixed camera means you rarely see more than one floor above. Reserved for edge cases (multi-level atrium with transparent floors, specific story moments).
|
||||
|
||||
The elaborate per-floor stacking (z:400, z:500, z:600...) from the original proposal is over-engineered for a fixed top-down camera. Simplified to a single UpperContent range.
|
||||
|
||||
### Flying Objects: Scale INCREASES with Altitude
|
||||
|
||||
With a fixed top-down camera, objects at higher altitude are CLOSER to the camera. This inverts the intuition from the looking-down case:
|
||||
|
||||
- **Things below you:** further from camera → scale < 1.0 (shrink)
|
||||
- **Things at your level:** normal → scale 1.0
|
||||
- **Things above you (flying):** closer to camera → scale > 1.0 (grow)
|
||||
|
||||
A drone at high altitude appears LARGE (close to camera). As it descends to ground level, it shrinks toward normal sprite size. A ship approaching from orbit starts as a large shadow, shrinking as it descends. This is physically correct for a top-down perspective.
|
||||
|
||||
**Scale formula for airborne objects:**
|
||||
|
||||
```gdscript
|
||||
# Altitude in floors above current floor
|
||||
var altitude_floors := (entity.z_step - current_floor_z_step) / 5.0
|
||||
# 3% size increase per floor of altitude
|
||||
var scale_factor := 1.0 + altitude_floors * 0.03
|
||||
airborne_sprite.scale = Vector2(scale_factor, scale_factor)
|
||||
```
|
||||
|
||||
Scale by altitude:
|
||||
|
||||
| Altitude | Floors above | Scale | Visual effect |
|
||||
|----------|-------------|-------|---------------|
|
||||
| Ground level | 0 | 1.00 | Normal entity size |
|
||||
| Low airborne | 0.5-1 | 1.02-1.03 | Barely noticeable |
|
||||
| Mid airborne | 2-3 | 1.06-1.09 | Clearly larger, floating above |
|
||||
| High airborne | 5 | 1.15 | Prominent, casting shadow below |
|
||||
| Near ceiling | 8-9 | 1.24-1.27 | Large, approaching render ceiling |
|
||||
| Render ceiling | 10 | 1.30 | Maximum, beyond this: not rendered |
|
||||
|
||||
### Rendering Ceiling
|
||||
|
||||
There must be a maximum altitude above which objects stop rendering as sprites. A ship in orbit, a satellite at 500m — these are not visible in the top-down game view.
|
||||
|
||||
**Contract: rendering ceiling = 10 floors above current floor = z_step + 50 = 25 meters.**
|
||||
|
||||
In the world z-coordinate system:
|
||||
- z_step is an integer, 0.5m per step
|
||||
- floor_index = z_step / 5, floor height = 2.5m (5 sub-levels * 0.5m)
|
||||
- Rendering ceiling: current z_step + 50 (10 floors, 25m)
|
||||
|
||||
Above the rendering ceiling:
|
||||
- Objects do NOT render as sprites
|
||||
- They MAY cast ground shadows (shadow sprite at z:10, FloorObjects level — a dark ellipse on the ground)
|
||||
- They MAY produce environmental effects (engine noise, wind particles, lighting changes)
|
||||
- They MAY appear as insert overlay indicators (radar blip, direction arrow) in the Insert scope
|
||||
|
||||
This aligns with both visual limits (at 1.30 scale, a sprite is already uncomfortably large) and gameplay logic (your character's perception doesn't extend 25m straight up in meaningful detail).
|
||||
|
||||
**Rendering floor (looking down):** Symmetric — 10 floors below = z_step - 50 = 25m down. Already enforced by the +/- 2 visible floor window for DETAILED rendering. Beyond +/- 2, content could render as simplified dots/shadows rather than full sprites, up to +/- 10. Beyond +/- 10, not rendered at all.
|
||||
|
||||
### Revised Cases
|
||||
|
||||
**Projectiles / low airborne** (arrow mid-flight, thrown grenade, hovering drone at room height):
|
||||
- z:200, scale ~1.0-1.03
|
||||
- Visually above ground entities, below overhead
|
||||
- NOT y-sorted with ground — always on top of ground content
|
||||
- Most common airborne case
|
||||
|
||||
**High airborne** (drone at altitude, descending shuttle, flying creature):
|
||||
- z:200, scale 1.03-1.30 (driven by altitude z_step)
|
||||
- Still inside Airborne node, differentiated by per-sprite scale
|
||||
- Objects above overhead height (z_step > current + 15, i.e., 3+ floors): render at z:350 (above Overhead at z:300) — they're above the ceiling, so they draw over it
|
||||
- Casts ground shadow at z:10 (FloorObjects)
|
||||
|
||||
**Overhead structure** (ceiling edges, catwalk beams, overhead pipes):
|
||||
- z:300 unchanged
|
||||
- Semi-transparent partial occlusion
|
||||
- NOT scaled — this is structural, not altitude-variable
|
||||
|
||||
**Upper floor content** (entities on a catwalk visible through horizontal offset or floor transparency):
|
||||
- z:400
|
||||
- Normal scale (they're ON a surface, not flying)
|
||||
- Own y-sort group for interleaving with each other
|
||||
- Rare case with fixed top-down camera
|
||||
|
||||
### Proposed World Z Layout (Revised)
|
||||
|
||||
```
|
||||
FogGroup (CanvasGroup, z:0)
|
||||
FloorTiles z:0 ground plane
|
||||
FloorObjects z:10 cosmetic detail + ground shadows from high-altitude objects
|
||||
YSortGroup z:100 ground entities/furniture/walls, y-sorted
|
||||
Airborne z:200 projectiles, low-flying objects (scale ~1.0)
|
||||
Overhead z:300 ceiling, upper structure, semi-transparent
|
||||
HighAirborne z:350 flying objects above ceiling height (scale 1.03-1.30)
|
||||
UpperContent z:400 upper-floor entities via horizontal offset/transparency
|
||||
[z:500-899 reserved] unlikely to be needed with fixed camera
|
||||
```
|
||||
|
||||
### Design Contract (Revised)
|
||||
|
||||
- **z:0-100 Ground:** Floor surfaces + y-sorted content on player's current floor.
|
||||
- **z:200 Airborne:** Low-altitude flying objects (0-3 floors above). Scale 1.0-1.09. Above ground entities, below overhead.
|
||||
- **z:300 Overhead:** Structural elements above player. Semi-transparent. No scale.
|
||||
- **z:350 HighAirborne:** Flying objects above ceiling height (3-10 floors above). Scale 1.09-1.30. Above overhead. Casts ground shadow at z:10.
|
||||
- **z:400 UpperContent:** Entities on visible upper floors (horizontal offset or transparency). Normal scale. Own y-sort. Rare with fixed camera.
|
||||
- **z:500-899 reserved:** Unlikely to be needed. Available for edge cases.
|
||||
- **Rendering ceiling:** 10 floors (25m, z_step + 50). Above this: no sprite rendering. Ground shadows and environmental effects only.
|
||||
- **Fog uniform across altitude:** Shader reads visibility per tile, doesn't distinguish z_index. Intentional — perception determines what you see, not altitude.
|
||||
|
||||
Sprint 6 impact: None. Airborne/HighAirborne/UpperContent don't exist in scene tree yet. Contract reserves the ranges.
|
||||
|
||||
---
|
||||
|
||||
## Complete Adjusted Pipeline
|
||||
|
||||
### World scope (default canvas, z:-200 to z:900)
|
||||
|
||||
```
|
||||
World (Node2D) [world_renderer.gd]
|
||||
FogGroup (CanvasGroup, z:0)
|
||||
LowerFloor2 (Node2D) z:-200 [future, dynamic, scale 0.94, modulate dim]
|
||||
LowerFloor1 (Node2D) z:-100 [future, dynamic, scale 0.97, modulate dim]
|
||||
FloorTiles (TileMapLayer) z:0
|
||||
FloorObjects (Node2D) z:10 also: ground shadows from high-altitude objects
|
||||
YSortGroup (Node2D, y_sort) z:100
|
||||
Furniture (Node2D) z:0 [future]
|
||||
Entities (Node2D, y_sort) z:0 FIX from current z:3
|
||||
WallFaces (Node2D) z:0 [future]
|
||||
Airborne (Node2D) z:200 [future] low-flying, scale ~1.0
|
||||
Overhead (Node2D) z:300
|
||||
HighAirborne (Node2D) z:350 [future] above-ceiling flying, scale 1.03-1.30
|
||||
UpperContent (Node2D, y_sort) z:400 [future] rare with fixed camera
|
||||
FogOverlay (Node2D) z:900 OUTSIDE FogGroup, fog shader
|
||||
```
|
||||
|
||||
### Insert scope (CanvasLayer 10)
|
||||
|
||||
```
|
||||
InsertOverlay (CanvasLayer, layer=10)
|
||||
InteractionPrompt v0.1 fallback
|
||||
InteractionList D-057 verb list
|
||||
RadialMenu [future] D-058
|
||||
PerceptionMarkers [future] entity labels
|
||||
Minimap [recommend: move from UI, diegetic per D-013]
|
||||
```
|
||||
|
||||
### UI scope (CanvasLayer 20)
|
||||
|
||||
```
|
||||
UILayer (CanvasLayer, layer=20)
|
||||
HUD health/stamina
|
||||
Minimap [current, may move to Insert]
|
||||
MonologueDisplay internal monologue text
|
||||
CursorRenderer geometric cursor, topmost in UI
|
||||
```
|
||||
|
||||
### Modal scope (CanvasLayer 30)
|
||||
|
||||
```
|
||||
ModalLayer (CanvasLayer, layer=30) NEW, empty for Sprint 6
|
||||
[future] PauseMenu, InventoryModal, DeathScreen
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Gap Checks
|
||||
|
||||
### CanvasGroup + PointLight2D -- NO GAP
|
||||
|
||||
Vision cone PointLight2D (D-046) sits inside FogGroup/YSortGroup/Entities on the player entity. CanvasGroup composites its children WITH lighting into the output texture. Fog overlay then draws over the composited result. Intended behavior: "vision cone lights the world, fog covers what you don't know." These are two separate systems sharing LOS data (as documented in fog-shader-spec.md).
|
||||
|
||||
### Camera2D + CanvasLayer -- NO GAP
|
||||
|
||||
Camera2D transforms the default canvas (World + FogOverlay). CanvasLayers (Insert, UI, Modal) are NOT affected by camera transform. Insert overlay elements that need world-anchoring (interaction labels near entities) must convert world-to-screen coordinates in their scripts. Standard Godot pattern. interaction_list.gd currently doesn't do this — it's a fixed overlay. Entity-anchored positioning per D-057 is a script change, not architecture.
|
||||
|
||||
### Multi-floor transitions -- NO GAP
|
||||
|
||||
floor_index = z_step / 5, sub_level = z_step % 5. When the player changes floors: server sends new visible_entities for the new floor, entity renderer removes old and creates new, fog textures update for new floor's visibility. No rendering layer changes needed. Multi-floor visibility (stairwells) would add per-floor y-sort groups at z:400+ — that's v0.2+, no architectural debt now.
|
||||
|
||||
### Fog shader coordinate mapping -- NO GAP
|
||||
|
||||
FogOverlay at z:900 outside FogGroup. Changing z:10 to z:900 doesn't affect shader behavior — z_index is draw order only, not UV math. fog_shader.gd positions its ColorRect via camera offset, confirmed by reading the source.
|
||||
|
||||
### Insert overlay sub-layering -- NO GAP
|
||||
|
||||
Within a CanvasLayer, child z_index values are relative to that CanvasLayer's space, not the world space. InsertOverlay children can use z_index 0, 1, 2... for internal ordering without conflicting with world z values. The 1000+ numbering scheme in constants.gd is for documentation/reference. Implementation uses scene tree order or small z_index values.
|
||||
|
||||
### z_index range -- NO GAP
|
||||
|
||||
Godot 4 z_index valid range: -4096 to 4096. Our highest world z value is 900. Well within range.
|
||||
|
||||
### Relative z_index accumulation -- NO GAP
|
||||
|
||||
Godot defaults to z_as_relative = true. Within FogGroup: FloorTiles effective z = FogGroup(0) + 0 = 0. FloorObjects effective z = 0 + 10 = 10. YSortGroup effective z = 0 + 100 = 100. Within YSortGroup: Entities z = 0 (relative to YSortGroup). In y-sort mode, this z:0 is used as the sort key within the y-sort pool — not accumulated with parent's z:100. The z:100 on YSortGroup determines when the entire group draws relative to FloorObjects (z:10) and Overhead (z:300). Correct behavior.
|
||||
|
||||
### Fog uniformity across altitude -- NO GAP
|
||||
|
||||
Fog shader reads visibility_texture per tile position. Doesn't distinguish by z_index. A drone at z:200 and an entity at z:100 on the same tile get the same fog treatment. Intentional — your perception determines what you see, not altitude.
|
||||
|
||||
---
|
||||
|
||||
## Scene Tree Changes Needed
|
||||
|
||||
| Node | Current z_index | Target z_index | Notes |
|
||||
|------|----------------|---------------|-------|
|
||||
| FloorTiles | 0 | 0 | Correct, no change |
|
||||
| FloorObjects | 1 | 10 | Gap for future floor layers |
|
||||
| YSortGroup | 2 | 100 | Gap for wall base, rubble |
|
||||
| **Entities** | **3** | **0** | **CRITICAL: fixes y-sort bug** |
|
||||
| Overhead | 4 | 300 | Gap for upper walls |
|
||||
| FogOverlay | 10 | 900 | Top of world scope |
|
||||
| ModalLayer | -- | NEW | CanvasLayer layer=30 |
|
||||
|
||||
Constants.gd: full rewrite of the Z_ block to three-scope numbering (World 0-899, Insert CanvasLayer 10, UI CanvasLayer 20, Modal CanvasLayer 30).
|
||||
|
||||
---
|
||||
|
||||
## Looking Down: Lower Floor Rendering
|
||||
|
||||
### The Problem
|
||||
|
||||
The pipeline above handles looking UP (overhead z:300, upper floors z:400+) and anchors the current floor at z:0-100. But what renders BELOW z:0? When the player stands on a balcony, catwalk, or upper floor, they may see floors below them.
|
||||
|
||||
### Key Insight: Looking Down != Being On That Floor
|
||||
|
||||
When you look DOWN at floor N from above, you see a fundamentally different view than when you're ON floor N:
|
||||
|
||||
- **On floor N:** Full interior — floor surface, furniture, entities at eye level, walls, overhead
|
||||
- **Looking down at floor N:** Top-down view — floor surface (or rooftops), furniture tops, entity heads/shoulders. No wall interiors, no overhead (you're above the overhead)
|
||||
|
||||
This means lower floor rendering is SIMPLER than same-floor rendering. Each lower floor needs:
|
||||
- Ground surface (the floor or rooftop you see looking down)
|
||||
- Floor objects (furniture tops, decoration — simplified)
|
||||
- Entities (people below, seen from above, y-sorted with each other)
|
||||
- Optionally airborne on that floor (rare — a drone flying below you)
|
||||
|
||||
### Range Allocation: Negative Z
|
||||
|
||||
Godot supports z_index -4096 to 4096. Lower floors use negative z ranges, mirroring the upper floor +100 pattern:
|
||||
|
||||
```
|
||||
z:-200 to z:-101 Floor-2 below (max visible depth)
|
||||
z:-100 to z:-1 Floor-1 below (one floor down)
|
||||
z:0 Current floor ground (anchor point)
|
||||
z:10 Current floor cosmetic
|
||||
z:100 Current floor y-sort
|
||||
z:200 Current floor airborne
|
||||
z:300 Current floor overhead
|
||||
z:400 Upper floor +1
|
||||
z:500 Upper floor +2 (max visible height)
|
||||
z:500-899 Reserved
|
||||
z:900 FogOverlay (outside FogGroup)
|
||||
```
|
||||
|
||||
Within each lower floor's 100-range block:
|
||||
|
||||
```
|
||||
z:X+0 LowerGround — floor surface / rooftop seen from above
|
||||
z:X+10 LowerObjects — furniture tops, decorations from above
|
||||
z:X+50 LowerYSort — entities on that floor, y-sorted with each other
|
||||
z:X+80 LowerAirborne — flying things on that floor (rare)
|
||||
```
|
||||
|
||||
Concrete example — floor one below (z:-100 to z:-1):
|
||||
|
||||
```
|
||||
z:-100 Ground surface (floor tiles or rooftop of floor below)
|
||||
z:-90 Floor objects (furniture tops from above)
|
||||
z:-50 Entities (y-sort group — NPCs on the floor below)
|
||||
z:-20 Airborne (drones flying on that floor, if any)
|
||||
```
|
||||
|
||||
Floor two below (z:-200 to z:-101):
|
||||
|
||||
```
|
||||
z:-200 Ground surface
|
||||
z:-190 Floor objects
|
||||
z:-150 Entities (y-sort group)
|
||||
z:-120 Airborne
|
||||
```
|
||||
|
||||
### Visible Floor Window
|
||||
|
||||
**Contract: current floor +/- 2** (5 floors rendered simultaneously max).
|
||||
|
||||
Rationale:
|
||||
- Beyond 2 floors of vertical distance, entities are too small/distant to be meaningful in a top-down view
|
||||
- 5 simultaneous floors is a generous budget for gameplay scenarios (2-story buildings, catwalks, atrium balconies, station concourses)
|
||||
- For extreme cases (50-floor skyscraper), clamp to +/- 2 visible. Floors beyond the window render as deep fog color. This is both a performance win and a gameplay statement: your perception doesn't extend that far vertically
|
||||
|
||||
Total z range used: -200 to 900. Well within Godot's -4096 to 4096.
|
||||
|
||||
### CanvasGroup Interaction: Confirmed Clean
|
||||
|
||||
Negative z_index values inside a CanvasGroup work correctly. The CanvasGroup collects ALL children, sorts them by z_index (negative values first), renders them in order into its compositing buffer, and outputs a single texture. Lower floor content at z:-100 draws first (underneath), current floor at z:0-300 draws over it, upper floor at z:400+ draws on top. The fog shader then draws over the entire composited result. No special handling needed.
|
||||
|
||||
### Fog Interaction: Per-Floor Visibility
|
||||
|
||||
This is the most subtle part. The fog shader reads a visibility texture that maps tile positions to visibility states. Currently, this texture covers only the current floor. For multi-floor rendering:
|
||||
|
||||
**The architectural constraint:** The fog shader maps screen pixels to tile XY coordinates, ignoring floor. A tile at (5, 10) on floor 0 and tile (5, 10) on floor 1 map to the same visibility pixel. If the current floor tile is visible, so is anything below/above at the same XY — which is wrong when there's a solid floor between them.
|
||||
|
||||
**Three solutions (design now, implement later):**
|
||||
|
||||
1. **Per-floor visibility textures.** Server sends separate visibility data per visible floor. FogState maintains one visibility texture per floor. The fog shader samples the correct texture based on which floor's content is being drawn. This requires the shader to know the floor of each pixel — achievable by encoding floor index in the alpha channel of each floor's content, or by rendering floors in separate passes.
|
||||
|
||||
2. **Modulate-based dimming (simplest, recommended for v0.1).** Lower floor content gets visual treatment BEFORE compositing, not via the fog shader. Apply `modulate` on the lower floor parent nodes:
|
||||
- Floor-1: `modulate = Color(0.4, 0.4, 0.5, 0.7)` — dim, desaturated, semi-transparent
|
||||
- Floor-2: `modulate = Color(0.25, 0.25, 0.35, 0.5)` — very dim, ghostly
|
||||
- The fog shader still applies current-floor fog over the composited result
|
||||
- Tiles outside current-floor LOS get full fog treatment, hiding lower floor content beneath them
|
||||
- Tiles inside LOS show the dimmed lower floor content (if there's a gap/hole in the current floor)
|
||||
|
||||
3. **Combined visibility texture with floor offset.** Single texture with per-floor visibility encoded in separate channels (R = current, G = floor-1, B = floor-2). Shader samples the right channel. Limited to 3-4 floors but compact.
|
||||
|
||||
**Recommendation:** Option 2 for initial implementation. It's simple, visually effective (dim = distant floor), and requires zero shader changes. Option 1 for the full implementation when multi-floor rendering is built properly. The z-range allocation works identically either way.
|
||||
|
||||
**When there's no hole:** The current floor's ground tiles at z:0 naturally occlude lower floor content at z:-100 to z:-1 (lower z draws first, ground tiles draw over them). Lower floor content is only visible where the current floor has gaps — open railings, missing floor tiles, glass floors, etc. This is automatic from the z-ordering. No special "can see below" flag needed.
|
||||
|
||||
### Performance Budget
|
||||
|
||||
Per additional visible floor:
|
||||
|
||||
| Component | Cost | Notes |
|
||||
|-----------|------|-------|
|
||||
| Ground tiles | ~0.1ms | 100-400 tiles, may use simplified tile set |
|
||||
| Entity sprites | ~0.05ms | 0-15 sprites per floor |
|
||||
| Y-sort computation | ~0.01ms | Godot internal, trivial |
|
||||
| Modulate overhead | ~0ms | Single property per parent node |
|
||||
| Scale transform | ~0ms | GPU matrix multiply, no CPU cost |
|
||||
| Position update | ~0ms | One Vector2 multiply per frame per floor |
|
||||
| **Per floor total** | **~0.16ms** | Scale + parallax add no measurable cost |
|
||||
|
||||
With +/- 2 window (4 additional floors max): **~0.64ms additional**. Total rendering stays well under budget.
|
||||
|
||||
Optimization for implementation: lower floors seen from above don't need full tile detail. A simplified representation (fewer tile variants, possibly lower-res) reduces both rendering cost and art production cost. This is an implementation optimization, not an architecture concern.
|
||||
|
||||
### Visual Treatment
|
||||
|
||||
Floors below seen from above look different from floors at eye level. This is an art direction question, but the architecture must support it:
|
||||
|
||||
**Interior floors (through gap/railing):** See actual floor surface and entity heads. Dim, desaturated (modulate). Entities use standard sprites — a top-down game already shows characters from above.
|
||||
|
||||
**Through transparent/grid floors:** Same as above but with grid pattern from the current floor overlaid. The current floor's FloorTiles at z:0 handle this (grid-pattern tile that's partially transparent).
|
||||
|
||||
**Rooftops (from exterior):** Flat roof surface sprite, not interior layout. The server/snapshot would include a "viewed from above" flag or the renderer detects floor offset and selects rooftop sprites. This is a different tile set per building type.
|
||||
|
||||
**Atriums / open vertical spaces:** Multiple floors visible simultaneously. Each floor at its depth level, progressively dimmer. Entities on each floor y-sorted within their floor group. The +/- 2 window handles 5-floor atriums cleanly.
|
||||
|
||||
### Distance Scaling for Depth Illusion
|
||||
|
||||
Should lower floors render at progressively smaller scale to fake perspective? E.g., floor-1 at 97%, floor-2 at 94%. This would sell vertical distance visually and create a parallax effect during camera movement.
|
||||
|
||||
#### Scale + Y-Sort: Composes Cleanly
|
||||
|
||||
In Godot 4, y_sort uses the child's global_position.y after all transforms. If a parent Node2D is scaled, its children's positions are transformed accordingly, but their relative y-ordering is preserved (all children are equally affected by the same parent scale). Cross-floor sorting is handled by z_index (floors don't interleave), so scale on one floor group cannot disrupt another floor's y-sort.
|
||||
|
||||
**Confirmed:** Scaling a floor group composes cleanly with y-sort within that group.
|
||||
|
||||
#### Scale Center: Player Position
|
||||
|
||||
Scaling a Node2D scales around its origin. For a floor group with world-space children, scaling around (0,0) would shift all content toward the origin — wrong. We need to scale around the player/camera position so lower floors appear to recede directly below the player.
|
||||
|
||||
Implementation pattern (per frame):
|
||||
|
||||
```gdscript
|
||||
# Scale around player position — creates parallax depth effect
|
||||
var scale_factor := 0.97 # floor-1
|
||||
lower_floor.scale = Vector2(scale_factor, scale_factor)
|
||||
lower_floor.position = player_world_pos * (1.0 - scale_factor)
|
||||
```
|
||||
|
||||
This makes lower floor content "lag behind" camera movement at a reduced rate — a natural parallax depth cue that sells vertical distance effectively.
|
||||
|
||||
#### Parallax Effect
|
||||
|
||||
Because the camera tracks the player, and lower floors are scaled around the player position, camera movement produces a subtle parallax: lower floor content shifts less than the current floor. This is the most compelling depth cue in a top-down perspective — pure color treatment (dimming/desaturation) cannot produce this spatial effect.
|
||||
|
||||
The parallax is proportional to the scale difference:
|
||||
- Floor-1 at 97% scale: content moves at 97% of camera speed — subtle but perceptible
|
||||
- Floor-2 at 94% scale: content moves at 94% — clearly different depth plane
|
||||
|
||||
#### Performance: Trivial
|
||||
|
||||
Scaling a Node2D group is a transform matrix multiplication, applied by the GPU automatically. Cost per floor:
|
||||
- Position update per frame: one Vector2 multiply + assign (~0ns, CPU)
|
||||
- GPU transform: included in existing draw call pipeline, no additional cost
|
||||
- No texture re-rendering, no additional draw calls
|
||||
|
||||
Total additional cost for scale: effectively zero.
|
||||
|
||||
#### Fog Shader Interaction: No Conflict
|
||||
|
||||
The fog shader operates on the COMPOSITED output of the CanvasGroup. The CanvasGroup composites all children (including scaled lower floors) into one texture. The fog shader sees final composited pixels — it doesn't know or care that some content was scaled.
|
||||
|
||||
Since we use modulate-based dimming for lower floors (not per-floor fog textures), the slight position offset from scaling doesn't cause fog misalignment. The lower floor's visual treatment is applied before compositing, and the fog shader applies current-floor fog over the composited result.
|
||||
|
||||
If per-floor visibility textures are implemented later (Option 1), the UV mapping for lower floor fog would need to account for the scale transform. This is a coordinate correction in the per-floor fog pass — straightforward but worth noting.
|
||||
|
||||
#### Readability Cutoff
|
||||
|
||||
At the proposed scale values:
|
||||
- Floor-1 at 97%: fully readable, barely noticeable size difference. Depth is perceived via parallax + dimming.
|
||||
- Floor-2 at 94%: readable but clearly smaller. Combined with heavy dimming (modulate 0.25 alpha), content becomes atmospheric rather than informational.
|
||||
|
||||
This reinforces the +/- 2 visible floor window. Beyond 2 floors, scale would drop below 90%, making content too small for a top-down view. The visibility window and the readability window align naturally.
|
||||
|
||||
#### Recommendation: Both Treatments, Layered
|
||||
|
||||
Use desaturation/darkening as the PRIMARY depth cue (via modulate — simple, effective, no transform complexity):
|
||||
- Floor-1: `modulate = Color(0.4, 0.4, 0.5, 0.7)` — dim, cool shift, semi-transparent
|
||||
- Floor-2: `modulate = Color(0.25, 0.25, 0.35, 0.5)` — very dim, ghostly
|
||||
|
||||
Use scale as SECONDARY depth cue (via transform — adds parallax, sells depth spatially):
|
||||
- Floor-1: `scale = Vector2(0.97, 0.97)` — subtle, most of the parallax effect
|
||||
- Floor-2: `scale = Vector2(0.94, 0.94)` — more pronounced, atmospheric
|
||||
|
||||
Both are applied on the lower floor parent nodes. Combined, they produce a convincing depth illusion:
|
||||
1. **Color treatment** tells you "this is distant/below" (instant read)
|
||||
2. **Scale/parallax** tells you "this is a different depth plane" (perceived during camera movement)
|
||||
3. **Occlusion** by current floor ground tiles tells you "this is underneath" (spatial relationship)
|
||||
|
||||
For upper floors seen from below: scale is less important (catwalks above you don't create the same parallax expectation). Use modulate only for upper floor dimming, no scale. This also avoids the visual oddity of overhead content being slightly larger than ground content.
|
||||
|
||||
#### Scene Tree Update
|
||||
|
||||
The lower floor parent nodes gain scale and position updates:
|
||||
|
||||
```
|
||||
LowerFloor1 (Node2D) z:-100
|
||||
modulate = Color(0.4, 0.4, 0.5, 0.7)
|
||||
scale = Vector2(0.97, 0.97)
|
||||
position = player_pos * 0.03 [updated per frame]
|
||||
...children...
|
||||
|
||||
LowerFloor2 (Node2D) z:-200
|
||||
modulate = Color(0.25, 0.25, 0.35, 0.5)
|
||||
scale = Vector2(0.94, 0.94)
|
||||
position = player_pos * 0.06 [updated per frame]
|
||||
...children...
|
||||
```
|
||||
|
||||
The per-frame position update is handled by the world renderer (or a dedicated multi-floor manager) alongside the existing camera tracking logic.
|
||||
|
||||
### Scene Tree (Multi-Floor, Future)
|
||||
|
||||
When multi-floor rendering is implemented, the FogGroup tree extends:
|
||||
|
||||
```
|
||||
FogGroup (CanvasGroup, z:0)
|
||||
LowerFloor2 (Node2D) z:-200 [dynamic, scale 0.94, modulate dim]
|
||||
Ground (TileMapLayer) z:0 relative
|
||||
Objects (Node2D) z:10 relative
|
||||
Entities (Node2D, y_sort) z:50 relative
|
||||
LowerFloor1 (Node2D) z:-100 [dynamic, scale 0.97, modulate dim]
|
||||
Ground (TileMapLayer) z:0 relative
|
||||
Objects (Node2D) z:10 relative
|
||||
Entities (Node2D, y_sort) z:50 relative
|
||||
FloorTiles z:0 [current floor, always present]
|
||||
FloorObjects z:10 also: ground shadows from HighAirborne
|
||||
YSortGroup z:100
|
||||
Airborne z:200 low flyers, per-sprite scale by altitude
|
||||
Overhead z:300
|
||||
HighAirborne z:350 above-ceiling flyers, per-sprite scale 1.03-1.30
|
||||
UpperContent (Node2D, y_sort) z:400 [dynamic, rare with fixed camera]
|
||||
```
|
||||
|
||||
Lower floor nodes are created dynamically when the player enters a location with vertical visibility (balcony, catwalk, atrium). Destroyed when they leave. The parent node's z_index places the entire floor at the correct depth. Child z_index values are relative, so the same sub-structure (ground, objects, entities) works at any depth.
|
||||
|
||||
Airborne sprites within the Airborne (z:200) and HighAirborne (z:350) nodes have per-sprite scale set by the entity renderer based on the entity's altitude z_step. High-altitude objects also spawn a ground shadow sprite as a child of FloorObjects (z:10).
|
||||
|
||||
### Sprint 6 Impact
|
||||
|
||||
None. Lower floor rendering is not implemented in Sprint 6. The architecture reserves z:-200 to z:-1 and defines the contract so future implementation has a clean home. No scene tree nodes needed now.
|
||||
|
||||
### Server Requirements (Future)
|
||||
|
||||
When multi-floor rendering is implemented, the server needs to provide:
|
||||
|
||||
1. **Per-floor visible entities:** ObserverSnapshot includes entities from visible adjacent floors, tagged with floor_index.
|
||||
2. **Per-floor visible tiles:** Tile data includes floor_index. The renderer groups tiles by floor and assigns to the correct lower/upper floor node.
|
||||
3. **Floor gap information:** Which tiles on the current floor are transparent/open (allowing view below). This could be a tile property (type: "open_floor", "railing") rather than a separate data field.
|
||||
4. **Per-floor visibility (for Option 1 fog):** Separate visibility data per floor. The server's LOS algorithm would need to project visibility downward through open floors.
|
||||
|
||||
None of these require protocol changes — they're extensions to existing ObserverSnapshot fields. The current protocol already includes floor-aware entity positions (x, y, z fields).
|
||||
|
||||
---
|
||||
|
||||
## Round 2 Corrections
|
||||
|
||||
### Items A-D: Confirmed Defer-Safe
|
||||
|
||||
**A. Floor transitions** (player moves between floors): Entity renderer swaps content per floor. No z-layer changes. The LowerFloor/UpperContent nodes are created/destroyed dynamically. Nothing in the z architecture blocks this — it's a script problem (entity renderer grouping by floor_index).
|
||||
|
||||
**B. Cursor on modals**: Cursor lives in UILayer (CanvasLayer 20). Modals live in ModalLayer (CanvasLayer 30). When a modal opens, cursor either (a) moves to ModalLayer temporarily, (b) ModalLayer spawns its own cursor, or (c) cursor CanvasLayer number bumps above modal. All are script-level solutions. The z-layer architecture accommodates any of these — CanvasLayers are independent.
|
||||
|
||||
**C. Half-wall occlusion**: Walls in YSortGroup at z:0, y-sort handles front/back occlusion naturally. Half-height walls use semi-transparent sprites. Entities behind half-walls: upper body occluded, lower body visible — achieved via sprite masking or split sprites (top half at wall z, bottom half visible). This is an art/shader problem. Nothing in the z architecture blocks it.
|
||||
|
||||
**D. Glass floors / forcefields**: Semi-transparent tiles at z:0 (ground plane). Lower floor content at z:-100 is visible through them. The z-ordering naturally handles this — lower floor renders first, semi-transparent ground renders over it, entities render on top. Forcefields could be FloorObjects (z:10) or YSortGroup members (z:100) depending on whether they're walked-over or blocking. Standard transparency, no architecture changes.
|
||||
|
||||
**Confirmation: nothing in the current z-layer architecture blocks any of A-D.** All are script, shader, or art problems that work within the established ranges and contracts.
|
||||
|
||||
### Item E: Airborne Ascending Alpha Fade
|
||||
|
||||
Lead's proposal: ascending objects get bigger (scale > 1.0, closer to camera) AND more transparent (fading out of active layer). A drone taking off fades as it ascends, eventually becoming just a ground shadow.
|
||||
|
||||
**Combined formula:**
|
||||
|
||||
```gdscript
|
||||
var altitude_floors := (entity.z_step - current_floor_z_step) / 5.0
|
||||
var scale_factor := 1.0 + altitude_floors * 0.03 # 1.0 → 1.30
|
||||
var alpha := clampf(1.0 - (altitude_floors / 10.0), 0.0, 1.0) # 1.0 → 0.0
|
||||
|
||||
airborne_sprite.scale = Vector2(scale_factor, scale_factor)
|
||||
airborne_sprite.modulate.a = alpha
|
||||
```
|
||||
|
||||
Visual progression:
|
||||
|
||||
| Altitude (floors) | Scale | Alpha | Visual |
|
||||
|-------------------|-------|-------|--------|
|
||||
| 0 (ground) | 1.00 | 1.00 | Normal entity, on ground |
|
||||
| 1 | 1.03 | 0.90 | Slightly larger, barely fading |
|
||||
| 3 | 1.09 | 0.70 | Noticeably larger, starting to ghost |
|
||||
| 5 | 1.15 | 0.50 | Large and semi-transparent |
|
||||
| 8 | 1.24 | 0.20 | Very large, ghostly, barely visible |
|
||||
| 10 (ceiling) | 1.30 | 0.00 | Invisible — only ground shadow remains |
|
||||
|
||||
This is elegant. The object visually "passes through" the camera plane — growing as it approaches, fading as it passes. The ground shadow (at z:10, FloorObjects) persists as the last trace.
|
||||
|
||||
**CanvasGroup composition: clean.** Per-sprite `modulate.a` works correctly inside a CanvasGroup. The CanvasGroup renders children in z-order into its compositing buffer with proper alpha blending. A sprite at modulate.a = 0.3 composites as semi-transparent in the output texture. Multiple semi-transparent airborne sprites that overlap would blend correctly (rendered in z-order within the Airborne/HighAirborne nodes).
|
||||
|
||||
**No conflict with lower floor modulate.** Lower floor depth treatment uses modulate on the PARENT node (LowerFloor1, LowerFloor2). Airborne alpha uses modulate on INDIVIDUAL sprites within Airborne/HighAirborne nodes. These are completely separate node hierarchies — the per-sprite alpha doesn't interact with the per-floor-group modulate. In Godot 4, modulate multiplies down the tree: a sprite with modulate.a = 0.5 under a parent with modulate.a = 0.7 renders at effective alpha 0.35. But airborne sprites are NOT children of lower floor groups — they're children of Airborne (z:200) or HighAirborne (z:350), whose parent is FogGroup (modulate = default 1.0). No unintended multiplication.
|
||||
|
||||
**Descending objects (landing):** The inverse — object descends from high altitude, scale shrinks from 1.30 toward 1.0, alpha increases from 0.0 toward 1.0. At ground level: normal size, fully opaque. Smooth transition. The same formula works bidirectionally.
|
||||
|
||||
**Contract update:** Airborne sprites have two per-sprite properties driven by altitude:
|
||||
1. `scale` — increases with altitude (1.0 to 1.30)
|
||||
2. `modulate.a` — decreases with altitude (1.0 to 0.0)
|
||||
3. Ground shadow sprite (child of FloorObjects z:10) — opacity inversely proportional to airborne alpha (shadow gets stronger as object gets higher/more transparent)
|
||||
|
||||
### Item F: Cross-Floor Visual Effects (Smoke, Water, Gas)
|
||||
|
||||
Smoke rising through floor grates, water dripping between levels, gas leaks spreading across floors. These are CROSS-FLOOR effects that visually span multiple floor groups.
|
||||
|
||||
**Key insight: the FogGroup CanvasGroup composites EVERYTHING in z-order.** Cross-floor effects don't need to be children of any specific floor group. They can be standalone nodes at intermediate z values within the FogGroup. The per-floor render group structure does NOT block cross-floor effects — it actually enables them.
|
||||
|
||||
**How it works:**
|
||||
|
||||
A smoke plume rising from floor 0 through the ceiling:
|
||||
1. Smoke origin particles at z:150 (between YSortGroup z:100 and Airborne z:200) — visible above ground entities, below flying objects
|
||||
2. Smoke mid-section at z:250 (between Airborne z:200 and Overhead z:300) — rising through air space
|
||||
3. Smoke passing through ceiling at z:325 (between Overhead z:300 and HighAirborne z:350) — partially occluded by ceiling semi-transparency
|
||||
|
||||
The effect spans multiple z ranges within the same FogGroup compositing pass. From the CanvasGroup's perspective, it's just more children at various z values — composited in order with everything else.
|
||||
|
||||
**Water dripping between levels:**
|
||||
1. Water source on current floor at z:100 (YSortGroup — a pipe, a ceiling leak target)
|
||||
2. Water particles falling downward: these would be in the lower floor range (z:-50 to z:-1) — visible through floor gaps, just like lower floor entities
|
||||
3. Water splash on lower floor at z:-50 (LowerFloor1's entity range)
|
||||
|
||||
**Gas spreading across floors:**
|
||||
1. Gas origin on one floor
|
||||
2. Gas particles at intermediate z values (z:150, z:250) for current floor
|
||||
3. Gas seeping downward through floor gaps: particles in z:-50 to z:-1 range
|
||||
4. Gas rising upward: particles in z:250 to z:325 range
|
||||
|
||||
**Reserved VFX z-ranges:**
|
||||
|
||||
The existing pipeline has natural gaps between functional ranges. These gaps are the VFX home:
|
||||
|
||||
```
|
||||
z:-75 to z:-51 Lower floor VFX (effects between lower floor entities and current ground)
|
||||
z:150 to z:199 Ground-level VFX (smoke starting, gas pooling, sparks from ground)
|
||||
z:250 to z:299 Mid-air VFX (rising smoke, floating particles, air effects)
|
||||
z:325 to z:349 Ceiling-level VFX (smoke passing through ceiling, overhead effects)
|
||||
```
|
||||
|
||||
These ranges are already available — they're gaps between the established functional z values. No architecture changes needed. Just a reservation in the contract so future implementers know where VFX nodes belong.
|
||||
|
||||
**CanvasGroup consideration:** All VFX nodes are inside FogGroup. Particle effects with transparency composite correctly in the CanvasGroup buffer. Semi-transparent smoke at z:250 blends with the Airborne content at z:200 and Overhead at z:300 naturally. The fog shader then applies fog over the entire composited result — fogged areas hide VFX just like they hide entities.
|
||||
|
||||
**Performance:** Particle effects are GPU-driven in Godot 4 (GPUParticles2D). A few hundred particles across 3-4 VFX emitters: ~0.1-0.2ms. Well within budget.
|
||||
|
||||
**Per-floor modulate interaction:** VFX nodes at intermediate z values are NOT children of floor groups. They're siblings in the FogGroup. Their modulate is independent. A smoke plume at z:250 is fully opaque regardless of LowerFloor1's modulate at z:-100. This is correct — you see the smoke at full intensity, even if the floor below is dimmed.
|
||||
|
||||
**Confirmation: nothing in the current z-layer architecture blocks cross-floor VFX.** The per-floor group structure and intermediate z gaps actually make it clean — effects slot into the gaps between functional ranges, composited by the CanvasGroup alongside everything else. The only reservation needed is documenting the VFX z-ranges in the contract.
|
||||
|
||||
---
|
||||
|
||||
## Updated Complete Pipeline (Full Range)
|
||||
|
||||
```
|
||||
z:-200 to z:-101 Floor-2 below [future, scale 0.94, modulate dim]
|
||||
z:-100 to z:-76 Floor-1 below [future, scale 0.97, modulate dim]
|
||||
z:-75 to z:-51 Lower floor VFX [future] effects between lower floor + current ground
|
||||
z:-50 to z:-1 Floor-1 entities/air [future] lower floor y-sort content
|
||||
z:0 FloorTiles current floor ground
|
||||
z:10 FloorObjects cosmetic + ground shadows from high flyers
|
||||
z:100 YSortGroup current floor y-sort (entities/furniture/walls)
|
||||
z:150 to z:199 Ground VFX [future] smoke origins, gas pools, ground sparks
|
||||
z:200 Airborne low-flying objects, scale+alpha by altitude
|
||||
z:250 to z:299 Mid-air VFX [future] rising smoke, floating particles
|
||||
z:300 Overhead ceiling, upper structure, semi-transparent
|
||||
z:325 to z:349 Ceiling VFX [future] smoke through ceiling, overhead effects
|
||||
z:350 HighAirborne above-ceiling flying, scale+alpha by altitude
|
||||
z:400 UpperContent upper-floor entities (rare, fixed camera)
|
||||
z:500-899 Reserved edge cases
|
||||
z:900 FogOverlay OUTSIDE FogGroup, fog shader
|
||||
|
||||
CanvasLayer 10 InsertOverlay interaction UI, perception markers
|
||||
CanvasLayer 20 UILayer HUD, monologue, cursor
|
||||
CanvasLayer 30 ModalLayer full-screen modals, pause menu
|
||||
```
|
||||
|
||||
**Total z range:** -200 to 900 (of -4096 to 4096 available).
|
||||
**Visible floor window (looking down):** current - 2 (detailed rendering with scale + parallax).
|
||||
**Rendering ceiling (looking up):** current + 10 floors (25m). Above: no sprites, ground shadows only.
|
||||
**Airborne treatment:** scale increases + alpha decreases with altitude. Ground shadow at z:10.
|
||||
**VFX ranges:** Reserved at z gaps between functional layers. Cross-floor effects via intermediate z values.
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## Quick Note: Liquid Depth and Z-Layers
|
||||
|
||||
Liquid depth at 0.5m sub-levels (wading → struggling → swimming) composes cleanly with the z-layer architecture. No changes needed — it fits in existing and reserved ranges.
|
||||
|
||||
### Where Liquid Renders
|
||||
|
||||
Water has TWO render layers, not one:
|
||||
|
||||
1. **Water bed** at z:10 (FloorObjects) — tinted/darkened floor beneath water. Always present when water exists. Shows underwater color, murk, submerged objects.
|
||||
|
||||
2. **Water surface occlusion** at z:110 (NEW range, between YSortGroup z:100 and Ground VFX z:150) — semi-transparent layer that partially covers entities. Opacity scales with depth:
|
||||
- Sub 1 (0.5m, wading): alpha ~0.15 — faint shimmer, entity legs slightly obscured
|
||||
- Sub 2 (1.0m, struggling): alpha ~0.45 — entity lower body submerged, water clearly visible
|
||||
- Sub 3 (1.5m, swimming): alpha ~0.75 — entity mostly submerged, only head/shoulders visible
|
||||
|
||||
3. **Water VFX** (ripples, splashes, shimmer) at z:150-199 (existing Ground VFX reservation) — particle effects on the water surface.
|
||||
|
||||
### Y-Sort Interaction
|
||||
|
||||
The water surface at z:110 sits ABOVE the entire YSortGroup (z:100). It covers all entities on that tile uniformly regardless of y-position. This is correct — water depth is uniform across a tile, not positional. An entity at y=3 and an entity at y=8 on the same flooded tile are equally submerged.
|
||||
|
||||
Entities do NOT y-sort with water. Water is a surface, not an object with a y-position. The z-layer separation handles this naturally: entities y-sort with each other (z:100 group), then water surface draws over all of them (z:110).
|
||||
|
||||
### Rising Water Transition
|
||||
|
||||
Rising water doesn't move through z-ranges. It stays at the same z values — only the OPACITY of the z:110 surface layer changes:
|
||||
|
||||
```
|
||||
Dry → Sub 1: z:10 appears (water bed color), z:110 appears (alpha 0.15)
|
||||
Sub 1 → Sub 2: z:10 darkens, z:110 alpha increases to 0.45
|
||||
Sub 2 → Sub 3: z:10 deeper color, z:110 alpha increases to 0.75
|
||||
Sub 3 → full: z:110 alpha 0.95, entities nearly invisible, only swimming animation above
|
||||
```
|
||||
|
||||
The transition is a smooth alpha tween on the surface layer, not a z-range change.
|
||||
|
||||
### Entity Sprite Consideration
|
||||
|
||||
At sub 2-3, entities should ideally show partial submersion (legs hidden, body partially in water). Two approaches:
|
||||
|
||||
**A. Sprite masking (shader):** Entity sprite shader clips pixels below a water-line y-offset. The water surface at z:110 then covers the clipped area. Clean but requires per-entity shader.
|
||||
|
||||
**B. Opacity-only (simpler):** Don't clip entity sprites. The semi-transparent water surface at z:110 just tints/obscures the lower portion naturally. Less precise but simpler. At 75% water alpha (sub 3), entity legs are heavily obscured without needing a clip mask.
|
||||
|
||||
Recommendation: Option B for v0.1, Option A as polish. The z-layer architecture supports both — it's an entity shader question, not a z-layer question.
|
||||
|
||||
### Pipeline Impact
|
||||
|
||||
One new reserved z-range:
|
||||
|
||||
```
|
||||
z:100 YSortGroup entities/furniture/walls
|
||||
z:110 LiquidSurface [future] water/liquid occlusion, alpha by depth
|
||||
z:150-199 Ground VFX smoke, ripples, splashes
|
||||
```
|
||||
|
||||
No architecture changes. z:110 is available in the existing gap. Cross-floor liquid effects (water dripping down) already covered by lower floor VFX ranges (z:-75 to z:-51).
|
||||
|
||||
---
|
||||
|
||||
## Verdict (Updated — Round 2)
|
||||
|
||||
**No remaining architectural gaps.** The full rendering pipeline is now specified:
|
||||
|
||||
- Fixed top-down camera (D-019) simplifies upper range: no "looking up," upper floors only via horizontal offset or transparency
|
||||
- Negative z (-200 to -1) for lower floors with scale < 1.0 + parallax (depth illusion)
|
||||
- Positive z (0-900) for current floor, airborne, overhead, high-altitude flying, upper content
|
||||
- Flying objects INCREASE in scale AND DECREASE in alpha with altitude — smooth "passing through camera" effect
|
||||
- Airborne split: z:200 (low, below ceiling) and z:350 (high, above ceiling) — different draw order relative to Overhead
|
||||
- Rendering ceiling: 10 floors / 25m / z_step+50 — above this, no sprites, ground shadows + environmental effects only
|
||||
- Per-sprite modulate.a on airborne objects composes cleanly with CanvasGroup (no conflict with per-floor modulate)
|
||||
- Cross-floor VFX (smoke, water, gas) slot into reserved z gaps between functional ranges — architecture enables, not blocks
|
||||
- VFX ranges reserved: z:-75 to -51, z:150-199, z:250-299, z:325-349
|
||||
- Items A-D (floor transitions, cursor on modals, half-wall occlusion, glass/forcefields) confirmed defer-safe — no architecture blockers
|
||||
- CanvasGroup composites all floors + VFX correctly (negative z first, positive z after)
|
||||
- Fog applies via modulate per floor (v0.1) or per-floor visibility textures (v0.2+)
|
||||
- Current floor occlusion is automatic from z-ordering (ground at z:0 covers z:-100 content)
|
||||
- Visible floor window (down): current - 2 (detailed, scale + parallax, ~0.64ms budget)
|
||||
- Y-sort contract preserved: each floor has its own y-sort group, no cross-floor interleaving
|
||||
- Dual depth cues for lower floors: modulate (color) + scale (parallax), composes cleanly with y-sort
|
||||
- Three CanvasLayer scopes defined (Insert 10, UI 20, Modal 30)
|
||||
- Critical bug: Entities z:3 must become z:0
|
||||
|
||||
Ready to implement Sprint 6 changes (scene tree z_index corrections + constants.gd rewrite) on lead's go.
|
||||
Reference in New Issue
Block a user