NEON-27: record planning decisions in implementation plan

Occluder ID: explicit group tag. Fade: instant (no Tween). Non-StandardMaterial3D
surfaces: skip + push_warning. Rationale documented in Decisions table.
pull/35/head
VinPropane 2026-04-08 23:28:02 -04:00
parent 185af5a7f6
commit b2d2500e6a
1 changed files with 13 additions and 6 deletions

View File

@ -48,16 +48,15 @@ Each `_process` frame, after the camera eye is positioned, cast a ray from the s
**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`. **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. **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:** 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}]`. **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 ### Step-by-step implementation
1. **`occlusion_policy.gd`** — `extends Resource`, no `class_name`. Exported fields: 1. **`occlusion_policy.gd`** — `extends Resource`, no `class_name`. Exported fields:
- `enabled: bool = true` - `enabled: bool = true`
- `fade_alpha: float = 0.25` — target alpha when occluding (0 = invisible, 1 = opaque). - `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_group: String = "occluder"` — group that tags scene nodes as potential occluders.
- `occluder_collision_mask: int = 1` — physics layers the ray checks. - `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). - `max_occluder_cast_depth: int = 4` — max successive intersect_ray calls per frame (caps cost).
@ -70,8 +69,8 @@ Each `_process` frame, after the camera eye is positioned, cast a ray from the s
- Add `_occlusion_policy_valid() -> bool` (mirrors `_zoom_config_valid`): checks script identity and `is_valid()`. - 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. - 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. - `_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. - `_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`: tween alpha back to 1.0 then restore original materials, remove from `_occluder_overrides`. - `_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). - `_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`. 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`.
@ -80,6 +79,14 @@ Each `_process` frame, after the camera eye is positioned, cast a ray from the s
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). 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 ## Files to add
| Path | Purpose | | Path | Purpose |
@ -108,7 +115,7 @@ Each `_process` frame, after the camera eye is positioned, cast a ray from the s
## Open questions / risks ## 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`. - **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. - **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. - **`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. - **Scene grows (NEON-29):** when the prototype district expands (NEON-29), add representative occluders to the `"occluder"` group and rerun the manual verification pass.