neon-sprawl/docs/plans/NEON-27-implementation-plan.md

10 KiB
Raw Blame History

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 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.gdextends 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).

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: Tweens 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.