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 recordedpull/35/head
parent
b2d2500e6a
commit
15d405261f
|
|
@ -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
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue