NEON-27: implement OcclusionPolicy — RayCast-based material fade

Adds OcclusionPolicy resource and wires it into IsometricFollowCamera.
Each _process frame, iterative rays from the camera eye to the player
focus detect bodies tagged "occluder"; their MeshInstance3D surfaces are
overridden with a transparent StandardMaterial3D (fade_alpha=0.25, instant).
Materials restore when the body clears the ray. Null-material surfaces
(e.g. the prototype Obstacle with no material assigned) receive a plain
transparent StandardMaterial3D so they fade correctly.

- client/scripts/occlusion_policy.gd — new Resource (enabled, fade_alpha,
  occluder_group, occluder_collision_mask, max_occluder_cast_depth,
  occluder_count_log_threshold, is_valid)
- client/resources/isometric_occlusion_policy.tres — default instance
- client/scripts/isometric_follow_camera.gd — occlusion_policy export,
  _update_occlusion pipeline, _apply_occluder_fade/_restore_occluder helpers,
  static occlusion_policy_is_valid guard, _exit_tree cleanup
- client/scenes/main.tscn — Obstacle tagged "occluder", policy assigned
- client/test/occlusion_policy_test.gd — new GdUnit4 unit tests
- client/test/isometric_follow_camera_test.gd — policy guard static tests
- docs/decomposition/modules/E1_M2_IsometricCameraController.md — NEON-27
  snapshot + readability demo gate note
- docs/plans/NEON-27-implementation-plan.md — null-material decision recorded
pull/35/head
VinPropane 2026-04-08 23:34:00 -04:00
parent b2d2500e6a
commit 15d405261f
8 changed files with 243 additions and 7 deletions

View File

