12 KiB
NEON-27 — Implementation plan
Story reference
| Field | Value |
|---|---|
| Key | NEON-27 |
| Title | E1.M2: OcclusionPolicy — keep player readable through geometry |
| Jira | NEON-27 |
| Parent | NEON-1 — Epic 1 — Core Player Runtime |
| Module | E1.M2 — IsometricCameraController; umbrella 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).
- New
OcclusionPolicyResource (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_nameconvention asZoomBandConfig. - Wire
OcclusionPolicyintoisometric_follow_camera.gdas an@exportso it runs consistently in the same camera controller stack as follow + zoom. - Tag the prototype district
Obstaclenode (a 2 × 2 × 2 box at(6, 0, 5)) with the"occluder"group; assign the new.treson theIsometricFollowCamerarig inmain.tscn. - Optional perf marker: when
occluder_count_log_threshold > 0and the active occluder count meets or exceeds the threshold, emitpush_warningfor later stress-test reference. - Update
E1_M2_IsometricCameraController.mdwith 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 (
OcclusionPolicyresource swappable in inspector;fade_alphaandfade_durationare 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
-
occlusion_policy.gd—extends Resource, noclass_name. Exported fields:enabled: bool = truefade_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— returnstrueifenabledandfade_alpha >= 0.
-
isometric_follow_camera.gdchanges:- Add
@export var occlusion_policy: Resource. - Add private state:
_occluder_overrides: Dictionary = {}. - Add
_occlusion_policy_valid() -> bool(mirrors_zoom_config_valid): checks script identity andis_valid(). - Call
_update_occlusion(focus)at end of_processafter the eye / look_at update. _update_occlusion(focus: Vector3) -> void: if policy invalid, call_restore_all_occluders()and return. Otherwise, cast iterative rays (up tomax_occluder_cast_depth), filter hits byoccluder_group; restore bodies no longer blocking; fade newly blocking bodies. Emit perf warning when threshold exceeded._apply_occluder_fade(body: Node3D) -> void: enumerateMeshInstance3Ddescendants, save overrides, create fadedStandardMaterial3Dcopies (instant); skip non-StandardMaterial3Dsurfaces withpush_warning._restore_occluder(body: Node3D) -> void: restore original surface override materials (instant), remove from_occluder_overrides._restore_all_occluders() -> void: call_restore_occluderfor all entries (on disable/null policy).
- Add
-
isometric_occlusion_policy.tres— defaultOcclusionPolicyresource (fade_alpha=0.25, fade_duration=0.15, mask=1, depth=4, threshold=0). Assign toIsometricFollowCamerainmain.tscn. -
main.tscn— Add"occluder"group to theObstaclenode; assignocclusion_policyresource onIsometricFollowCamera. -
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). |
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
Obstaclein the future uses a sharedStandardMaterial3Dfrom 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_depthperformance: eachintersect_raycall 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.