NEON-28: run follow camera in physics after Player (fix jitter)
parent
f0ff5c9d77
commit
6340693b46
|
|
@ -13,7 +13,7 @@ Do not grow an all-in-one **`main.gd`**. The main scene root should **compose**
|
|||
|
||||
## Follow camera (NEON-25)
|
||||
|
||||
The main scene uses **`World/IsometricFollowCamera`** (`scripts/isometric_follow_camera.gd`): client-local **isometric follow** for **`Player`** (`NodePath` **`../Player`**), with damped eye motion (`follow_smoothness`, exponential lerp) and **teleport snap** when the eye is farther than **`snap_distance`** from the desired pose (e.g. after **`snap_to_server`**). Framing exports **`follow_distance`**, **`pitch_elevation_deg`**, and **`presentation_yaw_deg`** match the pre–NEON-25 static camera at spawn. **Orbit is off in the prototype:** **`allow_yaw`** is false, **`CameraState.yaw`** stays **0**, and no rotate input is read—enable **`allow_yaw`** later and drive **`_orbit_yaw_rad`** from input on the rig (see **`docs/decomposition/modules/E1_M2_IsometricCameraController.md`**). **`CameraState`** (`scripts/camera_state.gd`) is refreshed every frame on the rig. **`ground_pick`** uses the viewport camera; **`main.gd`** sets **`fallback_camera`** to **`World/IsometricFollowCamera/Camera3D`**.
|
||||
The main scene uses **`World/IsometricFollowCamera`** (`scripts/isometric_follow_camera.gd`): client-local **isometric follow** for **`Player`** (`NodePath` **`../Player`**), with damped eye motion (`follow_smoothness`, exponential lerp) and **teleport snap** when the eye is farther than **`snap_distance`** from the desired pose (e.g. after **`snap_to_server`**). Framing exports **`follow_distance`**, **`pitch_elevation_deg`**, and **`presentation_yaw_deg`** match the pre–NEON-25 static camera at spawn. **Orbit is off in the prototype:** **`allow_yaw`** is false, **`CameraState.yaw`** stays **0**, and no rotate input is read—enable **`allow_yaw`** later and drive **`_orbit_yaw_rad`** from input on the rig (see **`docs/decomposition/modules/E1_M2_IsometricCameraController.md`**). **`CameraState`** (`scripts/camera_state.gd`) is refreshed every **`_physics_process`** tick on the rig (**`process_physics_priority`** after **`Player`**) so framing tracks **`move_and_slide`** without display-vs-physics jitter. **`ground_pick`** uses the viewport camera; **`main.gd`** sets **`fallback_camera`** to **`World/IsometricFollowCamera/Camera3D`**.
|
||||
|
||||
## Authoritative movement (NEON-4, NEON-8)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
extends RefCounted
|
||||
|
||||
## Client-local **per-frame** snapshot from the follow rig (`isometric_follow_camera.gd`).
|
||||
## The rig creates **one** instance and assigns fields every `_process` after pose is final.
|
||||
## Client-local snapshot from the follow rig (`isometric_follow_camera.gd`).
|
||||
## The rig creates **one** instance and assigns fields every **`_physics_process`** tick after
|
||||
## the follow target has moved (rig uses `process_physics_priority` after `Player`).
|
||||
## No `class_name` — see repo Godot headless / CI notes.
|
||||
##
|
||||
## **Consumer contract (NEON-28):** Risk UX / HUD may read this object from the rig’s
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
extends Node3D
|
||||
|
||||
## NEON-25: client-local isometric follow; updates [CameraState] each `_process`.
|
||||
## NEON-25: client-local isometric follow; updates [CameraState] each physics tick.
|
||||
## NEON-26: discrete zoom bands via [member zoom_band_config]; wheel / `camera_zoom_*` actions.
|
||||
## NEON-28: purge freed occluder keys from [member _occluder_overrides] before ray pass (see
|
||||
## [method occluder_override_key_is_valid]).
|
||||
|
|
@ -55,6 +55,8 @@ var _occluder_overrides: Dictionary = {}
|
|||
|
||||
|
||||
func _ready() -> void:
|
||||
# After `Player` (default priority 0): read `global_position` post–`move_and_slide` this tick.
|
||||
process_physics_priority = 1
|
||||
_state = CameraStateScript.new()
|
||||
_init_zoom_band_index()
|
||||
if camera == null:
|
||||
|
|
@ -80,7 +82,7 @@ func _ready() -> void:
|
|||
_warn_missing_follow_target_once()
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
func _physics_process(delta: float) -> void:
|
||||
if camera == null or _state == null:
|
||||
_restore_all_occluders()
|
||||
return
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ Single place for dependents (e.g. **E6.M2**): what is on `CameraState` vs what s
|
|||
|
||||
### `CameraState` fields (`client/scripts/camera_state.gd`)
|
||||
|
||||
Refreshed **every `_process`** on `IsometricFollowCamera` after pose is final. Same object each frame (rig-owned).
|
||||
Refreshed **every `_physics_process`** tick on `IsometricFollowCamera` after the follow target has moved (`process_physics_priority` after `Player`). Same object each tick (rig-owned).
|
||||
|
||||
| Field | Meaning |
|
||||
|--------|---------|
|
||||
|
|
@ -87,13 +87,13 @@ See Epic 1 **Slice 2 — Locked isometric camera**: follow-center, zoom bands, o
|
|||
- **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.
|
||||
- **Occlusion:** **`OcclusionPolicy`** resource (`client/scripts/occlusion_policy.gd`); default **`client/resources/isometric_occlusion_policy.tres`** on main rig. **Technique:** each **`_physics_process`** tick, 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.
|
||||
|
||||
- **Click-through input (NEON-30):** `scripts/ground_pick.gd` unconditionally skips bodies in the `"occluder"` group during click-to-move ground-pick raycasts. When a cast hits an occluder the ray origin advances `OCCLUDER_PICK_THROUGH` meters past the hit point and continues, independent of whether `OcclusionPolicy` is currently fading that body. This keeps the `"occluder"` group as the sole tagging convention shared between the camera occlusion system and ground-pick input — no dedicated collision layer is added for this case.
|
||||
|
||||
- **Integration hardening (NEON-28):** [Consumer contract](#consumer-contract-neon-28) + `camera_state.gd` header; **E6.M2** adjacency notes ([E6_M2_ConsentAndRiskUxSignals.md](E6_M2_ConsentAndRiskUxSignals.md)); invalid / freed occluder bodies are **purged** from `_occluder_overrides` before each occlusion ray pass and before full restore (`occluder_override_key_is_valid`, tests in `isometric_follow_camera_test.gd`).
|
||||
- **Integration hardening (NEON-28):** [Consumer contract](#consumer-contract-neon-28) + `camera_state.gd` header; **E6.M2** adjacency notes ([E6_M2_ConsentAndRiskUxSignals.md](E6_M2_ConsentAndRiskUxSignals.md)); invalid / freed occluder bodies are **purged** from `_occluder_overrides` before each occlusion ray pass and before full restore (`occluder_override_key_is_valid`, tests in `isometric_follow_camera_test.gd`). **Follow / state / occlusion** run in **`_physics_process`** with **`process_physics_priority = 1`** so the rig updates **after** `Player` (default **0**) and tracks **`move_and_slide`** without display-vs-physics jitter.
|
||||
|
||||
## Jira backlog
|
||||
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ Jira acceptance criteria (verbatim intent):
|
|||
| **`CameraState` policy flags** | **Doc-first** | Add fields only if Jira/E6.M2 contract text requires a snapshot; avoid speculative API. |
|
||||
| **Invalid occluder keys** | **Purge + restore** | Prevents stuck materials and leaks when occluders despawn. |
|
||||
| **Epic Slice 2 wording** | **Align with rotation policy** | Satisfies Jira AC on “yaw not exposed / default in UX” vs misleading “no rotation.” |
|
||||
| **Rig tick (jitter follow-up)** | **`_physics_process` + `process_physics_priority = 1`** | `Player` default **0** runs before **1**; camera reads post–`move_and_slide`. Avoids `_process` vs 120 Hz physics jitter (camera was above `Player` in scene order). |
|
||||
|
||||
## Files to add
|
||||
|
||||
|
|
@ -114,3 +115,4 @@ Jira acceptance criteria (verbatim intent):
|
|||
|
||||
- `camera_state.gd` consumer contract header; `isometric_follow_camera.gd` invalid occluder purge + `occluder_override_key_is_valid`; tests in `isometric_follow_camera_test.gd`.
|
||||
- Docs: `E1_M2_IsometricCameraController.md` (Ready, consumer contract, NEON-28 snapshot), `E6_M2_ConsentAndRiskUxSignals.md` (E1.M2 adjacency), `module_dependency_register.md`, `documentation_and_implementation_alignment.md`, `epic_01_core_player_runtime.md` Slice 2.
|
||||
- Follow-up: rig **`_physics_process`** + priority (see **Decisions**).
|
||||
|
|
|
|||
Loading…
Reference in New Issue