@ -0,0 +1,12 @@
[gd_resource type="Resource" load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/occlusion_policy.gd" id="1_occ_pol"]
[resource]
script = ExtResource("1_occ_pol")
enabled = true
fade_alpha = 0.25
occluder_group = "occluder"
occluder_collision_mask = 1
max_occluder_cast_depth = 4
occluder_count_log_threshold = 0

View File

@ -8,6 +8,7 @@
[ext_resource type="Script" uid="uid://n3p4f3fky3nv" path="res://scripts/interaction_radius_indicators.gd" id="6_rad"]
[ext_resource type="Script" uid="uid://b8x7c4m3r5t8t2" path="res://scripts/isometric_follow_camera.gd" id="7_iso_cam"]
[ext_resource type="Resource" path="res://resources/isometric_zoom_bands.tres" id="8_zoom_bands"]
[ext_resource type="Resource" path="res://resources/isometric_occlusion_policy.tres" id="9_occ_pol"]
[sub_resource type="NavigationMesh" id="NavigationMesh_district"]
vertices = PackedVector3Array(-0.6999998, 0.25, -0.6999998, -0.10000038, 0.25, -0.6999998, -0.10000038, 0.25, -9.55, -9.55, 0.25, 0.05000019, -1, 0.25, 0.05000019, -9.55, 0.25, -9.55, 8.75, 0.25, -7.4500003, 8.75, 0.25, -6.4000006, 9.65, 0.25, -6.4000006, 9.65, 0.25, -9.55, 0.5, 0.25, -0.6999998, 6.6499996, 0.25, -5.2000003, 6.200001, 0.25, -5.5, 8.450001, 0.25, -7.75, 6.5, 0.25, -7.75, 6.200001, 0.25, -7.4500003, 7.1000004, 2.8000002, -6.8500004, 7.1000004, 2.8000002, -6.1000004, 7.8500004, 2.8000002, -6.1000004, 7.8500004, 2.8000002, -6.8500004, 7.25, 0.25, -6.7000003, 7.25, 0.25, -6.25, 7.700001, 0.25, -6.25, 7.700001, 0.25, -6.7000003, 8.75, 0.25, -5.5, 7.550001, 0.25, 3.8000002, 7.550001, 0.25, 5, 9.65, 0.25, 5, 7.25, 0.25, 3.5, 0.9499998, 0.25, 0.5, 0.5, 0.25, 0.8000002, 4.55, 0.25, 3.9499998, 4.8500004, 0.25, 3.5, 8.450001, 0.25, -5.2000003, 0.9499998, 0.25, -0.39999962, -0.6999998, 0.25, 0.8000002, 4.55, 0.25, 6.200001, 4.55, 0.25, 9.65, -9.55, 0.25, 9.65, 5.45, 2.2, 4.4000006, 5.45, 2.2, 5.6000004, 6.6499996, 2.2, 5.6000004, 6.6499996, 2.2, 4.4000006, 5.6000004, 0.25, 4.55, 5.6000004, 0.25, 5.45, 6.5, 0.25, 5.45, 6.5, 0.25, 4.55, 7.550001, 0.25, 6.200001, 7.25, 0.25, 6.5, 9.800001, 0.40000004, 9.800001, 4.8500004, 0.25, 6.5)
@ -91,6 +92,7 @@ shadow_enabled = true
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 12, 10, 12)
script = ExtResource("7_iso_cam")
zoom_band_config = ExtResource("8_zoom_bands")
occlusion_policy = ExtResource("9_occ_pol")
[node name="Camera3D" type="Camera3D" parent="World/IsometricFollowCamera" unique_id=1124088857]
current = true
@ -141,7 +143,7 @@ surface_material_override/0 = SubResource("Mat_move_reject_farpad")
[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/MoveRejectFarPad" unique_id=1900033]
shape = SubResource("BoxShape3D_move_reject_farpad")
[node name="Obstacle" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1638845763]
[node name="Obstacle" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1638845763 groups=["occluder"]]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 6, 0, 5)
[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/Obstacle" unique_id=750473628]

View File

@ -12,6 +12,7 @@ signal zoom_band_changed(new_index: int, distance: float)
const CameraStateScript := preload("res://scripts/camera_state.gd")
const ZoomBandConfigScript := preload("res://scripts/zoom_band_config.gd")
const OcclusionPolicyScript := preload("res://scripts/occlusion_policy.gd")
## Tuned to match pre-NEON-25 static `Camera3D` at (12,10,12) vs player at (-5,0.9,-5).
## When [member zoom_band_config] is null or has no bands, this is the sole follow distance.
@ -24,6 +25,9 @@ const ZoomBandConfigScript := preload("res://scripts/zoom_band_config.gd")
## Designer-tunable discrete bands (`res://resources/isometric_zoom_bands.tres` on main rig).
@export var zoom_band_config: Resource
## RayCast-based occluder fade config (`res://resources/isometric_occlusion_policy.tres`).
@export var occlusion_policy: Resource
@export var allow_yaw: bool = false
@export var max_yaw_deg: float = 45.0
@ -42,6 +46,8 @@ var _smoothed_eye: Vector3 = Vector3.ZERO
var _orbit_yaw_rad: float = 0.0
var _zoom_band_index: int = 0
var _warned_missing_follow_target: bool = false
## Keys: Node3D occluder bodies. Values: Array of {mesh, surface, original} restore entries.
var _occluder_overrides: Dictionary = {}
@onready var camera: Camera3D = $Camera3D
@ -103,6 +109,11 @@ func _process(delta: float) -> void:
global_position = _smoothed_eye
camera.look_at(focus, Vector3.UP)
_sync_camera_state(focus, effective_dist)
_update_occlusion(focus)
func _exit_tree() -> void:
_restore_all_occluders()
func _unhandled_input(event: InputEvent) -> void:
@ -196,3 +207,116 @@ static func desired_eye_world(
) -> Vector3:
var h: float = distance * cos(pitch_elevation_rad)
return focus + Vector3(h * sin(yaw_rad), distance * sin(pitch_elevation_rad), h * cos(yaw_rad))
## Returns true when [member occlusion_policy] is a valid, enabled [OcclusionPolicy].
## Mirrors [method _zoom_config_valid]; delegates to the static helper so tests can call
## it without a live Node.
func _occlusion_policy_valid() -> bool:
return occlusion_policy_is_valid(occlusion_policy)
## Static guard — safe to call from tests without a live scene.
static func occlusion_policy_is_valid(policy: Resource) -> bool:
return (
policy != null
and policy.get_script() == OcclusionPolicyScript
and policy.is_valid()
)
## Cast iterative rays from the smoothed eye to the player focus.
## Bodies in the [member occluder_group] that intersect the ray are faded;
## bodies that leave the path have their materials restored.
func _update_occlusion(focus: Vector3) -> void:
if not _occlusion_policy_valid():
_restore_all_occluders()
return
var policy = occlusion_policy
var space_state := get_world_3d().direct_space_state
var newly_blocking: Array[Node3D] = []
var excluded: Array[RID] = []
for _i in range(policy.max_occluder_cast_depth):
var params := PhysicsRayQueryParameters3D.create(
_smoothed_eye, focus, policy.occluder_collision_mask
)
params.exclude = excluded
var result := space_state.intersect_ray(params)
if result.is_empty():
break
var body = result.get("collider")
if body is Node3D and (body as Node3D).is_in_group(policy.occluder_group):
newly_blocking.append(body as Node3D)
excluded.append(result.get("rid"))
if (
policy.occluder_count_log_threshold > 0
and newly_blocking.size() >= policy.occluder_count_log_threshold
):
push_warning(
"OcclusionPolicy: %d occluder(s) active (threshold %d)" % [
newly_blocking.size(), policy.occluder_count_log_threshold
]
)
var to_restore: Array = _occluder_overrides.keys()
for body in to_restore:
if body not in newly_blocking:
_restore_occluder(body)
for body in newly_blocking:
if body not in _occluder_overrides:
_apply_occluder_fade(body)
## Apply per-surface fade overrides to all [MeshInstance3D] children of [param body].
## - [StandardMaterial3D]: duplicated and alpha-set to [member OcclusionPolicy.fade_alpha].
## - null (no material): a plain [StandardMaterial3D] is created so the surface fades.
## - Anything else (e.g. [ShaderMaterial]): skipped with [method push_warning].
func _apply_occluder_fade(body: Node3D) -> void:
var policy = occlusion_policy
var overrides: Array = []
for mesh_node in body.find_children("*", "MeshInstance3D", true, false):
var mi: MeshInstance3D = mesh_node
var n: int = mi.get_surface_override_material_count()
for s in range(n):
var saved: Material = mi.get_surface_override_material(s)
var effective: Material = saved
if effective == null and mi.mesh != null:
effective = mi.mesh.surface_get_material(s)
var faded: StandardMaterial3D
if effective is StandardMaterial3D:
faded = (effective as StandardMaterial3D).duplicate()
elif effective == null:
faded = StandardMaterial3D.new()
else:
push_warning(
"OcclusionPolicy: %s surface %d is %s — skipping fade." % [
mi.get_path(), s, effective.get_class()
]
)
continue
faded.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
faded.albedo_color.a = policy.fade_alpha
mi.set_surface_override_material(s, faded)
overrides.append({"mesh": mi, "surface": s, "original": saved})
_occluder_overrides[body] = overrides
## Restore saved surface override materials on [param body] and remove it from tracking.
func _restore_occluder(body: Node3D) -> void:
if not _occluder_overrides.has(body):
return
for entry in _occluder_overrides[body]:
var mi: MeshInstance3D = entry["mesh"]
if is_instance_valid(mi):
mi.set_surface_override_material(entry["surface"], entry["original"])
_occluder_overrides.erase(body)
## Restore all currently faded occluders (called on policy disable or node exit).
func _restore_all_occluders() -> void:
for body in _occluder_overrides.keys():
_restore_occluder(body)

View File

@ -0,0 +1,30 @@
extends Resource
## OcclusionPolicy — config for the RayCast-based per-surface material fade used by
## [IsometricFollowCamera] (NEON-27). No `class_name` — see repo Godot headless / CI notes.
## Master switch. When false, no rays are cast and any active overrides are restored.
@export var enabled: bool = true
## Target alpha applied to occluder surfaces (0 = fully transparent, 1 = opaque).
@export_range(0.0, 1.0) var fade_alpha: float = 0.25
## Scene group that opts a [CollisionObject3D] into occlusion fading.
@export var occluder_group: String = "occluder"
## Physics collision mask for the ray. Default 1 (world geometry layer).
## The Player is on layer 2 and is already excluded with this default.
@export_flags_3d_physics var occluder_collision_mask: int = 1
## Max successive [method PhysicsDirectSpaceState3D.intersect_ray] calls per frame.
## Caps cost when occluders are stacked along the ray.
@export var max_occluder_cast_depth: int = 4
## When > 0, emits [method push_warning] when the active occluder count reaches this
## value. Set to 0 to disable. Marker for stress-test reference (TODO E9.M1 telemetry).
@export var occluder_count_log_threshold: int = 0
## Returns true when the policy should be applied this frame.
func is_valid() -> bool:
return enabled and fade_alpha >= 0.0

View File

@ -1,8 +1,9 @@
# Static eye-position helper for res://scripts/isometric_follow_camera.gd (NEON-25 framing).
# Static helpers for res://scripts/isometric_follow_camera.gd (NEON-25 framing, NEON-27 guard).
extends GdUnitTestSuite
const IsoScript := preload("res://scripts/isometric_follow_camera.gd")
const ZoomBandConfigScript := preload("res://scripts/zoom_band_config.gd")
const OcclusionPolicyScript := preload("res://scripts/occlusion_policy.gd")
func test_desired_eye_matches_previous_static_camera_frame() -> void:
@ -41,3 +42,23 @@ func test_effective_follow_distance_fallback_when_band_non_positive() -> void:
var cfg = ZoomBandConfigScript.new()
cfg.band_distances = PackedFloat32Array([10.0, 0.0, 30.0])
assert_that(IsoScript.effective_follow_distance(cfg, 2, 99.0)).is_equal(99.0)
func test_occlusion_policy_is_valid_null() -> void:
assert_that(IsoScript.occlusion_policy_is_valid(null)).is_false()
func test_occlusion_policy_is_valid_wrong_script() -> void:
var wrong = ZoomBandConfigScript.new()
assert_that(IsoScript.occlusion_policy_is_valid(wrong)).is_false()
func test_occlusion_policy_is_valid_correct_script() -> void:
var p = OcclusionPolicyScript.new()
assert_that(IsoScript.occlusion_policy_is_valid(p)).is_true()
func test_occlusion_policy_is_valid_false_when_disabled() -> void:
var p = OcclusionPolicyScript.new()
p.enabled = false
assert_that(IsoScript.occlusion_policy_is_valid(p)).is_false()

View File

@ -0,0 +1,43 @@
# Tests res://scripts/occlusion_policy.gd (NEON-27 occlusion config).
extends GdUnitTestSuite
const OcclusionPolicyScript := preload("res://scripts/occlusion_policy.gd")
func test_defaults_are_valid() -> void:
var p = OcclusionPolicyScript.new()
assert_that(p.is_valid()).is_true()
func test_defaults_match_spec() -> void:
var p = OcclusionPolicyScript.new()
assert_that(p.enabled).is_true()
assert_that(p.fade_alpha).is_equal(0.25)
assert_that(p.occluder_group).is_equal("occluder")
assert_that(p.occluder_collision_mask).is_equal(1)
assert_that(p.max_occluder_cast_depth).is_equal(4)
assert_that(p.occluder_count_log_threshold).is_equal(0)
func test_is_valid_false_when_disabled() -> void:
var p = OcclusionPolicyScript.new()
p.enabled = false
assert_that(p.is_valid()).is_false()
func test_is_valid_true_when_fade_alpha_zero() -> void:
var p = OcclusionPolicyScript.new()
p.fade_alpha = 0.0
assert_that(p.is_valid()).is_true()
func test_is_valid_false_when_fade_alpha_negative() -> void:
var p = OcclusionPolicyScript.new()
p.fade_alpha = -0.1
assert_that(p.is_valid()).is_false()
func test_is_valid_true_at_full_opaque() -> void:
var p = OcclusionPolicyScript.new()
p.fade_alpha = 1.0
assert_that(p.is_valid()).is_true()

View File

@ -7,7 +7,7 @@
| **Module ID** | E1.M2 |
| **Epic** | [Epic 1 — Core Player Runtime](../epics/epic_01_core_player_runtime.md) |
| **Stage target** | Prototype |
| **Status** | In progress — follow + `CameraState` + **discrete zoom bands** shipped ([NEON-25](https://neon-sprawl.atlassian.net/browse/NEON-25), [NEON-26](https://neon-sprawl.atlassian.net/browse/NEON-26)); occlusion + hardening open ([NEON-27](https://neon-sprawl.atlassian.net/browse/NEON-27)[NEON-28](https://neon-sprawl.atlassian.net/browse/NEON-28)); see [dependency register](module_dependency_register.md) |
| **Status** | In progress — follow + `CameraState` + **discrete zoom bands** + **occlusion policy** shipped ([NEON-25](https://neon-sprawl.atlassian.net/browse/NEON-25)[NEON-27](https://neon-sprawl.atlassian.net/browse/NEON-27)); hardening open ([NEON-28](https://neon-sprawl.atlassian.net/browse/NEON-28)); see [dependency register](module_dependency_register.md) |
| **Jira** | Feature [NEON-10](https://neon-sprawl.atlassian.net/browse/NEON-10); prototype backlog stories under parent [NEON-1](https://neon-sprawl.atlassian.net/browse/NEON-1) — [Jira backlog](#jira-backlog) |
## Purpose
@ -55,11 +55,14 @@ Delivers an isometric follow camera that keeps the player readable during motion
See Epic 1 **Slice 2 — Locked isometric camera**: follow-center, zoom bands, occlusion policy, **yaw fixed for players in prototype** (implementation still models yaw per [mid-project rotation policy](#mid-project-rotation-policy-practical-compromise)); optional telemetry such as throttled `camera_zoom_changed` and perf stress markers for occluders.
## Implementation snapshot (NEON-25 + NEON-26, 2026-04-08)
## Implementation snapshot (NEON-25 + NEON-26 + NEON-27, 2026-04-08)
- **Godot:** `client/scripts/isometric_follow_camera.gd` on **`World/IsometricFollowCamera`**; child **`Camera3D`** (current). **`scripts/camera_state.gd`** — `RefCounted` tick snapshot (`yaw` = orbit component, **0** while **`allow_yaw`** is false; **`distance`** = effective follow distance; **`zoom_band_index`**; **`focus_world`**, **`follow_target_path`**).
- **Seam:** **`allow_yaw`** / **`max_yaw_deg`** on the rig; orbit would adjust **`_orbit_yaw_rad`** (no input bound yet). **Presentation** compass uses **`presentation_yaw_deg`** separately from orbit **`yaw`** in state.
- **Zoom:** **`ZoomBandConfig`** resource (`client/scripts/zoom_band_config.gd`); default **`client/resources/isometric_zoom_bands.tres`** on main rig. Bands are **distance-only** (ascending near→far); **pitch / FOV** unchanged vs NEON-25. Input: **`camera_zoom_in`** / **`camera_zoom_out`** in `client/project.godot` (mouse wheel + `=` / `-` + keypad **+** / ****). **`follow_distance`** remains **fallback** when config is null or has no bands; state **`zoom_band_index`** is **0** in that case. **`zoom_band_changed`** signal on rig; **TODO(E9.M1)** for throttled `camera_zoom_changed` telemetry.
- **Occlusion:** **`OcclusionPolicy`** resource (`client/scripts/occlusion_policy.gd`); default **`client/resources/isometric_occlusion_policy.tres`** on main rig. **Technique:** each `_process` frame, iterative `intersect_ray` from `_smoothed_eye` to player focus (up to `max_occluder_cast_depth` calls). Bodies tagged `"occluder"` that intersect the ray have their `MeshInstance3D` surfaces overridden with a transparent `StandardMaterial3D` copy (`fade_alpha = 0.25`). Materials restored instantly when the body clears the ray. **Null-material surfaces** (no mesh or override material set) receive a plain transparent `StandardMaterial3D`; other non-`StandardMaterial3D` surfaces are skipped with `push_warning`. **Prototype district:** `Obstacle` node in `main.tscn` tagged `"occluder"`. **Perf marker:** `occluder_count_log_threshold` export (default 0 = disabled) emits `push_warning` for stress-test reference. See [risks and telemetry](#risks-and-telemetry) for readability gate.
> **Prototype demo readability gate:** before shipping the prototype demo, the PR for NEON-27 must include a before/after screenshot or clip demonstrating that the player is no longer fully hidden by the `Obstacle`. This serves as the first data point for the occlusion-hiding-telegraphs risk documented below.
## Jira backlog

View File

@ -33,7 +33,7 @@
## 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).
- [ ] Policy is **configurable or data-driven** enough to iterate without rewriting core follow logic (`OcclusionPolicy` resource swappable in inspector; `fade_alpha`, `occluder_group`, and `occluder_collision_mask` are exported).
- [ ] Risk called out in module doc: occlusion hiding telegraphs — link to **readability checklist** or gate note for prototype demo.
## Technical approach
@ -73,7 +73,7 @@ Each `_process` frame, after the camera eye is positioned, cast a ray from the s
- `_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).
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, 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`.
@ -85,7 +85,8 @@ Each `_process` frame, after the camera eye is positioned, cast a ray from the s
|-------|--------|-----------|
| **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. |
| **Non-`StandardMaterial3D` surfaces** | Skip silently + `push_warning` | Avoids unexpected appearance change; warning surfaces future mismatches without hiding them. |
| **Null surface material** | Create plain `StandardMaterial3D` with fade alpha | The prototype `Obstacle` (`BoxMesh`, no material assigned) hits this path. A null material means Godot renders the default; overriding with a transparent `StandardMaterial3D` achieves the fade, and restoring to `null` returns to the default exactly. |
## Files to add