Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 102 additions & 1 deletion godot/src/logic/player/player.gd
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,18 @@ const GLIDE_COOLDOWN := 0.6
const GLIDE_OPENING_TIME := 0.5
const GLIDE_CLOSING_TIME := 0.15

# Character mass for scene-driven impulses/forces (matches Unity for tuning parity).
# Δv = impulse / CHARACTER_MASS, a = force / CHARACTER_MASS.
const CHARACTER_MASS := 1.0
# Viscous drag on external_velocity each tick.
# Total damping = ENV (always) + GROUND_FRICTION (when grounded).
const EXT_ENV_DRAG := 1.5
const EXT_GROUND_FRICTION := 4.0
# Hard ceiling on |external_velocity|.
const MAX_EXTERNAL_VELOCITY := 50.0
# Snap external_velocity to zero below this squared magnitude.
const EXT_VELOCITY_EPSILON_SQR := 0.0001

# Glide FSM values — mirror DclAvatar.glide_state and rfc4.Movement.GlideState.
const GLIDE_CLOSED := 0
const GLIDE_OPENING := 1
Expand Down Expand Up @@ -61,6 +73,10 @@ var current_direction: Vector3 = Vector3()
var time_falling := 0.0
var current_profile_version: int = -1

# Persistent velocity accumulating scene-driven impulses (full XYZ) and forces
# (XZ only — force.y feeds gravity). Decays via drag each tick.
var external_velocity: Vector3 = Vector3.ZERO

# Private variables (prefixed with _)
var _hard_landing_timer: float = 0.0
var _locomotion_settings: DclLocomotionSettings = null
Expand Down Expand Up @@ -247,6 +263,11 @@ func clamp_camera_rotation():


func _physics_process(dt: float) -> void:
# Sample scene-driven physics before gravity — force.y feeds effective_gravity below.
var scene_external_force: Vector3 = Global.scene_runner.get_active_external_force()
var scene_pending_impulses: PackedVector3Array = Global.scene_runner.consume_pending_impulses()
var external_acceleration: Vector3 = scene_external_force / CHARACTER_MASS

# Keep the top-level avatar co-located with the player (picks up teleports,
# external position changes, and ensures look_at below uses the correct origin)
avatar.global_position = global_position
Expand Down Expand Up @@ -350,7 +371,9 @@ func _physics_process(dt: float) -> void:
var free_flight: bool = glide_state == GLIDE_CLOSED or glide_state == GLIDE_CLOSING
avatar.rise = velocity.y > .3 and free_flight
avatar.fall = velocity.y < -.3 && !in_grace_time and free_flight
velocity.y -= gravity * dt
# Scene force.y reduces effective gravity, so an upward wind cancels
# fall instead of stacking on velocity.y.
velocity.y -= (gravity - external_acceleration.y) * dt

