NEON-27: add implementation plan for OcclusionPolicy

Documents RayCast-based material-fade approach, files to add/modify,
GdUnit4 test coverage, and readability risk gate note.
pull/35/head
VinPropane 2026-04-08 23:21:51 -04:00
parent 1d891eb8bb
commit 185af5a7f6
1 changed files with 115 additions and 0 deletions

View File

@ -0,0 +1,115 @@
# 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). Create a transparent override: if the surface's mesh material is a `StandardMaterial3D`, `duplicate()` it; otherwise create a new `StandardMaterial3D` inheriting only `albedo_color`. Set `transparency = BaseMaterial3D.TRANSPARENCY_ALPHA` and `albedo_color.a = fade_alpha`. Restore by setting `surface_override_material` back to the saved value (or `null`) when the body is no longer blocking.
**Fade timing:** use a `Tween` on the camera rig to animate the alpha from 1.0 → `fade_alpha` over `fade_duration` seconds (and back when restoring), avoiding jarring pop. State: `_occluder_overrides: Dictionary` mapping `Node3D → Array[{mesh, surface, original_mat, tween}]`.
### 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).
- `fade_duration: float = 0.15` — tween duration in seconds for appear/disappear.
- `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 materials, tween alpha in.
- `_restore_occluder(body: Node3D) -> void`: tween alpha back to 1.0 then restore original materials, 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).
## 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
- **Tween lifecycle:** `Tween`s created on the rig must be killed (`tween.kill()`) when a body is re-acquired as an occluder before the restore tween completes, to avoid competing animations on the same material. Track the active tween per body in `_occluder_overrides`.
- **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.