# NEON-27 — Implementation plan ## Story reference | Field | Value | |--------|--------| | **Key** | NEON-27 | | **Title** | E1.M2: OcclusionPolicy — keep player readable through geometry | | **Jira** | [NEON-27](https://neon-sprawl.atlassian.net/browse/NEON-27) | | **Parent** | [NEON-1 — Epic 1 — Core Player Runtime](https://neon-sprawl.atlassian.net/browse/NEON-1) | | **Module** | [E1.M2 — IsometricCameraController](../decomposition/modules/E1_M2_IsometricCameraController.md); umbrella [NEON-10](https://neon-sprawl.atlassian.net/browse/NEON-10) | ## Goal, scope, and out-of-scope **Goal:** Implement `OcclusionPolicy` so static geometry between the camera and the player does not fully hide the player silhouette or combat telegraphs. **In scope** - Choose and implement a prototype-appropriate occlusion technique: **RayCast-based material fade** (see [Technical approach](#technical-approach)). - New `OcclusionPolicy` **Resource** (`occlusion_policy.gd`) — data-driven config for fade alpha, fade duration, affected collision mask, cast depth cap, and optional perf-log threshold; follows the same no-`class_name` convention as `ZoomBandConfig`. - Wire `OcclusionPolicy` into `isometric_follow_camera.gd` as an `@export` so it runs consistently in the same camera controller stack as follow + zoom. - Tag the prototype district `Obstacle` node (a 2 × 2 × 2 box at `(6, 0, 5)`) with the **`"occluder"`** group; assign the new `.tres` on the `IsometricFollowCamera` rig in `main.tscn`. - Optional perf marker: when `occluder_count_log_threshold > 0` and the active occluder count meets or exceeds the threshold, emit `push_warning` for later stress-test reference. - Update `E1_M2_IsometricCameraController.md` with an implementation snapshot and the readability risk gate note required by acceptance criteria. **Out of scope** (per Jira) - Perfect transparency for all scene assets; full art pipeline integration. - Server awareness of occlusion. - Smooth occlusion handling for moving/dynamic objects (static geometry only for prototype). - Yaw orbit or camera nudge approach (would break framing; fixed rig stays). ## Acceptance criteria checklist - [ ] In a documented test setup, occlusion **no longer fully hides** the player in the common worst case (before/after screenshot or short clip note in PR). - [ ] Policy is **configurable or data-driven** enough to iterate without rewriting core follow logic (`OcclusionPolicy` resource swappable in inspector; `fade_alpha` and `fade_duration` are exported). - [ ] Risk called out in module doc: occlusion hiding telegraphs — link to **readability checklist** or gate note for prototype demo. ## Technical approach ### Technique: RayCast-based per-surface material fade Each `_process` frame, after the camera eye is positioned, cast a ray from the smoothed eye position (`_smoothed_eye`) to the player's focus point. Any `StaticBody3D` (or similar) intersecting the ray that is tagged with the **`"occluder"`** group has its `MeshInstance3D` children' surfaces overridden with a **faded copy** of their materials (alpha reduced to `fade_alpha`). When a body leaves the ray path, its original surface materials are restored. **Why this technique:** pure GDScript — no custom shader required for prototype; configurable; reversible; respects combat readability by making geometry semi-transparent rather than fully hiding it. Camera nudge would break the fixed isometric framing. A cutout shader would require art pipeline work outside the prototype scope. **Why group-based (`"occluder"`) rather than collision-layer filtering alone:** the prototype `Floor` (`StaticBody3D`) shares the default collision layer 1 with the `Obstacle`. Filtering by group is explicit and avoids false hits from the floor geometry without requiring separate collision-layer assignments. **Player exclusion:** the `Player` is on collision layer 2 (independent of layer 1); the default `occluder_collision_mask` of `1` already excludes the player body without needing to add it to the ray exclusion list. Confirmed from `main.tscn`: `Player.collision_layer = 2`. **Material override strategy:** for each `MeshInstance3D` child of a blocking body, iterate surfaces. For each surface, save the current `surface_override_material` (may be null). Resolve the effective material: prefer the surface override; fall back to `mesh.surface_get_material(i)`. If that material is a `StandardMaterial3D`, `duplicate()` it, set `transparency = BaseMaterial3D.TRANSPARENCY_ALPHA` and `albedo_color.a = fade_alpha`, then apply as the surface override. If it is not a `StandardMaterial3D` (e.g. `ShaderMaterial` or null), **skip that surface silently and emit `push_warning`** — do not substitute a plain gray material. Restore by setting `surface_override_material` back to the saved value (or `null`) when the body is no longer blocking. **Fade timing:** instant — set `albedo_color.a` to `fade_alpha` on entry, restore immediately on exit. No Tween; no `fade_duration` export. A smooth-fade pass can be added post-prototype if the pop is distracting during the demo playtest. State: `_occluder_overrides: Dictionary` mapping `Node3D → Array[{mesh, surface, original_mat}]`. ### Step-by-step implementation 1. **`occlusion_policy.gd`** — `extends Resource`, no `class_name`. Exported fields: - `enabled: bool = true` - `fade_alpha: float = 0.25` — target alpha when occluding (0 = invisible, 1 = opaque). - `occluder_group: String = "occluder"` — group that tags scene nodes as potential occluders. - `occluder_collision_mask: int = 1` — physics layers the ray checks. - `max_occluder_cast_depth: int = 4` — max successive intersect_ray calls per frame (caps cost). - `occluder_count_log_threshold: int = 0` — if > 0, push_warning when active count ≥ threshold. - Validation helper: `func is_valid() -> bool` — returns `true` if `enabled` and `fade_alpha >= 0`. 2. **`isometric_follow_camera.gd`** changes: - Add `@export var occlusion_policy: Resource`. - Add private state: `_occluder_overrides: Dictionary = {}`. - Add `_occlusion_policy_valid() -> bool` (mirrors `_zoom_config_valid`): checks script identity and `is_valid()`. - Call `_update_occlusion(focus)` at end of `_process` after the eye / look_at update. - `_update_occlusion(focus: Vector3) -> void`: if policy invalid, call `_restore_all_occluders()` and return. Otherwise, cast iterative rays (up to `max_occluder_cast_depth`), filter hits by `occluder_group`; restore bodies no longer blocking; fade newly blocking bodies. Emit perf warning when threshold exceeded. - `_apply_occluder_fade(body: Node3D) -> void`: enumerate `MeshInstance3D` descendants, save overrides, create faded `StandardMaterial3D` copies (instant); skip non-`StandardMaterial3D` surfaces with `push_warning`. - `_restore_occluder(body: Node3D) -> void`: restore original surface override materials (instant), remove from `_occluder_overrides`. - `_restore_all_occluders() -> void`: call `_restore_occluder` for all entries (on disable/null policy). 3. **`isometric_occlusion_policy.tres`** — default `OcclusionPolicy` resource (fade_alpha=0.25, fade_duration=0.15, mask=1, depth=4, threshold=0). Assign to `IsometricFollowCamera` in `main.tscn`. 4. **`main.tscn`** — Add `"occluder"` group to the `Obstacle` node; assign `occlusion_policy` resource on `IsometricFollowCamera`. 5. **`E1_M2_IsometricCameraController.md`** — Add NEON-27 implementation snapshot; add readability risk gate note (links to acceptance criteria: before-/after screenshot in PR required at prototype demo gate). ## Decisions | Topic | Choice | Rationale | |-------|--------|-----------| | **Occluder identification** | Explicit `"occluder"` group tag (Option A) | Precise; failure mode is obvious ("wall doesn't fade — tag it"); no false positives as scene grows; collision layers stay single-purpose. | | **Fade timing** | Instant alpha override — no Tween | Eliminates Tween race conditions (stuck-transparent geometry); simpler state; satisfies AC; smooth-fade pass deferred to post-prototype if pop is distracting at demo. | | **Non-`StandardMaterial3D` surfaces** | Skip silently + `push_warning` | Avoids unexpected appearance change; prototype `Obstacle` is unaffected (uses `StandardMaterial3D`); warning surfaces future mismatches without hiding them. | ## Files to add | Path | Purpose | |------|---------| | `client/scripts/occlusion_policy.gd` | `Resource` type holding fade and detection config; `is_valid()` helper; no `class_name`. | | `client/resources/isometric_occlusion_policy.tres` | Default `OcclusionPolicy` instance for the prototype rig. | | `client/test/occlusion_policy_test.gd` | GdUnit4 unit tests for the policy resource (see [Tests](#tests)). | ## Files to modify | Path | Rationale | |------|-----------| | `client/scripts/isometric_follow_camera.gd` | Add `occlusion_policy` export, `_update_occlusion` pipeline, occluder override state, fade/restore helpers, `_occlusion_policy_valid` guard. | | `client/scenes/main.tscn` | Tag `Obstacle` with `"occluder"` group; assign `isometric_occlusion_policy.tres` on `IsometricFollowCamera`. | | `client/test/isometric_follow_camera_test.gd` | Add test for `_occlusion_policy_valid`-equivalent static path (null policy → false; wrong-script resource → false; valid resource → true) via a static helper if possible; otherwise document as manual-only. | | `docs/decomposition/modules/E1_M2_IsometricCameraController.md` | Add NEON-27 implementation snapshot; add readability risk gate note per AC. | ## Tests | File | Coverage | |------|----------| | `client/test/occlusion_policy_test.gd` | **New.** `is_valid()` returns true with defaults; returns false when `enabled = false`; `fade_alpha` edge cases (0.0 valid, negative invalid if applicable). Default field values match resource. | | `client/test/isometric_follow_camera_test.gd` | **Change.** Add a static-helper test for occlusion policy guard: confirm that a null resource and a wrong-script resource are rejected, and a correct-script `OcclusionPolicy` resource is accepted (no physics engine needed — tests the guard function logic only). | **Manual verification:** Walk the player behind the `Obstacle` node in the running client. The obstacle should fade to semi-transparent (alpha ~0.25) as the player passes behind it, then restore when the player moves clear. Confirm `CameraState` / camera framing are unaffected. Capture a brief clip or before/after screenshot for the PR. ## Open questions / risks - **Rapid toggle at wall edges:** with instant override, a player sidestepping at the edge of an occluder may cause per-frame toggle. This is visually acceptable for prototype; if it proves distracting, a one-frame hysteresis (require N consecutive frames clear before restoring) can be added without a structural change. - **Shared vs. per-instance materials:** if the `Obstacle` in the future uses a shared `StandardMaterial3D` from the mesh resource directly (no override set), `duplicate()` ensures we don't mutate the shared asset. This is already handled by the override strategy above, but must be verified at runtime. - **`max_occluder_cast_depth` performance:** each `intersect_ray` call with growing exclusion list is O(scene complexity). Cap of 4 is conservative for the prototype district; tune if the perf log fires. - **Scene grows (NEON-29):** when the prototype district expands (NEON-29), add representative occluders to the `"occluder"` group and rerun the manual verification pass. - **Readability gate:** the risk documented in the module doc (occlusion hiding telegraphs) must be surfaced in the prototype demo checklist. Before-/after evidence in this PR's description serves as the first data point.