From 15d405261f5a15178aa697415d7e499eb1ac138f Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 8 Apr 2026 23:34:00 -0400 Subject: [PATCH] =?UTF-8?q?NEON-27:=20implement=20OcclusionPolicy=20?= =?UTF-8?q?=E2=80=94=20RayCast-based=20material=20fade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../resources/isometric_occlusion_policy.tres | 12 ++ client/scenes/main.tscn | 4 +- client/scripts/isometric_follow_camera.gd | 124 ++++++++++++++++++ client/scripts/occlusion_policy.gd | 30 +++++ client/test/isometric_follow_camera_test.gd | 23 +++- client/test/occlusion_policy_test.gd | 43 ++++++ .../E1_M2_IsometricCameraController.md | 7 +- docs/plans/NEON-27-implementation-plan.md | 7 +- 8 files changed, 243 insertions(+), 7 deletions(-) create mode 100644 client/resources/isometric_occlusion_policy.tres create mode 100644 client/scripts/occlusion_policy.gd create mode 100644 client/test/occlusion_policy_test.gd diff --git a/client/resources/isometric_occlusion_policy.tres b/client/resources/isometric_occlusion_policy.tres new file mode 100644 index 0000000..3e7c44d --- /dev/null +++ b/client/resources/isometric_occlusion_policy.tres @@ -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 diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index 1e98450..e954510 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -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] diff --git a/client/scripts/isometric_follow_camera.gd b/client/scripts/isometric_follow_camera.gd index 3b5ac20..01ccefd 100644 --- a/client/scripts/isometric_follow_camera.gd +++ b/client/scripts/isometric_follow_camera.gd @@ -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) diff --git a/client/scripts/occlusion_policy.gd b/client/scripts/occlusion_policy.gd new file mode 100644 index 0000000..6d716c3 --- /dev/null +++ b/client/scripts/occlusion_policy.gd @@ -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 diff --git a/client/test/isometric_follow_camera_test.gd b/client/test/isometric_follow_camera_test.gd index 5bae4d4..542d935 100644 --- a/client/test/isometric_follow_camera_test.gd +++ b/client/test/isometric_follow_camera_test.gd @@ -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() diff --git a/client/test/occlusion_policy_test.gd b/client/test/occlusion_policy_test.gd new file mode 100644 index 0000000..acca22d --- /dev/null +++ b/client/test/occlusion_policy_test.gd @@ -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() diff --git a/docs/decomposition/modules/E1_M2_IsometricCameraController.md b/docs/decomposition/modules/E1_M2_IsometricCameraController.md index e466ee4..3ec9079 100644 --- a/docs/decomposition/modules/E1_M2_IsometricCameraController.md +++ b/docs/decomposition/modules/E1_M2_IsometricCameraController.md @@ -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 diff --git a/docs/plans/NEON-27-implementation-plan.md b/docs/plans/NEON-27-implementation-plan.md index 7bbde6f..94f2678 100644 --- a/docs/plans/NEON-27-implementation-plan.md +++ b/docs/plans/NEON-27-implementation-plan.md @@ -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