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
parent
1d891eb8bb
commit
185af5a7f6
|
|
@ -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.
|
||||
Loading…
Reference in New Issue