# Air-jump: 0.2s hover then impulse (matches Unity ApplyJump two-step).
if (
Expand Down Expand Up @@ -487,11 +510,89 @@ func _physics_process(dt: float) -> void:
avatar.glide_state = glide_state
avatar.is_grounded = on_floor

_apply_scene_physics(dt, external_acceleration, scene_pending_impulses, on_floor)

# Re-derive rise/fall from the combined vertical velocity: the lift from
# external_velocity isn't visible in velocity.y alone (we undo the Y mix
# below, so velocity.y reads near-zero mid-bounce).
if external_velocity.length_squared() > 0.01:
var combined_vy: float = velocity.y + external_velocity.y
var free_flight: bool = glide_state == GLIDE_CLOSED or glide_state == GLIDE_CLOSING
if free_flight:
if combined_vy > 0.3:
avatar.rise = true
avatar.fall = false
avatar.land = false
avatar.is_grounded = false
elif combined_vy < -0.3:
avatar.fall = true
avatar.rise = false

# Snapshot locomotion XZ so we can restore them after the move. Otherwise
# `move_toward` on the next no-input tick would decel from velocity-with-
# external stacked, and the external add would compound indefinitely.
var locomotion_x: float = velocity.x
var locomotion_z: float = velocity.z
var external_y_for_move: float = external_velocity.y
velocity.x += external_velocity.x
velocity.y += external_y_for_move
velocity.z += external_velocity.z

last_position = global_position
move_and_slide()
position.y = max(position.y, 0)
avatar.global_position = global_position

# Restore locomotion-only XZ; external_velocity carries its own state and is
# re-added next frame.
velocity.x = locomotion_x
velocity.z = locomotion_z

# Restore velocity.y unless a floor/ceiling collision already zeroed it.
if not is_on_floor() and not is_on_ceiling():
velocity.y -= external_y_for_move


# Fold scene-driven force/impulses into external_velocity, then drag and clamp.
# scene_runner only emits these for the current parcel scene.
func _apply_scene_physics(
dt: float, external_acceleration: Vector3, impulses: PackedVector3Array, on_floor: bool
) -> void:
# Force XZ accumulates; force Y was already folded into effective_gravity.
external_velocity.x += external_acceleration.x * dt
external_velocity.z += external_acceleration.z * dt

var got_upward_impulse: bool = false
for impulse in impulses:
var delta_v: Vector3 = impulse / CHARACTER_MASS
if delta_v.y > 0.0:
got_upward_impulse = true
# Upward impulse on a falling player clears gravity so jump pads launch.
if velocity.y < 0.0:
velocity.y = 0.0
external_velocity += delta_v

# Treat an upward impulse as ungrounding for the rest of this tick, or the
# grounded drag + Y-zero below would cancel a jump-pad launch.
var effective_on_floor: bool = on_floor and not got_upward_impulse

# Viscous drag: v *= (1 - damping * dt).
var damping := EXT_ENV_DRAG
if effective_on_floor:
damping += EXT_GROUND_FRICTION
external_velocity *= maxf(0.0, 1.0 - damping * dt)

# Zero Y when grounded so landings don't micro-bounce.
if effective_on_floor:
external_velocity.y = 0.0

# Snap tiny magnitudes to zero, clamp large ones to MAX_EXTERNAL_VELOCITY.
var sqr_mag: float = external_velocity.length_squared()
if sqr_mag < EXT_VELOCITY_EPSILON_SQR:
external_velocity = Vector3.ZERO
elif sqr_mag > MAX_EXTERNAL_VELOCITY * MAX_EXTERNAL_VELOCITY:
external_velocity = external_velocity.normalized() * MAX_EXTERNAL_VELOCITY


func avatar_look_at(target_position: Vector3):
var global_pos := get_global_position()
Expand Down
1 change: 1 addition & 0 deletions lib/src/scene_runner/components/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ pub mod material;
pub mod mesh_collider;
pub mod mesh_renderer;
pub mod nft_shape;
pub mod physics_combined;
pub mod pointer_events;
pub mod raycast;
pub mod realm_info;
Expand Down
105 changes: 105 additions & 0 deletions lib/src/scene_runner/components/physics_combined.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
use godot::builtin::Vector3;

use crate::{
dcl::{
components::{proto_components, SceneComponentId, SceneEntityId},
crdt::{
last_write_wins::LastWriteWinsComponentOperation, SceneCrdtState,
SceneCrdtStateProtoComponents,
},
SceneId,
},
scene_runner::scene::Scene,
};

/// Convert a DCL scene-space vector to Godot world-space (Z is negated).
fn scene_vec_to_godot(v: &proto_components::common::Vector3) -> Vector3 {
Vector3::new(v.x, v.y, -v.z)
}

/// Update the per-scene cached force from the player's `PBPhysicsCombinedForce`.
/// Non-current scenes are cleared so wind zones stop the moment the player leaves.
pub fn update_physics_combined_force(
scene: &mut Scene,
crdt_state: &mut SceneCrdtState,
current_parcel_scene_id: &SceneId,
) {
let is_current_parcel_scene = scene.scene_id == *current_parcel_scene_id;

if !is_current_parcel_scene {
if scene.active_external_force != Vector3::ZERO {
tracing::debug!(
"physics_combined: force cleared on scene {:?} (no longer current)",
scene.scene_id
);
}
scene.active_external_force = Vector3::ZERO;
return;
}

let force_component = SceneCrdtStateProtoComponents::get_physics_combined_force(crdt_state);
let new_force = force_component
.get(&SceneEntityId::PLAYER)
.and_then(|entry| entry.value.as_ref())
.and_then(|pb| pb.vector.as_ref())
.map(scene_vec_to_godot)
.unwrap_or(Vector3::ZERO);

if new_force != scene.active_external_force {
tracing::debug!(
"physics_combined: force changed on scene {:?}: {:?} → {:?}",
scene.scene_id,
scene.active_external_force,
new_force
);
}
scene.active_external_force = new_force;
}

/// Queue one impulse per CRDT write of the player's `PBPhysicsCombinedImpulse`.
///
/// The proto says `event_id` is the renderer's dedup key, but production scenes
/// (Genesis Plaza, etc.) ship with `eventId: 0` hardcoded and expect each
/// `createOrReplace` call to fire the impulse. So we gate on the CRDT dirty
/// signal alone — one write, one fire — and ignore `event_id`.
pub fn update_physics_combined_impulse(
scene: &mut Scene,
crdt_state: &mut SceneCrdtState,
current_parcel_scene_id: &SceneId,
) {
let dirty_lww_components = &scene.current_dirty.lww_components;
let is_current_parcel_scene = scene.scene_id == *current_parcel_scene_id;

if !is_current_parcel_scene {
return;
}

let is_dirty = dirty_lww_components
.get(&SceneComponentId::PHYSICS_COMBINED_IMPULSE)
.is_some_and(|entities| entities.contains(&SceneEntityId::PLAYER));

if !is_dirty {
return;
}

let impulse_component = SceneCrdtStateProtoComponents::get_physics_combined_impulse(crdt_state);
let Some(entry) = impulse_component.get(&SceneEntityId::PLAYER) else {
return;
};
let Some(pb) = entry.value.as_ref() else {
return;
};
let Some(vector) = pb.vector.as_ref() else {
return;
};

let godot_vec = scene_vec_to_godot(vector);
tracing::debug!(
"physics_combined: queue impulse event_id={} godot=({:.3},{:.3},{:.3})",
pb.event_id,
godot_vec.x,
godot_vec.y,
godot_vec.z,
);
scene.pending_impulses.push(godot_vec);
}
23 changes: 21 additions & 2 deletions lib/src/scene_runner/scene.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ use std::{
time::Instant,
};

use godot::{obj::NewAlloc, prelude::Gd, prelude::ToGodot};
use godot::{
builtin::Vector3,
obj::NewAlloc,
prelude::{Gd, ToGodot},
};

use crate::{
content::content_mapping::{ContentMappingAndUrl, ContentMappingAndUrlRef},
Expand Down Expand Up @@ -117,6 +121,8 @@ pub enum SceneUpdateState {
AudioStream,
AvatarModifierArea,
AvatarLocomotionSettings,
PhysicsCombinedForce,
PhysicsCombinedImpulse,
CameraModeArea,
InputModifier,
SkyboxTime,
Expand Down Expand Up @@ -155,7 +161,9 @@ impl SceneUpdateState {
Self::VideoPlayer => Self::AudioStream,
Self::AudioStream => Self::AvatarModifierArea,
Self::AvatarModifierArea => Self::AvatarLocomotionSettings,
Self::AvatarLocomotionSettings => Self::CameraModeArea,
Self::AvatarLocomotionSettings => Self::PhysicsCombinedForce,
Self::PhysicsCombinedForce => Self::PhysicsCombinedImpulse,
Self::PhysicsCombinedImpulse => Self::CameraModeArea,
Self::CameraModeArea => Self::InputModifier,
Self::InputModifier => Self::SkyboxTime,
Self::SkyboxTime => Self::TriggerArea,
Expand Down Expand Up @@ -273,6 +281,13 @@ pub struct Scene {

pub locomotion_settings: Gd<DclLocomotionSettings>,

/// Continuous force from this scene's `PBPhysicsCombinedForce` on the player,
/// in Godot world axes. Only sampled when this is the current parcel scene.
pub active_external_force: Vector3,
/// One-shot impulses from this scene's `PBPhysicsCombinedImpulse`, drained
/// each physics tick by the player controller.
pub pending_impulses: Vec<Vector3>,

pub paused: bool,

// Deno/V8 memory statistics for this scene
Expand Down Expand Up @@ -392,6 +407,8 @@ impl Scene {
paused: false,
virtual_camera: Default::default(),
locomotion_settings: Default::default(),
active_external_force: Vector3::ZERO,
pending_impulses: Vec::new(),
deno_memory_stats: None,
stuck_frames: 0,
}
Expand Down Expand Up @@ -469,6 +486,8 @@ impl Scene {
paused: false,
virtual_camera: Default::default(),
locomotion_settings: Default::default(),
active_external_force: Vector3::ZERO,
pending_impulses: Vec::new(),
deno_memory_stats: None,
stuck_frames: 0,
}
Expand Down
25 changes: 25 additions & 0 deletions lib/src/scene_runner/scene_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1473,6 +1473,10 @@ impl SceneManager {
video_player_node.bind_mut().set_muted(true);
}

// Stop any wind/impulse the player just walked out of.
scene.active_external_force = Vector3::ZERO;
scene.pending_impulses.clear();

scene
.avatar_scene_updates
.internal_player_data
Expand Down Expand Up @@ -1579,6 +1583,27 @@ impl SceneManager {
.unwrap_or_default()
}

/// Current parcel scene's external force on the player (Godot axes), or
/// zero if no scene is driving one.
#[func]
pub fn get_active_external_force(&self) -> Vector3 {
self.scenes
.get(&self.current_parcel_scene_id)
.map(|x| x.active_external_force)
.unwrap_or(Vector3::ZERO)
}

/// Drain pending impulse vectors (Godot axes) from the current parcel
/// scene. The player controller calls this once per physics tick.
#[func]
pub fn consume_pending_impulses(&mut self) -> PackedVector3Array {
let Some(scene) = self.scenes.get_mut(&self.current_parcel_scene_id) else {
return PackedVector3Array::new();
};
let drained: Vec<Vector3> = scene.pending_impulses.drain(..).collect();
PackedVector3Array::from(drained.as_slice())
}

#[func]
pub fn is_scene_tests_finished(&self, scene_id: i32) -> bool {
let Some(scene) = self.scenes.get(&SceneId(scene_id)) else {
Expand Down
9 changes: 9 additions & 0 deletions lib/src/scene_runner/update_scene.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ use super::{
mesh_collider::update_mesh_collider,
mesh_renderer::update_mesh_renderer,
nft_shape::update_nft_shape,
physics_combined::{update_physics_combined_force, update_physics_combined_impulse},
pointer_events::update_scene_pointer_events,
raycast::update_raycasts,
realm_info::sync_realm_info,
Expand Down Expand Up @@ -367,6 +368,14 @@ pub fn _process_scene(
}
false
}
SceneUpdateState::PhysicsCombinedForce => {
update_physics_combined_force(scene, crdt_state, current_parcel_scene_id);
false
}
SceneUpdateState::PhysicsCombinedImpulse => {
update_physics_combined_impulse(scene, crdt_state, current_parcel_scene_id);
false
}
SceneUpdateState::CameraModeArea => {
update_camera_mode_area(scene, crdt_state);
false
Expand Down
Loading