#1 - ClientWorldController refactoring
This commit is contained in:
@@ -7,6 +7,7 @@ file(GLOB FILES
|
||||
src/app/*.cpp
|
||||
src/network/*.cpp
|
||||
src/world/*.cpp
|
||||
src/world/controllers/*.cpp
|
||||
src/io/*.cpp
|
||||
src/draw/*.cpp
|
||||
src/draw/RenderPasses/*.cpp
|
||||
@@ -39,8 +40,8 @@ target_link_libraries(${PROJECT_NAME}
|
||||
Jolt
|
||||
Tracy::TracyClient
|
||||
TracyClient
|
||||
pqxx
|
||||
pq
|
||||
# pqxx
|
||||
# pq
|
||||
)
|
||||
|
||||
target_include_directories(${PROJECT_NAME}
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
#include "debug/tools/EntityManagerGui.hpp"
|
||||
#include "debug/tools/NetworkStatsGui.hpp"
|
||||
#include "network/ServerConnection.hpp"
|
||||
#include "world/ClientWorldController.hpp"
|
||||
#include "world/controllers/ClientWorldController.hpp"
|
||||
|
||||
namespace tw::app {
|
||||
|
||||
/**
|
||||
* The game world state. Owns the connection, world controller, and debug GUIs.
|
||||
* The game world state.
|
||||
* Responsible for updating the game simulation and rendering debug information.
|
||||
*/
|
||||
class GameState {
|
||||
|
||||
@@ -1,92 +1,126 @@
|
||||
#pragma once
|
||||
|
||||
#include <imgui.h>
|
||||
#include <implot.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <format>
|
||||
#include <ratio>
|
||||
#include <cstddef>
|
||||
#include <vector>
|
||||
|
||||
#include "debug/ComponentGui.hpp"
|
||||
#include <implot.h>
|
||||
#include <implot_internal.h>
|
||||
#include "network/EntityInterpolation.hpp"
|
||||
#include "world/Transform.hpp"
|
||||
|
||||
|
||||
template<>
|
||||
class tw::dbg::ComponentGui<tw::net::EntityPositionInterpolation> {
|
||||
class tw::dbg::ComponentGui<tw::Transform> {
|
||||
private:
|
||||
using Clock = std::chrono::high_resolution_clock;
|
||||
|
||||
/** Ten seconds of frames at sixty a second. */
|
||||
static constexpr size_t CAPACITY = 600;
|
||||
|
||||
/** How much of the trace the plot shows. */
|
||||
static constexpr float WINDOW_IN_SECONDS = 10.0f;
|
||||
|
||||
inline static const Transform* m_subject = nullptr;
|
||||
inline static int m_last_frame = -1;
|
||||
inline static Clock::time_point m_started_at {};
|
||||
inline static size_t m_head = 0;
|
||||
inline static size_t m_count = 0;
|
||||
inline static std::vector<float> m_times;
|
||||
inline static std::vector<float> m_positions;
|
||||
|
||||
void restart(const Transform* instance) {
|
||||
m_subject = instance;
|
||||
m_last_frame = -1;
|
||||
m_started_at = Clock::now();
|
||||
m_head = 0;
|
||||
m_count = 0;
|
||||
|
||||
m_times.resize(CAPACITY);
|
||||
m_positions.resize(CAPACITY);
|
||||
}
|
||||
|
||||
/** Keeps one sample per frame, so drawing twice does not double up. */
|
||||
void sample(float time, float position) {
|
||||
const int frame = ImGui::GetFrameCount();
|
||||
if(m_last_frame == frame) {
|
||||
return;
|
||||
}
|
||||
m_last_frame = frame;
|
||||
|
||||
m_times[m_head] = time;
|
||||
m_positions[m_head] = position;
|
||||
|
||||
m_head = (m_head + 1) % CAPACITY;
|
||||
m_count = std::min(m_count + 1, CAPACITY);
|
||||
}
|
||||
|
||||
public:
|
||||
void draw(net::EntityPositionInterpolation* instance) {
|
||||
ImGui::SeparatorText("Entity Interpolation");
|
||||
void draw(Transform* instance) {
|
||||
ImGui::SeparatorText("Drawn Position");
|
||||
|
||||
bool m_is_scrolling = true;
|
||||
static float m_metric_history = 10.0f;
|
||||
ImGui::Checkbox("Is Scrolling", &m_is_scrolling);
|
||||
if(m_is_scrolling) {
|
||||
ImGui::SliderFloat("History", &m_metric_history,1,30,"%.1f s");
|
||||
static int axis = 0;
|
||||
ImGui::Combo("Axis", &axis, "X\0Y\0Z\0");
|
||||
|
||||
if(m_subject != instance) {
|
||||
restart(instance);
|
||||
}
|
||||
|
||||
static int32_t time_buffer_len = 1000;
|
||||
ImGui::SliderInt("Buffer length (ms)", &time_buffer_len, 0, 1000);
|
||||
const float time = std::chrono::duration<float>(Clock::now() - m_started_at).count();
|
||||
const float position = instance->position()[axis];
|
||||
|
||||
auto now = std::chrono::high_resolution_clock::now() - std::chrono::milliseconds(time_buffer_len);
|
||||
auto [from, to, mix] = instance->get_values_around(now);
|
||||
auto mixed_value = glm::mix(from, to, mix);
|
||||
sample(time, position);
|
||||
|
||||
ImGui::Text("Mixed value: (%f, %f)", mixed_value.x, mixed_value.y);
|
||||
ImGui::Text("%.3f", position);
|
||||
|
||||
if(ImPlot::BeginPlot("Interpolation")) {
|
||||
auto plot_x_from = instance->times().begin()->time_since_epoch().count();
|
||||
std::vector<float> times(instance->times().max_size());
|
||||
for(auto it = instance->times().begin(); it != instance->times().end(); ++it) {
|
||||
times.push_back(it->time_since_epoch().count() - plot_x_from);
|
||||
}
|
||||
if(ImPlot::BeginPlot("Position", ImVec2(-1.0f, 180.0f))) {
|
||||
ImPlot::SetupAxes("seconds", "position", ImPlotAxisFlags_None, ImPlotAxisFlags_AutoFit);
|
||||
ImPlot::SetupAxisLimits(ImAxis_X1,
|
||||
time - WINDOW_IN_SECONDS, time, ImGuiCond_Always);
|
||||
|
||||
float interpolated_time = now.time_since_epoch().count() - plot_x_from;
|
||||
std::vector<float> values(instance->values().max_size());
|
||||
for(auto it = instance->values().begin(); it != instance->values().end(); ++it) {
|
||||
values.push_back(it->x);
|
||||
}
|
||||
values.push_back(mixed_value.x);
|
||||
// The trace wraps around, so it is read from wherever the oldest
|
||||
// sample ended up.
|
||||
const int offset = m_count == CAPACITY ? (int)m_head : 0;
|
||||
ImPlot::PlotLine("Drawn", m_times.data(), m_positions.data(),
|
||||
(int)m_count, ImPlotLineFlags_None, offset);
|
||||
|
||||
ImPlot::PlotScatter("Server", times.data(), values.data(), times.size() - 1);
|
||||
ImPlot::PushStyleVar(ImPlotStyleVar_FillAlpha, 0.25f);
|
||||
ImPlot::SetNextMarkerStyle(ImPlotMarker_Square, 6, ImPlot::GetColormapColor(1), IMPLOT_AUTO, ImPlot::GetColormapColor(1));
|
||||
ImPlot::PlotScatter("Client", &interpolated_time, &mixed_value.x, 1);
|
||||
ImPlot::PopStyleVar();
|
||||
ImPlot::EndPlot();
|
||||
}
|
||||
|
||||
if(ImGui::BeginTable("entityInterpolation", 2)) {
|
||||
for(int i = 0; i < instance->values().size(); i++) {
|
||||
ImGui::TableNextRow();
|
||||
|
||||
ImGui::TableNextColumn();
|
||||
auto value = instance->values()[i];
|
||||
auto now = std::chrono::high_resolution_clock::now() - std::chrono::milliseconds(time_buffer_len);
|
||||
if(instance->times()[i] > now && (i == instance->values().size() - 1 || instance->times()[i + 1] <= now)) {
|
||||
ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg1, ImGui::GetColorU32(ImVec4(0.7f, 0.3f, 0.3f, 0.65f)));
|
||||
ImGui::Text("%f %f %f", mixed_value.x, mixed_value.y, mixed_value.z);
|
||||
ImGui::TableNextColumn();
|
||||
ImGui::TableNextRow();
|
||||
ImGui::TableNextColumn();
|
||||
ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg1, ImGui::GetColorU32(ImVec4(0.0f, 0.0f, 0.0f, 0.0f)));
|
||||
}
|
||||
|
||||
ImGui::Text("%f %f %f", value.x, value.y, value.z);
|
||||
|
||||
auto point = instance->times()[i].time_since_epoch();
|
||||
auto hours = std::chrono::duration_cast<std::chrono::hours>(point);
|
||||
point -= hours;
|
||||
hours %= 24;
|
||||
auto minutes = std::chrono::duration_cast<std::chrono::minutes>(point);
|
||||
point -= minutes;
|
||||
auto seconds = std::chrono::duration_cast<std::chrono::seconds>(point);
|
||||
point -= seconds;
|
||||
auto milliseconds = std::chrono::duration_cast<std::chrono::milliseconds>(point);
|
||||
|
||||
ImGui::TableNextColumn();
|
||||
auto fmt = std::format("{}:{}:{}.{}", hours, minutes, seconds, milliseconds);
|
||||
ImGui::Text(fmt.c_str());
|
||||
}
|
||||
ImGui::EndTable();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Says whether the position above stands still because nothing new arrives for
|
||||
* the entity, or because what arrives is the same position over and over.
|
||||
*/
|
||||
template<>
|
||||
class tw::dbg::ComponentGui<tw::net::EntityPositionInterpolation> {
|
||||
private:
|
||||
using Clock = std::chrono::high_resolution_clock;
|
||||
|
||||
static int64_t millis_since(Clock::time_point point) {
|
||||
return std::chrono::duration_cast<std::chrono::milliseconds>(Clock::now() - point).count();
|
||||
}
|
||||
|
||||
public:
|
||||
void draw(net::EntityPositionInterpolation* instance) {
|
||||
ImGui::SeparatorText("Received Positions");
|
||||
|
||||
const size_t newest = instance->values().size() - 1;
|
||||
|
||||
ImGui::Text("Newest %.3f %.3f %.3f, %ld ms ago",
|
||||
instance->values()[newest].x,
|
||||
instance->values()[newest].y,
|
||||
instance->values()[newest].z,
|
||||
millis_since(instance->times()[newest]));
|
||||
|
||||
ImGui::Text("Oldest %.3f %.3f %.3f, %ld ms ago",
|
||||
instance->values()[0].x,
|
||||
instance->values()[0].y,
|
||||
instance->values()[0].z,
|
||||
millis_since(instance->times()[0]));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -50,7 +50,8 @@ void EntityManagerGui::draw_entity_components() {
|
||||
ImGui::Text("Vertex offset: %i", mesh->vertex_offset());
|
||||
}
|
||||
|
||||
draw_debug_components<tw::CharacterBody, tw::CharacterController, tw::net::EntityPositionInterpolation>();
|
||||
draw_debug_components<tw::Transform, tw::net::EntityPositionInterpolation,
|
||||
tw::CharacterBody, tw::CharacterController>();
|
||||
|
||||
// auto character = m_world->registry().try_get<tw::CharacterBody>(m_selected);
|
||||
// if(character != nullptr) {
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
|
||||
/**
|
||||
* Writes entity derivation message
|
||||
*/
|
||||
template<typename TMesg>
|
||||
class EntityDerivationWriter {
|
||||
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
#pragma once
|
||||
|
||||
#include "entt/entt.hpp"
|
||||
#include <cstdint>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
|
||||
namespace tw::net {
|
||||
|
||||
class EntityIdMap {
|
||||
public:
|
||||
const bool is_server_entity_registered(uint32_t server_id) const {
|
||||
return m_mappings.find(server_id) != m_mappings.end();
|
||||
}
|
||||
|
||||
const std::optional<entt::entity> get_local(uint32_t server_id) const {
|
||||
if(is_server_entity_registered(server_id)) {
|
||||
return m_mappings.find(server_id)->second;
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
void register_server_to_local(uint32_t server_id, entt::entity local) {
|
||||
m_mappings[server_id] = local;
|
||||
}
|
||||
|
||||
private:
|
||||
std::map<uint32_t, entt::entity> m_mappings;
|
||||
};
|
||||
|
||||
}
|
||||
@@ -9,19 +9,16 @@
|
||||
namespace tw::net {
|
||||
|
||||
EntityPositionInterpolator::EntityPositionInterpolator(
|
||||
entt::registry* registry, entt::entity player_entity, size_t bufferingIntervalInMillis
|
||||
entt::registry* registry, size_t bufferingIntervalInMillis
|
||||
) : m_registry(registry),
|
||||
m_player_entity(player_entity),
|
||||
m_bufferingIntervalInMillis(bufferingIntervalInMillis)
|
||||
{
|
||||
|
||||
}
|
||||
{ }
|
||||
|
||||
void EntityPositionInterpolator::register_entity(entt::entity entity) {
|
||||
m_registry->emplace<EntityPositionInterpolation>(entity, glm::vec3());
|
||||
}
|
||||
|
||||
void EntityPositionInterpolator::add_position_for_entity(entt::entity entity, glm::vec3 position) {
|
||||
void EntityPositionInterpolator::set_position(Clock::time_point time_point, entt::entity entity, glm::vec3 position) {
|
||||
auto* interpolation = m_registry->try_get<EntityPositionInterpolation>(entity);
|
||||
if(interpolation == nullptr) {
|
||||
spdlog::warn("Attempt to add position for non-registered entity {}", (uint32_t)entity);
|
||||
@@ -31,7 +28,7 @@ void EntityPositionInterpolator::add_position_for_entity(entt::entity entity, gl
|
||||
interpolation->push(Clock::now(), position);
|
||||
}
|
||||
|
||||
glm::vec3 EntityPositionInterpolator::get_position_for_entity(entt::entity entity) {
|
||||
glm::vec3 EntityPositionInterpolator::get_position(Clock::time_point time_point, entt::entity entity) {
|
||||
auto* interpolation = m_registry->try_get<EntityPositionInterpolation>(entity);
|
||||
if(interpolation == nullptr) {
|
||||
spdlog::warn("Attempt to get position for non-registered entity {}", (uint32_t)entity);
|
||||
@@ -44,12 +41,10 @@ glm::vec3 EntityPositionInterpolator::get_position_for_entity(entt::entity entit
|
||||
return glm::mix(from, to, value);
|
||||
}
|
||||
|
||||
void EntityPositionInterpolator::update() {
|
||||
void EntityPositionInterpolator::interpolate_smoothed_entities(Clock::time_point time_point) {
|
||||
m_registry->view<EntityPositionInterpolation, Transform>()
|
||||
.each([&](const auto entity, const EntityPositionInterpolation& interpolation, Transform& ts) {
|
||||
auto now = Clock::now() - std::chrono::milliseconds(m_bufferingIntervalInMillis);
|
||||
|
||||
auto [from, to, value] = interpolation.get_values_around(now);
|
||||
auto [from, to, value] = interpolation.get_values_around(time_point);
|
||||
ts.set_position(glm::mix(from, to, value));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#pragma once
|
||||
|
||||
#include <chrono>
|
||||
#include <entt/entt.hpp>
|
||||
@@ -12,23 +13,20 @@ class EntityPositionInterpolator {
|
||||
|
||||
entt::registry* m_registry;
|
||||
|
||||
entt::entity m_player_entity;
|
||||
|
||||
size_t m_bufferingIntervalInMillis;
|
||||
|
||||
glm::vec3 get_position(Clock::time_point time_point, entt::entity entity);
|
||||
|
||||
public:
|
||||
EntityPositionInterpolator(
|
||||
entt::registry* registry,
|
||||
entt::entity player_entity,
|
||||
size_t bufferingIntervalInMillis);
|
||||
|
||||
void register_entity(entt::entity entity);
|
||||
|
||||
void add_position_for_entity(entt::entity entity, glm::vec3 position);
|
||||
void set_position(Clock::time_point time_point, entt::entity entity, glm::vec3 position);
|
||||
|
||||
glm::vec3 get_position_for_entity(entt::entity entity);
|
||||
|
||||
void update();
|
||||
void interpolate_smoothed_entities(Clock::time_point time_point);
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
+10
-8
@@ -1,4 +1,4 @@
|
||||
#include "PlayerReconciler.hpp"
|
||||
#include "PlayerRollback.hpp"
|
||||
|
||||
#include "world/JoltPhysicsWorld.hpp"
|
||||
#include "world/CharacterBody.hpp"
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
namespace tw::net {
|
||||
|
||||
PlayerReconciler::PlayerReconciler(JoltPhysicsWorld* physics)
|
||||
PlayerRollback::PlayerRollback(JoltPhysicsWorld* physics)
|
||||
: m_physics(physics), m_last_reconciled_ack(0), m_rollback_count(0),
|
||||
m_last_correction_distance(0.0f), m_last_replayed_frames(0), m_last_ack_frame(0)
|
||||
{
|
||||
@@ -21,21 +21,21 @@ PlayerReconciler::PlayerReconciler(JoltPhysicsWorld* physics)
|
||||
}
|
||||
}
|
||||
|
||||
void PlayerReconciler::record_input(uint32_t frame, glm::vec3 input) {
|
||||
void PlayerRollback::set_input(uint32_t frame, glm::vec3 input) {
|
||||
size_t idx = frame % RING_SIZE;
|
||||
m_records[idx].frame = frame;
|
||||
m_records[idx].valid = true;
|
||||
m_records[idx].input = input;
|
||||
}
|
||||
|
||||
void PlayerReconciler::record_prediction(uint32_t frame, glm::vec3 position) {
|
||||
void PlayerRollback::record_prediction(uint32_t frame, glm::vec3 position) {
|
||||
size_t idx = frame % RING_SIZE;
|
||||
if (m_records[idx].frame == frame && m_records[idx].valid) {
|
||||
m_records[idx].predicted_position = position;
|
||||
}
|
||||
}
|
||||
|
||||
bool PlayerReconciler::reconcile(uint32_t ack_frame, glm::vec3 authoritative_position,
|
||||
bool PlayerRollback::apply_correction(uint32_t ack_frame, glm::vec3 authoritative_position,
|
||||
entt::entity player, entt::registry* registry,
|
||||
uint32_t current_frame)
|
||||
{
|
||||
@@ -77,14 +77,16 @@ bool PlayerReconciler::reconcile(uint32_t ack_frame, glm::vec3 authoritative_pos
|
||||
return true;
|
||||
}
|
||||
|
||||
void PlayerReconciler::place_character(entt::entity player, entt::registry* registry,
|
||||
void PlayerRollback::place_character(entt::entity player, entt::registry* registry,
|
||||
glm::vec3 position, bool clear_velocity) {
|
||||
CharacterBody* body = registry->try_get<CharacterBody>(player);
|
||||
Transform* transform = registry->try_get<Transform>(player);
|
||||
if (!body) {
|
||||
return;
|
||||
}
|
||||
|
||||
body->m_character->SetPosition(JPH::RVec3(position.x, position.y, position.z));
|
||||
transform->set_position(position);
|
||||
|
||||
if (clear_velocity) {
|
||||
body->m_character->SetLinearVelocity(JPH::Vec3::sZero());
|
||||
@@ -92,7 +94,7 @@ void PlayerReconciler::place_character(entt::entity player, entt::registry* regi
|
||||
}
|
||||
}
|
||||
|
||||
void PlayerReconciler::replay_from(uint32_t from_frame, entt::entity player,
|
||||
void PlayerRollback::replay_from(uint32_t from_frame, entt::entity player,
|
||||
entt::registry* registry, uint32_t current_frame) {
|
||||
for (uint32_t f = from_frame + 1; f < current_frame; ++f) {
|
||||
m_physics->step(f, tw::JoltPhysicsWorld::FIXED_DELTA_TIME, true);
|
||||
@@ -111,7 +113,7 @@ void PlayerReconciler::replay_from(uint32_t from_frame, entt::entity player,
|
||||
}
|
||||
}
|
||||
|
||||
void PlayerReconciler::reset_at(uint32_t frame) {
|
||||
void PlayerRollback::reset_at(uint32_t frame) {
|
||||
m_last_reconciled_ack = frame;
|
||||
|
||||
for (auto& record : m_records) {
|
||||
+6
-10
@@ -11,7 +11,7 @@ class JoltPhysicsWorld;
|
||||
|
||||
namespace tw::net {
|
||||
|
||||
class PlayerReconciler {
|
||||
class PlayerRollback {
|
||||
private:
|
||||
struct Record {
|
||||
uint32_t frame;
|
||||
@@ -33,13 +33,14 @@ private:
|
||||
uint32_t m_last_ack_frame = 0;
|
||||
|
||||
public:
|
||||
PlayerReconciler(tw::JoltPhysicsWorld* physics);
|
||||
PlayerRollback(tw::JoltPhysicsWorld* physics);
|
||||
|
||||
void record_input(uint32_t frame, glm::vec3 input);
|
||||
void set_input(uint32_t frame, glm::vec3 input);
|
||||
void record_prediction(uint32_t frame, glm::vec3 position);
|
||||
|
||||
bool reconcile(uint32_t ack_frame, glm::vec3 authoritative_position,
|
||||
entt::entity player, entt::registry* registry, uint32_t current_frame);
|
||||
bool apply_correction(
|
||||
uint32_t ack_frame, glm::vec3 authoritative_position,
|
||||
entt::entity player, entt::registry* registry, uint32_t current_frame);
|
||||
|
||||
private:
|
||||
/**
|
||||
@@ -56,11 +57,6 @@ private:
|
||||
|
||||
public:
|
||||
|
||||
/**
|
||||
* Drops every stored frame and treats `frame` as already answered. Used when
|
||||
* the player is placed outright, where nothing recorded before the placement
|
||||
* describes where it now is.
|
||||
*/
|
||||
void reset_at(uint32_t frame);
|
||||
|
||||
uint64_t rollback_count() const { return m_rollback_count; }
|
||||
@@ -0,0 +1,187 @@
|
||||
#include "ReplicatorClient.hpp"
|
||||
|
||||
#include "PlayerMove.pb.h"
|
||||
#include "debug/metrics/NetworkMetrics.hpp"
|
||||
#include "network/EntityIdMap.hpp"
|
||||
#include "world/CharacterBody.hpp"
|
||||
#include "world/JoltPhysicsWorld.hpp"
|
||||
#include "world/Transform.hpp"
|
||||
#include <chrono>
|
||||
|
||||
namespace tw::net {
|
||||
|
||||
ReplicatorClient::ReplicatorClient(
|
||||
ProtobufMessages* messages,
|
||||
EntityIdMap* entity_id_map,
|
||||
dbg::NetworkMetrics *network_metrics,
|
||||
JoltPhysicsWorld *physics_world,
|
||||
World *world,
|
||||
net::ServerConnection* server_connection,
|
||||
EntityPositionInterpolator* entity_writer
|
||||
) :
|
||||
m_messages(messages),
|
||||
m_entity_id_map(entity_id_map),
|
||||
m_network_metrics(network_metrics),
|
||||
m_rollback(physics_world),
|
||||
m_world(world),
|
||||
m_server_connection(server_connection),
|
||||
m_input_send_times(INPUT_SEND_TIME_COUNT),
|
||||
m_entity_interpolator(entity_writer)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
int64_t ReplicatorClient::now_ms() {
|
||||
return std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
Clock::now().time_since_epoch()).count();
|
||||
}
|
||||
|
||||
void ReplicatorClient::set_player_entity(entt::entity entity) {
|
||||
m_player_entity = entity;
|
||||
}
|
||||
|
||||
void ReplicatorClient::measure_response_time(uint32_t current_frame_idx, uint32_t snapshot_frame_idx) {
|
||||
// Snapshots carry frame zero until the server has an input to answer, and
|
||||
// repeat the same frame whenever no newer one arrived in between.
|
||||
if(snapshot_frame_idx == 0 || snapshot_frame_idx <= m_last_measured_frame) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Anything the send times no longer cover, including a frame we never sent,
|
||||
// which underflows into a large distance.
|
||||
if(current_frame_idx - snapshot_frame_idx >= INPUT_SEND_TIME_COUNT) {
|
||||
return;
|
||||
}
|
||||
|
||||
m_last_measured_frame = snapshot_frame_idx;
|
||||
|
||||
auto sent_at = m_input_send_times[snapshot_frame_idx % INPUT_SEND_TIME_COUNT];
|
||||
m_network_metrics->record_response_time(Clock::now() - sent_at);
|
||||
}
|
||||
|
||||
void ReplicatorClient::set_input(uint32_t frame_idx, glm::vec3 input) {
|
||||
m_rollback.set_input(frame_idx, input);
|
||||
|
||||
mmo::PlayerMoveMessage player_move_message = {};
|
||||
|
||||
player_move_message.set_frame_idx(frame_idx);
|
||||
mmo::PlayerInput* player_input = new mmo::PlayerInput();
|
||||
player_input->set_x(input.x);
|
||||
player_input->set_y(input.y);
|
||||
player_input->set_z(input.z);
|
||||
|
||||
player_move_message.set_allocated_input(player_input);
|
||||
|
||||
auto send_result = m_messages->send(m_server_connection->server(), player_move_message, false);
|
||||
if(!send_result) {
|
||||
spdlog::error("Failed to send message: {}", send_result.error().message());
|
||||
}
|
||||
|
||||
m_input_send_times[frame_idx % INPUT_SEND_TIME_COUNT] = Clock::now();
|
||||
}
|
||||
|
||||
void ReplicatorClient::record_prediction(uint32_t frame_idx) {
|
||||
if(!m_player_entity.has_value()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Transform* transform = m_world->registry().try_get<Transform>(m_player_entity.value());
|
||||
if(!transform) {
|
||||
return;
|
||||
}
|
||||
|
||||
glm::vec3 position = transform->position();
|
||||
|
||||
m_rollback.record_prediction(frame_idx, position);
|
||||
}
|
||||
|
||||
void ReplicatorClient::update() {
|
||||
if(!m_player_entity.has_value()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// m_entity_interpolator.interpolate_smoothed_entities(std::chrono::high_resolution_clock::now() - std::chrono::milliseconds(100));
|
||||
|
||||
// Transform* transform = m_world->registry().try_get<Transform>(m_player_entity.value());
|
||||
// if(!transform) {
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// transform->set_position(m_render_position);
|
||||
}
|
||||
|
||||
void ReplicatorClient::snap_player_to(uint32_t frame_idx, entt::entity entity, glm::vec3 position) {
|
||||
CharacterBody* body = m_world->registry().try_get<CharacterBody>(entity);
|
||||
if(body) {
|
||||
body->m_character->SetPosition(JPH::RVec3(position.x, position.y, position.z));
|
||||
body->m_character->SetLinearVelocity(JPH::Vec3::sZero());
|
||||
body->m_desired_velocity = JPH::Vec3::sZero();
|
||||
}
|
||||
|
||||
Transform* transform = m_world->registry().try_get<Transform>(entity);
|
||||
if(transform) {
|
||||
transform->set_position(position);
|
||||
}
|
||||
|
||||
m_render_position = position;
|
||||
|
||||
// m_rollback.reset_at(frame_idx);
|
||||
}
|
||||
|
||||
void ReplicatorClient::handle_snapshot_entity(
|
||||
uint32_t current_frame_idx,
|
||||
uint32_t record_frame_idx,
|
||||
serial::EntityRecord& record
|
||||
) {
|
||||
std::optional<entt::entity> entity = m_entity_id_map->get_local(record.id);
|
||||
|
||||
if(!entity.has_value()) {
|
||||
spdlog::warn("Received position update for unknown entity {}", record.id);
|
||||
return;
|
||||
}
|
||||
|
||||
glm::vec3 p = {record.position.x, record.position.y, record.position.z};
|
||||
|
||||
if(m_player_entity.has_value() &&
|
||||
entity.value() == m_player_entity.value()
|
||||
) {
|
||||
if(!m_player_position_initialized) {
|
||||
m_player_position_initialized = true;
|
||||
// snap_player_to(current_frame_idx, entity.value(), p);
|
||||
// return;
|
||||
}
|
||||
|
||||
//snap_player_to(current_frame_idx, entity.value(), p);
|
||||
|
||||
// bool reconcile_happened = m_reconciler.reconcile(record_frame_idx, p, entity.value(), &m_world->registry(), current_frame_idx);
|
||||
//
|
||||
// if(reconcile_happened) {
|
||||
// m_network_metrics->record_rollback();
|
||||
// m_network_metrics->record_correction_distance(m_reconciler.last_correction_distance());
|
||||
// m_network_metrics->record_replayed_frames(m_reconciler.last_replayed_frames());
|
||||
// }
|
||||
|
||||
// if(record_frame_idx != 0) {
|
||||
// uint32_t ack_lag = 0;
|
||||
// if(current_frame_idx >= record_frame_idx) {
|
||||
// ack_lag = current_frame_idx - record_frame_idx;
|
||||
// }
|
||||
// m_network_metrics->record_ack_lag(ack_lag);
|
||||
// }
|
||||
}
|
||||
|
||||
m_entity_interpolator->set_position(std::chrono::high_resolution_clock::now(), entity.value(), p);
|
||||
}
|
||||
|
||||
void ReplicatorClient::handle_snapshot(uint32_t current_frame_idx, serial::WorldStateReader& reader) {
|
||||
auto header = reader.read_header();
|
||||
measure_response_time(current_frame_idx, header.frame_idx);
|
||||
|
||||
while(reader.has_entity()) {
|
||||
auto entity_r = reader.read_entity();
|
||||
|
||||
handle_snapshot_entity(current_frame_idx, header.frame_idx, entity_r);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
#pragma once
|
||||
|
||||
#include "ProtobufMessages.hpp"
|
||||
#include "WorldStateWriter.hpp"
|
||||
#include "debug/metrics/NetworkMetrics.hpp"
|
||||
#include "network/EntityIdMap.hpp"
|
||||
#include "network/PlayerRollback.hpp"
|
||||
#include "network/ServerConnection.hpp"
|
||||
#include "world/JoltPhysicsWorld.hpp"
|
||||
#include "network/EntityPositionInterpolator.hpp"
|
||||
#include "world/World.hpp"
|
||||
|
||||
namespace tw::net {
|
||||
|
||||
|
||||
/**
|
||||
* Handles replication messages from server.
|
||||
*/
|
||||
class ReplicatorClient {
|
||||
public:
|
||||
ReplicatorClient(
|
||||
ProtobufMessages* messages,
|
||||
EntityIdMap* entity_id_map,
|
||||
dbg::NetworkMetrics *network_metrics,
|
||||
JoltPhysicsWorld *physics_world,
|
||||
World *world,
|
||||
net::ServerConnection* server_connection,
|
||||
EntityPositionInterpolator* entity_writer
|
||||
);
|
||||
|
||||
/**
|
||||
* Sets player controlled entity. The behaviour for this entity will be
|
||||
* little different.
|
||||
*/
|
||||
void set_player_entity(entt::entity entity);
|
||||
|
||||
/**
|
||||
* Takes the input driving `frame_idx`, keeping it for the answer to that
|
||||
* frame and putting it on the wire.
|
||||
*/
|
||||
void set_input(uint32_t frame_idx, glm::vec3 input);
|
||||
|
||||
/**
|
||||
* Takes where the player ended up on `frame_idx`, which should represent
|
||||
* the prediction taken.
|
||||
*/
|
||||
void record_prediction(uint32_t frame_idx);
|
||||
|
||||
void handle_snapshot(uint32_t current_frame_idx, serial::WorldStateReader& reader);
|
||||
|
||||
private:
|
||||
/**
|
||||
* Records how long the answer to `frame_idx` took to arrive, ignoring
|
||||
* frames that were already measured or are too old to still have a send
|
||||
* time.
|
||||
*/
|
||||
void measure_response_time(uint32_t current_frame_idx, uint32_t snapshot_frame_idx);
|
||||
|
||||
/**
|
||||
* Places the player at an authoritative position outright, clearing the
|
||||
* predicted state that led there. Used for the first position the server
|
||||
* sends, which the local simulation has no history to reconcile against.
|
||||
*/
|
||||
void snap_player_to(uint32_t frame_idx, entt::entity entity, glm::vec3 position);
|
||||
|
||||
void handle_snapshot_entity(
|
||||
uint32_t current_frame_idx,
|
||||
uint32_t record_frame_idx,
|
||||
serial::EntityRecord& record
|
||||
);
|
||||
|
||||
void update();
|
||||
|
||||
glm::vec3 render_position() const {
|
||||
return m_render_position;
|
||||
}
|
||||
|
||||
private:
|
||||
using Clock = std::chrono::steady_clock;
|
||||
|
||||
static int64_t now_ms();
|
||||
|
||||
ProtobufMessages *m_messages;
|
||||
EntityIdMap* m_entity_id_map;
|
||||
dbg::NetworkMetrics *m_network_metrics;
|
||||
net::PlayerRollback m_rollback;
|
||||
World *m_world;
|
||||
net::ServerConnection* m_server_connection;
|
||||
|
||||
std::optional<entt::entity> m_player_entity;
|
||||
|
||||
net::EntityPositionInterpolator* m_entity_interpolator;
|
||||
|
||||
/**
|
||||
* Whether the server has placed the player at least once. Entities are
|
||||
* created before their position arrives, so the body starts at the origin
|
||||
* and has to be moved once the first position shows up.
|
||||
*/
|
||||
bool m_player_position_initialized = false;
|
||||
|
||||
glm::vec3 m_render_position{0.0f};
|
||||
|
||||
/**
|
||||
* When each input was sent, indexed by its frame. Holds the most recent
|
||||
* INPUT_SEND_TIME_COUNT frames; an answer that takes longer than that goes
|
||||
* unmeasured.
|
||||
*/
|
||||
static constexpr size_t INPUT_SEND_TIME_COUNT = 256;
|
||||
|
||||
std::vector<Clock::time_point> m_input_send_times;
|
||||
uint32_t m_last_measured_frame = 0;
|
||||
};
|
||||
|
||||
}
|
||||
@@ -11,7 +11,6 @@ ServerConnection::ServerConnection(Address address) :
|
||||
}
|
||||
|
||||
tl::expected<void, msg::MessageError> ServerConnection::start() {
|
||||
// Create the endpoint
|
||||
auto endpoint_r = msg::MessageEndpoint::create();
|
||||
if(!endpoint_r) {
|
||||
m_status = ConnectionStatus::Failed;
|
||||
@@ -21,7 +20,6 @@ tl::expected<void, msg::MessageError> ServerConnection::start() {
|
||||
|
||||
m_endpoint = std::move(endpoint_r.value());
|
||||
|
||||
// Connect to the server
|
||||
auto server_r = m_endpoint->connect(m_address.ip_string(), m_address.port());
|
||||
if(!server_r) {
|
||||
m_status = ConnectionStatus::Failed;
|
||||
@@ -44,13 +42,11 @@ void ServerConnection::update() {
|
||||
|
||||
m_endpoint->update();
|
||||
|
||||
// Check if the connection has become established
|
||||
if(m_status == ConnectionStatus::Connecting && m_server) {
|
||||
if(m_server->is_established()) {
|
||||
m_status = ConnectionStatus::Connected;
|
||||
spdlog::info("Connected to server at {}", m_address.to_string());
|
||||
} else {
|
||||
// Check for timeout
|
||||
auto elapsed = Clock::now() - m_started_at;
|
||||
if(elapsed >= CONNECT_TIMEOUT) {
|
||||
m_status = ConnectionStatus::Failed;
|
||||
@@ -61,24 +57,5 @@ void ServerConnection::update() {
|
||||
}
|
||||
}
|
||||
|
||||
ConnectionStatus ServerConnection::status() const {
|
||||
return m_status;
|
||||
}
|
||||
|
||||
const std::string& ServerConnection::error() const {
|
||||
return m_error;
|
||||
}
|
||||
|
||||
const Address& ServerConnection::address() const {
|
||||
return m_address;
|
||||
}
|
||||
|
||||
msg::MessageEndpoint* ServerConnection::endpoint() const {
|
||||
return m_endpoint.get();
|
||||
}
|
||||
|
||||
msg::MessageConnection* ServerConnection::server() const {
|
||||
return m_server;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -20,9 +20,6 @@ enum class ConnectionStatus { Idle, Connecting, Connected, Failed };
|
||||
|
||||
/**
|
||||
* Encapsulates a connection to a game server.
|
||||
*
|
||||
* Owns the endpoint and connection, managing the state of the connection
|
||||
* attempt and providing non-blocking access to send/receive.
|
||||
*/
|
||||
class ServerConnection {
|
||||
using Clock = std::chrono::steady_clock;
|
||||
@@ -37,28 +34,21 @@ class ServerConnection {
|
||||
static constexpr std::chrono::seconds CONNECT_TIMEOUT{5};
|
||||
|
||||
public:
|
||||
/**
|
||||
* Constructs a connection object for the given address, without starting I/O.
|
||||
*/
|
||||
explicit ServerConnection(Address address);
|
||||
|
||||
/**
|
||||
* Starts the connection process by creating an endpoint and connecting to
|
||||
* the server. Returns an error if the endpoint cannot be created.
|
||||
*/
|
||||
tl::expected<void, msg::MessageError> start();
|
||||
|
||||
/**
|
||||
* Updates the connection state: pumps the endpoint, and checks for timeout
|
||||
* or successful connection. Must be called regularly.
|
||||
*/
|
||||
void update();
|
||||
|
||||
ConnectionStatus status() const;
|
||||
const std::string& error() const;
|
||||
const Address& address() const;
|
||||
msg::MessageEndpoint* endpoint() const;
|
||||
msg::MessageConnection* server() const;
|
||||
ConnectionStatus status() const { return m_status; }
|
||||
|
||||
const std::string& error() const { return m_error; }
|
||||
|
||||
const Address& address() const { return m_address; }
|
||||
|
||||
msg::MessageEndpoint* endpoint() const { return m_endpoint.get(); }
|
||||
|
||||
msg::MessageConnection* server() const { return m_server; }
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
@@ -1,447 +0,0 @@
|
||||
#include "ClientWorldController.hpp"
|
||||
|
||||
#include <glm/glm.hpp>
|
||||
#include <chrono>
|
||||
#include <spdlog/spdlog.h>
|
||||
|
||||
#include "network/ServerConnection.hpp"
|
||||
|
||||
#include "Entity.pb.h"
|
||||
#include "Login.pb.h"
|
||||
#include "WorldState.pb.h"
|
||||
#include "PlayerMove.pb.h"
|
||||
|
||||
#include "entt/entity/entity.hpp"
|
||||
#include "entt/entity/fwd.hpp"
|
||||
#include "messages/PlayerMoveMessage.hpp"
|
||||
#include "metrics/HistoryBuffer.hpp"
|
||||
#include "world/CharacterBody.hpp"
|
||||
#include "world/CharacterController.hpp"
|
||||
#include "world/JoltPhysicsWorld.hpp"
|
||||
#include "world/WorldEntity.hpp"
|
||||
#include "tw/serial/WorldStateWriter.hpp"
|
||||
#include "network/EntityInterpolation.hpp"
|
||||
|
||||
namespace tw {
|
||||
|
||||
typedef HistoryBuffer<long, glm::vec3> EntityPositionHistory;
|
||||
|
||||
entt::entity create_player_entity(World* world, JoltPhysicsWorld* physics_world, drw::WorldRenderer* renderer) {
|
||||
// entt::entity entity = world->registry().create();
|
||||
|
||||
// drw::Mesh mesh = renderer->add_mesh(drw::MeshData::cube(glm::vec3(1.0f)));
|
||||
|
||||
// world->registry()
|
||||
// .emplace<Transform>(entity,
|
||||
// Transform(glm::vec3(0.0f, 10.0f, 0.0f)));
|
||||
|
||||
// world->registry()
|
||||
// .emplace<EntityPositionHistory>(entity, 0, glm::vec3(0.0f, 10.0f, 0.0f), 1000);
|
||||
|
||||
// world->registry()
|
||||
// .emplace<tw::WorldEntity>(entity,
|
||||
// tw::WorldEntity(std::string("player"), (uint32_t)entity));
|
||||
|
||||
// world->registry()
|
||||
// .emplace<tw::drw::Mesh>(entity, mesh);
|
||||
|
||||
// world->registry()
|
||||
// .emplace<tw::CharacterController>(entity, 20.0f);
|
||||
// auto* body = &world->registry()
|
||||
// .emplace<CharacterBody>(entity, physics_world->create_character(
|
||||
// new JPH::BoxShape(JPH::Vec3Arg(0.5f, 0.5f, 0.5f)),
|
||||
// glm::vec3(0.0f, 10.0f, 0.0f)));
|
||||
|
||||
|
||||
// return entity;
|
||||
return entt::entity(0);
|
||||
}
|
||||
|
||||
|
||||
entt::entity
|
||||
ClientWorldController::create_entity(const std::string& name, glm::vec3 position) {
|
||||
const auto entity = m_world->registry().create();
|
||||
|
||||
if(!m_mesh.has_value()) {
|
||||
m_mesh = m_world_renderer->add_mesh(drw::MeshData::cube(glm::vec3(1.0f)));
|
||||
}
|
||||
spdlog::info("Creating entity {}", name);
|
||||
|
||||
m_world->registry()
|
||||
.emplace<Transform>(entity,
|
||||
Transform(position));
|
||||
|
||||
m_world->registry()
|
||||
.emplace<tw::WorldEntity>(entity,
|
||||
tw::WorldEntity(name, (uint32_t)entity));
|
||||
|
||||
m_world->registry()
|
||||
.emplace<tw::drw::Mesh>(entity,
|
||||
m_mesh.value());
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
|
||||
std::optional<entt::entity> ClientWorldController::map_from_server_entity(int id) {
|
||||
if(m_entity_mapping.contains(id)) {
|
||||
return m_entity_mapping[id];
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
void ClientWorldController::map_server_entity(int server_id, entt::entity local_id) {
|
||||
m_entity_mapping[server_id] = local_id;
|
||||
}
|
||||
|
||||
void ClientWorldController::apply_entity_positions(const mmo::EntityPosition* const* positions, size_t count) {
|
||||
for(int i = 0; i < count; i++) {
|
||||
const mmo::EntityPosition* const position = positions[i];
|
||||
std::optional<entt::entity> entity = map_from_server_entity(position->id());
|
||||
|
||||
if(!entity.has_value()) {
|
||||
spdlog::warn("Received position update for unknown entity {}", position->id());
|
||||
continue;
|
||||
}
|
||||
|
||||
glm::vec3 p = {position->x(), position->y(), position->z()};
|
||||
m_entity_interpolator.add_position_for_entity(entity.value(), p);
|
||||
|
||||
EntityPositionHistory* history = m_world->registry().try_get<EntityPositionHistory>(entity.value());
|
||||
|
||||
if(history != nullptr) {
|
||||
auto millis = std::chrono::duration_cast<std::chrono::milliseconds>(Clock::now().time_since_epoch()).count();
|
||||
history->set(millis, p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ClientWorldController::ClientWorldController(
|
||||
const io::InputManager* inputs,
|
||||
World* world,
|
||||
JoltPhysicsWorld* physics_world,
|
||||
drw::WorldRenderer* world_renderer,
|
||||
net::ServerConnection* connection,
|
||||
dbg::NetworkMetrics* network_metrics
|
||||
) :
|
||||
m_input_manager(inputs),
|
||||
m_world(world),
|
||||
m_physics_world(physics_world),
|
||||
m_world_renderer(world_renderer),
|
||||
// m_player_entity(/* create_player_entity(world, physics_world, world_renderer) */),
|
||||
m_player_controller(&world_renderer->camera(), glm::vec3()),
|
||||
m_connection(connection),
|
||||
m_messages(connection->endpoint()),
|
||||
m_network_metrics(network_metrics),
|
||||
m_tick_step(20),
|
||||
m_input_send_times(INPUT_SEND_TIME_COUNT),
|
||||
m_position_history_exporter("/home/martin/output.csv"),
|
||||
m_entity_interpolator(&m_world->registry(), (entt::entity)0, 300),
|
||||
m_reconciler(physics_world)
|
||||
{
|
||||
m_messages.set_handler<mmo::LoginResponse>(
|
||||
[this](msg::PeerId, const mmo::LoginResponse& mesg) {
|
||||
spdlog::info("Logged in!");
|
||||
});
|
||||
|
||||
m_messages.set_handler<mmo::SetControlledEntity>(
|
||||
[this](msg::PeerId, const mmo::SetControlledEntity& mesg) {
|
||||
spdlog::info("Setting controlled entity from server id {}", mesg.entity_id());
|
||||
m_controlled_server_id = mesg.entity_id();
|
||||
try_bind_player_entity();
|
||||
});
|
||||
|
||||
m_connection->endpoint()->set_handler(Message<mmo::WorldStateMessage>::value,
|
||||
[this](msg::PeerId, std::span<const std::byte> data) {
|
||||
|
||||
serial::WorldStateReader reader(data);
|
||||
|
||||
auto header = reader.read_header();
|
||||
|
||||
measure_response_time(header.frame_idx);
|
||||
|
||||
while(reader.has_spawn()) {
|
||||
auto spawn = reader.read_spawn();
|
||||
auto entity = create_entity("test", glm::vec3());
|
||||
spdlog::info("Spawning entity {}", spawn);
|
||||
|
||||
map_server_entity(spawn, entity);
|
||||
|
||||
if(m_controlled_server_id.has_value() && m_controlled_server_id.value() == spawn) {
|
||||
try_bind_player_entity();
|
||||
} else {
|
||||
m_entity_interpolator.register_entity(entity);
|
||||
}
|
||||
}
|
||||
|
||||
while(reader.has_entity()) {
|
||||
auto entity_r = reader.read_entity();
|
||||
|
||||
std::optional<entt::entity> entity = map_from_server_entity(entity_r.id);
|
||||
|
||||
if(!entity.has_value()) {
|
||||
spdlog::warn("Received position update for unknown entity {}", entity_r.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
glm::vec3 p = {entity_r.position.x, entity_r.position.y, entity_r.position.z};
|
||||
|
||||
if(m_player_entity.has_value() && entity.value() == m_player_entity.value()) {
|
||||
// The entity was created before its position was known, so the
|
||||
// body sits at the origin until the server places it. There is
|
||||
// no predicted history to reconcile against yet.
|
||||
if(!m_player_position_initialized) {
|
||||
m_player_position_initialized = true;
|
||||
snap_player_to(entity.value(), p);
|
||||
continue;
|
||||
}
|
||||
|
||||
glm::vec3 position_before = glm::vec3(0.0f);
|
||||
Transform* player_transform = m_world->registry().try_get<Transform>(entity.value());
|
||||
if(player_transform) {
|
||||
position_before = player_transform->position();
|
||||
}
|
||||
|
||||
bool reconcile_happened = m_reconciler.reconcile(header.frame_idx, p, entity.value(), &m_world->registry(), m_frame_idx);
|
||||
|
||||
if(reconcile_happened) {
|
||||
// Where the replay actually ended up, which is ahead of the
|
||||
// acked position by the frames that were re-simulated.
|
||||
glm::vec3 position_after = player_transform
|
||||
? player_transform->position()
|
||||
: p;
|
||||
glm::vec3 correction_delta = position_before - position_after;
|
||||
float correction_magnitude = glm::length(correction_delta);
|
||||
if(correction_magnitude > 5.0f) {
|
||||
correction_delta = glm::normalize(correction_delta) * 5.0f;
|
||||
}
|
||||
m_visual_error += correction_delta;
|
||||
|
||||
m_render_curr_position = position_after;
|
||||
m_render_prev_position = position_after;
|
||||
m_tick_accumulator = 0.0;
|
||||
|
||||
m_network_metrics->record_rollback();
|
||||
m_network_metrics->record_correction_distance(m_reconciler.last_correction_distance());
|
||||
m_network_metrics->record_replayed_frames(m_reconciler.last_replayed_frames());
|
||||
}
|
||||
|
||||
if(header.frame_idx != 0) {
|
||||
uint32_t ack_lag = 0;
|
||||
if(m_frame_idx >= header.frame_idx) {
|
||||
ack_lag = m_frame_idx - header.frame_idx;
|
||||
}
|
||||
m_network_metrics->record_ack_lag(ack_lag);
|
||||
}
|
||||
} else {
|
||||
m_entity_interpolator.add_position_for_entity(entity.value(), p);
|
||||
}
|
||||
|
||||
EntityPositionHistory* history = m_world->registry().try_get<EntityPositionHistory>(entity.value());
|
||||
|
||||
if(history != nullptr) {
|
||||
auto millis = std::chrono::duration_cast<std::chrono::milliseconds>(Clock::now().time_since_epoch()).count();
|
||||
history->set(millis, p);
|
||||
}
|
||||
}
|
||||
|
||||
// apply_entity_positions();
|
||||
});
|
||||
|
||||
m_messages.set_handler<mmo::EntitySpawnMessage>(
|
||||
[this](msg::PeerId, const mmo::EntitySpawnMessage& mesg) {
|
||||
auto entity = create_entity(mesg.name(), glm::vec3());
|
||||
|
||||
map_server_entity(mesg.entity_id(), entity);
|
||||
|
||||
if(m_controlled_server_id.has_value() && m_controlled_server_id.value() == mesg.entity_id()) {
|
||||
try_bind_player_entity();
|
||||
} else {
|
||||
m_entity_interpolator.register_entity(entity);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ClientWorldController::~ClientWorldController() {
|
||||
}
|
||||
|
||||
void ClientWorldController::try_bind_player_entity() {
|
||||
if(!m_controlled_server_id.has_value()) {
|
||||
return;
|
||||
}
|
||||
|
||||
auto local_entity = map_from_server_entity(m_controlled_server_id.value());
|
||||
if(!local_entity.has_value()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if(m_player_entity.has_value() && m_player_entity.value() == local_entity.value()) {
|
||||
return;
|
||||
}
|
||||
|
||||
entt::entity entity = local_entity.value();
|
||||
spdlog::info("Binding player entity");
|
||||
|
||||
m_player_entity = entity;
|
||||
|
||||
Transform* transform = m_world->registry().try_get<Transform>(entity);
|
||||
glm::vec3 position = transform ? transform->position() : glm::vec3(0.0f);
|
||||
|
||||
m_world->registry().emplace<CharacterController>(entity, 20.0f);
|
||||
m_world->registry().emplace<CharacterBody>(entity, m_physics_world->create_character(
|
||||
new JPH::BoxShape(JPH::Vec3Arg(0.5f, 0.5f, 0.5f)),
|
||||
position
|
||||
));
|
||||
|
||||
if(m_world->registry().all_of<net::EntityPositionInterpolation>(entity)) {
|
||||
m_world->registry().remove<net::EntityPositionInterpolation>(entity);
|
||||
}
|
||||
}
|
||||
|
||||
void ClientWorldController::snap_player_to(entt::entity entity, glm::vec3 position) {
|
||||
CharacterBody* body = m_world->registry().try_get<CharacterBody>(entity);
|
||||
if(body) {
|
||||
body->m_character->SetPosition(JPH::RVec3(position.x, position.y, position.z));
|
||||
body->m_character->SetLinearVelocity(JPH::Vec3::sZero());
|
||||
body->m_desired_velocity = JPH::Vec3::sZero();
|
||||
}
|
||||
|
||||
Transform* transform = m_world->registry().try_get<Transform>(entity);
|
||||
if(transform) {
|
||||
transform->set_position(position);
|
||||
}
|
||||
|
||||
m_render_prev_position = position;
|
||||
m_render_curr_position = position;
|
||||
m_tick_accumulator = 0.0;
|
||||
m_visual_error = glm::vec3(0.0f);
|
||||
|
||||
// Frames simulated before the player was placed describe a position it never
|
||||
// actually had, so answers to them must not be reconciled against.
|
||||
m_reconciler.reset_at(m_frame_idx);
|
||||
}
|
||||
|
||||
void ClientWorldController::export_entity_history() {
|
||||
}
|
||||
|
||||
void ClientWorldController::measure_response_time(uint32_t frame_idx) {
|
||||
// Snapshots carry frame zero until the server has an input to answer, and
|
||||
// repeat the same frame whenever no newer one arrived in between.
|
||||
if(frame_idx == 0 || frame_idx <= m_last_measured_frame) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Anything the send times no longer cover, including a frame we never sent,
|
||||
// which underflows into a large distance.
|
||||
if(m_frame_idx - frame_idx >= INPUT_SEND_TIME_COUNT) {
|
||||
return;
|
||||
}
|
||||
|
||||
m_last_measured_frame = frame_idx;
|
||||
|
||||
auto sent_at = m_input_send_times[frame_idx % INPUT_SEND_TIME_COUNT];
|
||||
m_network_metrics->record_response_time(Clock::now() - sent_at);
|
||||
}
|
||||
|
||||
void ClientWorldController::update(double delta_time) {
|
||||
m_player_controller.update(m_input_manager, delta_time);
|
||||
|
||||
ImGui::Begin("Player Controller");
|
||||
if(m_player_entity.has_value()) {
|
||||
ImGui::Text("Player entity ID: %d", (uint32_t)m_player_entity.value());
|
||||
}
|
||||
|
||||
ImGui::Text("Player count: %ld", m_entity_mapping.size());
|
||||
for(auto mapping : m_entity_mapping) {
|
||||
ImGui::Text("%d -> %d", (uint32_t)mapping.first, mapping.second);
|
||||
}
|
||||
|
||||
ImGui::End();
|
||||
|
||||
if(m_tick_step.update()) {
|
||||
auto network_start = Clock::now();
|
||||
m_connection->update();
|
||||
m_network_metrics->record_update_time(Clock::now() - network_start);
|
||||
|
||||
m_network_metrics->sample({
|
||||
.bytes_sent = m_connection->endpoint()->bytes_sent(),
|
||||
.bytes_received = m_connection->endpoint()->bytes_received(),
|
||||
.messages_sent = m_connection->endpoint()->messages_sent(),
|
||||
.messages_received = m_connection->endpoint()->messages_received()
|
||||
});
|
||||
|
||||
{
|
||||
glm::vec3 input = m_player_controller.input();
|
||||
|
||||
if(m_player_entity.has_value()) {
|
||||
CharacterController* controller = m_world->registry().try_get<CharacterController>(m_player_entity.value());
|
||||
if(controller) {
|
||||
controller->set_input(m_frame_idx, input);
|
||||
m_reconciler.record_input(m_frame_idx, input);
|
||||
}
|
||||
}
|
||||
|
||||
m_physics_world->step(m_frame_idx, JoltPhysicsWorld::FIXED_DELTA_TIME, true);
|
||||
|
||||
if(m_player_entity.has_value()) {
|
||||
Transform* player_transform = m_world->registry().try_get<Transform>(m_player_entity.value());
|
||||
if(player_transform) {
|
||||
glm::vec3 true_position = player_transform->position();
|
||||
m_reconciler.record_prediction(m_frame_idx, true_position);
|
||||
|
||||
m_render_prev_position = m_render_curr_position;
|
||||
m_render_curr_position = true_position;
|
||||
m_tick_accumulator = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
mmo::PlayerMoveMessage player_move_message = {};
|
||||
|
||||
player_move_message.set_frame_idx(m_frame_idx);
|
||||
mmo::PlayerInput* player_input = new mmo::PlayerInput();
|
||||
player_input->set_x(input.x);
|
||||
player_input->set_y(input.y);
|
||||
player_input->set_z(input.z);
|
||||
|
||||
player_move_message.set_allocated_input(player_input);
|
||||
auto r = m_messages.send(m_connection->server(), player_move_message, false);
|
||||
|
||||
m_input_send_times[m_frame_idx % INPUT_SEND_TIME_COUNT] = Clock::now();
|
||||
|
||||
export_entity_history();
|
||||
|
||||
m_frame_idx++;
|
||||
|
||||
m_world->registry().view<Transform>()
|
||||
.each([&](const auto e, Transform& t) {
|
||||
m_position_history_exporter.write((uint32_t)e,
|
||||
std::chrono::duration_cast<std::chrono::milliseconds>(Clock::now().time_since_epoch()).count(), t.position());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
m_entity_interpolator.update();
|
||||
|
||||
if(m_player_entity.has_value()) {
|
||||
Transform* player_transform = m_world->registry().try_get<Transform>(m_player_entity.value());
|
||||
if(player_transform) {
|
||||
m_visual_error *= std::exp(-delta_time * kVisualErrorDecayRate);
|
||||
if(glm::length(m_visual_error) < 0.001f) {
|
||||
m_visual_error = glm::vec3(0.0f);
|
||||
}
|
||||
|
||||
m_tick_accumulator += delta_time;
|
||||
float alpha = glm::clamp(
|
||||
static_cast<float>(m_tick_accumulator / JoltPhysicsWorld::FIXED_DELTA_TIME),
|
||||
0.0f, 1.0f
|
||||
);
|
||||
glm::vec3 smoothed_position = glm::mix(m_render_prev_position, m_render_curr_position, alpha) + m_visual_error;
|
||||
player_transform->set_position(smoothed_position);
|
||||
|
||||
m_player_controller.set_target(smoothed_position);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <entt/entt.hpp>
|
||||
#include <glm/gtx/io.hpp>
|
||||
|
||||
#include "ProtobufMessages.hpp"
|
||||
#include "debug/metrics/NetworkMetrics.hpp"
|
||||
#include "entt/entity/fwd.hpp"
|
||||
#include "io/InputState.hpp"
|
||||
#include "metrics/HistoryBufferExporter.hpp"
|
||||
#include "runtime/LockStep.hpp"
|
||||
#include "world/JoltPhysicsWorld.hpp"
|
||||
#include "world/World.hpp"
|
||||
#include "draw/WorldRenderer.hpp"
|
||||
#include "world/ThirdPersonPlayerController.hpp"
|
||||
#include "network/EntityPositionInterpolator.hpp"
|
||||
#include "network/PlayerReconciler.hpp"
|
||||
|
||||
namespace tw::net {
|
||||
class ServerConnection;
|
||||
}
|
||||
|
||||
namespace tw {
|
||||
|
||||
/**
|
||||
* Multiplayer player controller. Requires address of a server to work.
|
||||
*/
|
||||
class ClientWorldController {
|
||||
/**
|
||||
* Inputs
|
||||
*/
|
||||
const io::InputManager* m_input_manager;
|
||||
|
||||
/**
|
||||
* Pointers to the world
|
||||
*/
|
||||
World* m_world;
|
||||
drw::WorldRenderer* m_world_renderer;
|
||||
JoltPhysicsWorld* m_physics_world;
|
||||
|
||||
std::optional<entt::entity> m_player_entity;
|
||||
std::optional<uint32_t> m_controlled_server_id;
|
||||
ThirdPersonPlayerController m_player_controller;
|
||||
|
||||
net::ServerConnection* m_connection;
|
||||
ProtobufMessages m_messages;
|
||||
|
||||
dbg::NetworkMetrics* m_network_metrics;
|
||||
|
||||
LockStep m_tick_step;
|
||||
|
||||
uint32_t m_frame_idx = 1;
|
||||
entt::entity m_entity_id;
|
||||
|
||||
glm::vec3 m_input;
|
||||
|
||||
std::optional<drw::Mesh> m_mesh;
|
||||
|
||||
using Clock = std::chrono::steady_clock;
|
||||
|
||||
void try_bind_player_entity();
|
||||
|
||||
/**
|
||||
* Places the player at an authoritative position outright, clearing the
|
||||
* predicted state that led there. Used for the first position the server
|
||||
* sends, which the local simulation has no history to reconcile against.
|
||||
*/
|
||||
void snap_player_to(entt::entity entity, glm::vec3 position);
|
||||
|
||||
/**
|
||||
* Whether the server has placed the player at least once. Entities are
|
||||
* created before their position arrives, so the body starts at the origin
|
||||
* and has to be moved once the first position shows up.
|
||||
*/
|
||||
bool m_player_position_initialized = false;
|
||||
|
||||
/**
|
||||
* When each input was sent, indexed by its frame. Holds the most recent
|
||||
* INPUT_SEND_TIME_COUNT frames; an answer that takes longer than that goes
|
||||
* unmeasured.
|
||||
*/
|
||||
static constexpr size_t INPUT_SEND_TIME_COUNT = 256;
|
||||
|
||||
std::vector<Clock::time_point> m_input_send_times;
|
||||
uint32_t m_last_measured_frame = 0;
|
||||
|
||||
/**
|
||||
* Records how long the answer to `frame_idx` took to arrive, ignoring
|
||||
* frames that were already measured or are too old to still have a send
|
||||
* time.
|
||||
*/
|
||||
void measure_response_time(uint32_t frame_idx);
|
||||
|
||||
HistoryBufferExporter<long, glm::vec3> m_position_history_exporter;
|
||||
|
||||
net::EntityPositionInterpolator m_entity_interpolator;
|
||||
|
||||
net::PlayerReconciler m_reconciler;
|
||||
|
||||
/**
|
||||
* Visual smoothing for render-rate interpolation between 20 Hz ticks.
|
||||
*/
|
||||
glm::vec3 m_render_prev_position{0.0f};
|
||||
glm::vec3 m_render_curr_position{0.0f};
|
||||
double m_tick_accumulator = 0.0;
|
||||
|
||||
/**
|
||||
* Visual error from reconciliation corrections, decays over time.
|
||||
*/
|
||||
glm::vec3 m_visual_error{0.0f};
|
||||
static constexpr double kVisualErrorDecayRate = 12.0;
|
||||
|
||||
/**
|
||||
* Mapping from the server entity_id to local entity_id
|
||||
* Server might have the same entity under different name
|
||||
* TODO: Figure out if entt supports custUntitledom IDs to sync them
|
||||
*/
|
||||
std::unordered_map<uint32_t, entt::entity> m_entity_mapping;
|
||||
|
||||
entt::entity create_entity(const std::string& name, glm::vec3 position);
|
||||
|
||||
std::optional<entt::entity> map_from_server_entity(int id);
|
||||
|
||||
void map_server_entity(int server_id, entt::entity local_id);
|
||||
|
||||
void apply_entity_positions(const mmo::EntityPosition* const* positions, size_t count);
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Exports entity history to CSV file for analysis
|
||||
*/
|
||||
void export_entity_history();
|
||||
|
||||
public:
|
||||
ClientWorldController(
|
||||
const io::InputManager* inputs,
|
||||
World* world,
|
||||
JoltPhysicsWorld* physics_world,
|
||||
drw::WorldRenderer* world_renderer,
|
||||
net::ServerConnection* connection,
|
||||
dbg::NetworkMetrics* network_metrics
|
||||
);
|
||||
|
||||
~ClientWorldController();
|
||||
|
||||
void update(double delta_time);
|
||||
};
|
||||
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
#include "ClientWorldController.hpp"
|
||||
|
||||
#include <glm/glm.hpp>
|
||||
#include <chrono>
|
||||
#include <spdlog/spdlog.h>
|
||||
#include <span>
|
||||
|
||||
#include "network/ServerConnection.hpp"
|
||||
|
||||
#include "Entity.pb.h"
|
||||
#include "Login.pb.h"
|
||||
#include "WorldState.pb.h"
|
||||
#include "PlayerMove.pb.h"
|
||||
|
||||
#include "entt/entity/entity.hpp"
|
||||
#include "entt/entity/fwd.hpp"
|
||||
#include "messages/PlayerMoveMessage.hpp"
|
||||
#include "metrics/HistoryBuffer.hpp"
|
||||
#include "world/CharacterBody.hpp"
|
||||
#include "world/CharacterController.hpp"
|
||||
#include "world/JoltPhysicsWorld.hpp"
|
||||
#include "world/WorldEntity.hpp"
|
||||
#include "tw/serial/WorldStateWriter.hpp"
|
||||
#include "network/EntityInterpolation.hpp"
|
||||
|
||||
namespace tw {
|
||||
|
||||
typedef HistoryBuffer<long, glm::vec3> EntityPositionHistory;
|
||||
|
||||
entt::entity
|
||||
ClientWorldController::create_entity(const std::string& name, glm::vec3 position) {
|
||||
const auto entity = m_world->registry().create();
|
||||
|
||||
if(!m_mesh.has_value()) {
|
||||
m_mesh = m_world_renderer->add_mesh(drw::MeshData::cube(glm::vec3(1.0f)));
|
||||
}
|
||||
spdlog::info("Creating entity {}", name);
|
||||
|
||||
m_world->registry()
|
||||
.emplace<Transform>(entity,
|
||||
Transform(position));
|
||||
|
||||
m_world->registry()
|
||||
.emplace<tw::WorldEntity>(entity,
|
||||
tw::WorldEntity(name, (uint32_t)entity));
|
||||
|
||||
m_world->registry()
|
||||
.emplace<tw::drw::Mesh>(entity,
|
||||
m_mesh.value());
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
// void ClientWorldController::apply_entity_positions(const mmo::EntityPosition* const* positions, size_t count) {
|
||||
// for(int i = 0; i < count; i++) {
|
||||
// const mmo::EntityPosition* const position = positions[i];
|
||||
// std::optional<entt::entity> entity = m_entity_id_map.get_local(position->id());
|
||||
//
|
||||
// if(!entity.has_value()) {
|
||||
// spdlog::warn("Received position update for unknown entity {}", position->id());
|
||||
// continue;
|
||||
// }
|
||||
//
|
||||
// glm::vec3 p = {position->x(), position->y(), position->z()};
|
||||
// m_entity_interpolator.add_position_for_entity(entity.value(), p);
|
||||
//
|
||||
// EntityPositionHistory* history = m_world->registry().try_get<EntityPositionHistory>(entity.value());
|
||||
//
|
||||
// if(history != nullptr) {
|
||||
// auto millis = std::chrono::duration_cast<std::chrono::milliseconds>(Clock::now().time_since_epoch()).count();
|
||||
// history->set(millis, p);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
void ClientWorldController::spawn_entity(const std::string& name, uint32_t server_id) {
|
||||
auto entity = create_entity(name, glm::vec3());
|
||||
spdlog::info("Spawning entity {}", server_id);
|
||||
|
||||
m_entity_id_map.register_server_to_local(server_id, entity);
|
||||
|
||||
if(m_controlled_server_id.has_value() && m_controlled_server_id.value() == server_id) {
|
||||
try_bind_player_entity();
|
||||
} else {
|
||||
}
|
||||
m_interpolator.register_entity(entity);
|
||||
}
|
||||
|
||||
ClientWorldController::ClientWorldController(
|
||||
const io::InputManager* inputs,
|
||||
World* world,
|
||||
JoltPhysicsWorld* physics_world,
|
||||
drw::WorldRenderer* world_renderer,
|
||||
net::ServerConnection* connection,
|
||||
dbg::NetworkMetrics* network_metrics
|
||||
) :
|
||||
m_input_manager(inputs),
|
||||
m_world(world),
|
||||
m_physics_world(physics_world),
|
||||
m_world_renderer(world_renderer),
|
||||
m_player_controller(&world_renderer->camera(), glm::vec3()),
|
||||
m_connection(connection),
|
||||
m_messages(connection->endpoint()),
|
||||
m_network_metrics(network_metrics),
|
||||
m_input_update_tick_step(20),
|
||||
m_interpolator(&m_world->registry(), 300),
|
||||
m_replicator_client(&m_messages, &m_entity_id_map, m_network_metrics, m_physics_world, m_world, m_connection, &m_interpolator)
|
||||
{
|
||||
m_messages.set_handler<mmo::LoginResponse>(
|
||||
[this](msg::PeerId, const mmo::LoginResponse& mesg) {
|
||||
spdlog::info("Logged in!");
|
||||
});
|
||||
|
||||
m_messages.set_handler<mmo::SetControlledEntity>(
|
||||
[this](msg::PeerId, const mmo::SetControlledEntity& mesg) {
|
||||
spdlog::info("Setting controlled entity from server id {}", mesg.entity_id());
|
||||
m_controlled_server_id = mesg.entity_id();
|
||||
try_bind_player_entity();
|
||||
});
|
||||
|
||||
m_connection->endpoint()->set_handler(Message<mmo::WorldStateMessage>::value,
|
||||
[this](msg::PeerId, std::span<const std::byte> data) {
|
||||
|
||||
serial::WorldStateReader reader(data);
|
||||
|
||||
m_replicator_client.handle_snapshot(m_network_frame_idx, reader);
|
||||
});
|
||||
|
||||
m_messages.set_handler<mmo::EntitySpawnMessage>(
|
||||
[this](msg::PeerId, const mmo::EntitySpawnMessage& mesg) {
|
||||
for(auto& spawn : mesg.spawns()) {
|
||||
spawn_entity(spawn.name(), spawn.entity_id());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ClientWorldController::~ClientWorldController() { }
|
||||
|
||||
void ClientWorldController::try_bind_player_entity() {
|
||||
if(!m_controlled_server_id.has_value()) {
|
||||
return;
|
||||
}
|
||||
|
||||
auto local_entity = m_entity_id_map.get_local(m_controlled_server_id.value());
|
||||
if(!local_entity.has_value()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if(m_player_entity.has_value() && m_player_entity.value() == local_entity.value()) {
|
||||
return;
|
||||
}
|
||||
|
||||
entt::entity entity = local_entity.value();
|
||||
spdlog::info("Binding player entity");
|
||||
|
||||
m_player_entity = entity;
|
||||
m_replicator_client.set_player_entity(entity);
|
||||
|
||||
Transform* transform = m_world->registry().try_get<Transform>(entity);
|
||||
glm::vec3 position = transform ? transform->position() : glm::vec3(0.0f);
|
||||
|
||||
m_world->registry().emplace<CharacterController>(entity, 20.0f);
|
||||
m_world->registry().emplace<CharacterBody>(entity, m_physics_world->create_character(
|
||||
new JPH::BoxShape(JPH::Vec3Arg(0.5f, 0.5f, 0.5f)),
|
||||
position
|
||||
));
|
||||
|
||||
// if(m_world->registry().all_of<net::EntityPositionInterpolation>(entity)) {
|
||||
// m_world->registry().remove<net::EntityPositionInterpolation>(entity);
|
||||
// }
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
void ClientWorldController::update_network() {
|
||||
auto network_start = Clock::now();
|
||||
// collects incoming messages
|
||||
m_connection->update();
|
||||
m_network_metrics->record_update_time(Clock::now() - network_start);
|
||||
|
||||
m_network_metrics->sample({
|
||||
.bytes_sent = m_connection->endpoint()->bytes_sent(),
|
||||
.bytes_received = m_connection->endpoint()->bytes_received(),
|
||||
.messages_sent = m_connection->endpoint()->messages_sent(),
|
||||
.messages_received = m_connection->endpoint()->messages_received()
|
||||
});
|
||||
|
||||
|
||||
m_replicator_client.record_prediction(m_frame_idx);
|
||||
|
||||
|
||||
// m_world->registry().view<Transform>()
|
||||
// .each([&](const auto e, Transform& t) {
|
||||
// m_position_history_exporter.write((uint32_t)e,
|
||||
// std::chrono::duration_cast<std::chrono::milliseconds>(Clock::now().time_since_epoch()).count(), t.position());
|
||||
// });
|
||||
|
||||
}
|
||||
|
||||
void ClientWorldController::update(double delta_time) {
|
||||
// collects messages from network for processing
|
||||
update_network();
|
||||
|
||||
m_player_controller.update(m_input_manager, delta_time);
|
||||
|
||||
// collect input every tick of the input_update_tick_step
|
||||
if(m_input_update_tick_step.has_ticked()) {
|
||||
// TODO: might be more like `calc_velocity_vector`.
|
||||
// 1. it is not exactly *input* but input mapped to normalized velocity vector
|
||||
// 2. I hate calling methods `update`
|
||||
glm::vec3 input = m_player_controller.input();
|
||||
|
||||
if(m_player_entity.has_value()) {
|
||||
CharacterController* controller = m_world->registry().try_get<CharacterController>(m_player_entity.value());
|
||||
if(controller) {
|
||||
// controller->set_input(m_network_frame_idx, input);
|
||||
m_replicator_client.set_input(m_network_frame_idx, input);
|
||||
}
|
||||
}
|
||||
|
||||
// m_physics_world->step(m_network_frame_idx, JoltPhysicsWorld::FIXED_DELTA_TIME, true);
|
||||
m_network_frame_idx++;
|
||||
}
|
||||
|
||||
m_interpolator.interpolate_smoothed_entities(std::chrono::high_resolution_clock::now() - std::chrono::milliseconds(500));
|
||||
|
||||
if(m_player_entity.has_value()) {
|
||||
// after interpolation happen, follow the target
|
||||
// TODO: The third person controller could pull
|
||||
Transform* player_transform = m_world->registry().try_get<Transform>(m_player_entity.value());
|
||||
if(player_transform) {
|
||||
m_player_controller.set_target(player_transform->position());
|
||||
}
|
||||
}
|
||||
|
||||
m_frame_idx++;
|
||||
|
||||
// m_replicator_client.update();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
#pragma once
|
||||
|
||||
#include <entt/entt.hpp>
|
||||
#include <glm/gtx/io.hpp>
|
||||
|
||||
#include "ProtobufMessages.hpp"
|
||||
#include "debug/metrics/NetworkMetrics.hpp"
|
||||
#include "entt/entity/fwd.hpp"
|
||||
#include "io/InputState.hpp"
|
||||
#include "metrics/HistoryBufferExporter.hpp"
|
||||
#include "network/EntityIdMap.hpp"
|
||||
#include "network/ReplicatorClient.hpp"
|
||||
#include "runtime/LockStep.hpp"
|
||||
#include "world/JoltPhysicsWorld.hpp"
|
||||
#include "world/World.hpp"
|
||||
#include "draw/WorldRenderer.hpp"
|
||||
#include "world/ThirdPersonPlayerController.hpp"
|
||||
#include "network/EntityPositionInterpolator.hpp"
|
||||
|
||||
namespace tw::net {
|
||||
class ServerConnection;
|
||||
}
|
||||
|
||||
namespace tw {
|
||||
|
||||
/**
|
||||
* Multiplayer player controller. Requires address of a server to work.
|
||||
*/
|
||||
class ClientWorldController {
|
||||
/**
|
||||
* Inputs
|
||||
*/
|
||||
const io::InputManager* m_input_manager;
|
||||
|
||||
/**
|
||||
* Pointers to the world
|
||||
*/
|
||||
World* m_world;
|
||||
drw::WorldRenderer* m_world_renderer;
|
||||
JoltPhysicsWorld* m_physics_world;
|
||||
|
||||
std::optional<entt::entity> m_player_entity;
|
||||
std::optional<uint32_t> m_controlled_server_id;
|
||||
ThirdPersonPlayerController m_player_controller;
|
||||
|
||||
net::ServerConnection* m_connection;
|
||||
ProtobufMessages m_messages;
|
||||
|
||||
dbg::NetworkMetrics* m_network_metrics;
|
||||
|
||||
LockStep m_input_update_tick_step;
|
||||
|
||||
uint32_t m_frame_idx = 1;
|
||||
uint32_t m_network_frame_idx = 1;
|
||||
entt::entity m_entity_id;
|
||||
|
||||
glm::vec3 m_input;
|
||||
|
||||
std::optional<drw::Mesh> m_mesh;
|
||||
|
||||
net::EntityPositionInterpolator m_interpolator;
|
||||
|
||||
net::ReplicatorClient m_replicator_client;
|
||||
|
||||
net::EntityIdMap m_entity_id_map;
|
||||
|
||||
using Clock = std::chrono::steady_clock;
|
||||
|
||||
void try_bind_player_entity();
|
||||
|
||||
entt::entity create_entity(const std::string& name, glm::vec3 position);
|
||||
|
||||
void apply_entity_positions(const mmo::EntityPosition* const* positions, size_t count);
|
||||
|
||||
void update_network();
|
||||
|
||||
public:
|
||||
ClientWorldController(
|
||||
const io::InputManager* inputs,
|
||||
World* world,
|
||||
JoltPhysicsWorld* physics_world,
|
||||
drw::WorldRenderer* world_renderer,
|
||||
net::ServerConnection* connection,
|
||||
dbg::NetworkMetrics* network_metrics
|
||||
);
|
||||
|
||||
~ClientWorldController();
|
||||
|
||||
|
||||
constexpr uint32_t frame_idx() const {
|
||||
return m_frame_idx;
|
||||
}
|
||||
|
||||
constexpr std::optional<entt::entity> player_entity() const {
|
||||
if(!m_controlled_server_id.has_value()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return m_entity_id_map.get_local(m_controlled_server_id.value());
|
||||
}
|
||||
|
||||
void spawn_entity(const std::string& name, uint32_t server_id);
|
||||
|
||||
void update(double delta_time);
|
||||
};
|
||||
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "Address.hpp"
|
||||
#include "Entity.pb.h"
|
||||
#include "Login.pb.h"
|
||||
#include "PlayerMove.pb.h"
|
||||
#include "ProtobufMessages.hpp"
|
||||
@@ -63,6 +64,9 @@ public:
|
||||
m_messages.set_handler<mmo::WorldStateMessage>(
|
||||
[this](tw::msg::PeerId, const mmo::WorldStateMessage& mesg) { });
|
||||
|
||||
m_messages.set_handler<mmo::EntitySpawnMessage>(
|
||||
[this](tw::msg::PeerId, const mmo::EntitySpawnMessage& mesg) { });
|
||||
|
||||
m_messages.set_handler<mmo::LoginResponse>(
|
||||
[this](tw::msg::PeerId, const mmo::LoginResponse& mesg) {
|
||||
if(!m_is_connected) {
|
||||
@@ -103,12 +107,13 @@ public:
|
||||
player_input->set_z(m_velocity.z);
|
||||
player_move_mesg.set_allocated_input(player_input);
|
||||
auto r = m_messages.send(m_server, player_move_mesg, false);
|
||||
m_frame_idx++;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
int main() {
|
||||
const int NUM_CLIENTS = 600;
|
||||
const int NUM_CLIENTS = 10;
|
||||
tw::net::Address address = {"127.0.0.1", 8101};
|
||||
|
||||
std::vector<std::thread> threads;
|
||||
|
||||
@@ -2,12 +2,16 @@ syntax = "proto3";
|
||||
|
||||
package mmo;
|
||||
|
||||
message EntitySpawnMessage {
|
||||
message EntitySpawn {
|
||||
uint32 entity_id = 1;
|
||||
bool is_player = 2;
|
||||
string name = 3;
|
||||
}
|
||||
|
||||
message EntitySpawnMessage {
|
||||
repeated EntitySpawn spawns = 1;
|
||||
}
|
||||
|
||||
message EntityDespawnMessage {
|
||||
uint32 entity_id = 1;
|
||||
}
|
||||
|
||||
@@ -1,59 +1,5 @@
|
||||
#pragma once
|
||||
|
||||
/**
|
||||
* WorldStateWriter / WorldStateReader
|
||||
* =====================================
|
||||
* Project-specific serialisation for the per-frame world-state snapshot
|
||||
* sent from the server to each connected client.
|
||||
*
|
||||
* Wire format (all values little-endian):
|
||||
*
|
||||
* ┌──────────────────────────────────────────────────────────┐
|
||||
* │ Header (16 bytes) │
|
||||
* │ message_type : uint32 (supplied by the caller) │
|
||||
* │ sequence : uint32 (always 0, no reply expected) │
|
||||
* │ frame_idx : uint32 │
|
||||
* │ entity_count : uint32 (number of position records) │
|
||||
* ├──────────────────────────────────────────────────────────┤
|
||||
* │ Spawns section │
|
||||
* │ spawn_count : uint32 │
|
||||
* │ spawn[i].id : uint32 × spawn_count │
|
||||
* ├──────────────────────────────────────────────────────────┤
|
||||
* │ Despawns section │
|
||||
* │ despawn_count : uint32 │
|
||||
* │ despawn[i].id : uint32 × despawn_count │
|
||||
* ├──────────────────────────────────────────────────────────┤
|
||||
* │ Entity positions (hot path — tightly packed) │
|
||||
* │ [ id:uint32, x:float, y:float, z:float ] × entity_count│
|
||||
* └──────────────────────────────────────────────────────────┘
|
||||
*
|
||||
* Total minimum size : 20 bytes (header + empty spawns + empty despawns)
|
||||
* Per entity : 16 bytes
|
||||
* 300 entities : 20 + 300×16 = 4820 bytes (well under MTU for segmented)
|
||||
*
|
||||
* Usage (server side, called once per client per frame):
|
||||
*
|
||||
* tw::serial::WorldStateWriter w(buffer); // buffer is a BinaryBuffer
|
||||
* w.begin(frame_idx);
|
||||
* w.write_spawns(interest.spawns());
|
||||
* w.write_despawns(interest.despawns());
|
||||
* w.begin_entities(num_entities); // writes entity_count slot
|
||||
* for each entity in interest.entities():
|
||||
* w.write_entity(entity_id, position);
|
||||
* w.end(); // patches entity_count
|
||||
* // buffer.view() is ready to send
|
||||
*
|
||||
* Usage (client side):
|
||||
*
|
||||
* tw::serial::WorldStateReader r(payload_span);
|
||||
* auto header = r.read_header(); // frame_idx + counts
|
||||
* for (auto id : r.read_spawns()) { ... }
|
||||
* for (auto id : r.read_despawns()) { ... }
|
||||
* while (r.has_entity()) {
|
||||
* auto [id, pos] = r.read_entity();
|
||||
* ...
|
||||
* }
|
||||
*/
|
||||
|
||||
#include "Codec.hpp"
|
||||
#include "GlmCodec.hpp"
|
||||
@@ -67,10 +13,6 @@
|
||||
|
||||
namespace tw::serial {
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// WorldStateWriter
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class WorldStateWriter {
|
||||
BinaryWriter m_w;
|
||||
|
||||
@@ -81,13 +23,6 @@ class WorldStateWriter {
|
||||
public:
|
||||
explicit WorldStateWriter(BinaryBuffer& buf) noexcept : m_w(buf) {}
|
||||
|
||||
/**
|
||||
* Write the header. Call once per frame, before everything else.
|
||||
* entity_count is patched in end().
|
||||
*
|
||||
* message_type is supplied by the caller so that this module stays free of
|
||||
* the address the message is delivered to.
|
||||
*/
|
||||
void begin(uint32_t frame_idx, uint32_t message_type) noexcept {
|
||||
m_entity_count = 0;
|
||||
|
||||
@@ -104,11 +39,6 @@ public:
|
||||
m_entity_count_offset = m_w.reserve_u32();
|
||||
}
|
||||
|
||||
// ── Spawns ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Write the spawn list. Pass any range of entt::entity.
|
||||
*/
|
||||
template<typename Range>
|
||||
void write_spawns(const Range& spawns) noexcept {
|
||||
auto count = static_cast<uint32_t>(std::size(spawns));
|
||||
@@ -118,8 +48,6 @@ public:
|
||||
}
|
||||
}
|
||||
|
||||
// ── Despawns ──────────────────────────────────────────────────────────
|
||||
|
||||
template<typename Range>
|
||||
void write_despawns(const Range& despawns) noexcept {
|
||||
auto count = static_cast<uint32_t>(std::size(despawns));
|
||||
@@ -129,16 +57,6 @@ public:
|
||||
}
|
||||
}
|
||||
|
||||
// ── Entity positions (the hot path) ───────────────────────────────────
|
||||
|
||||
/**
|
||||
* Write a single entity position record.
|
||||
* id : raw uint32 of the entity handle
|
||||
* pos : world-space position (x, y, z)
|
||||
*
|
||||
* This is the innermost loop of the replicator — every byte matters.
|
||||
* The compiler will inline both calls down to two contiguous memcpys.
|
||||
*/
|
||||
void write_entity(uint32_t id, const glm::vec3& pos) noexcept {
|
||||
m_w.write(id);
|
||||
// Write x, y, z as 3 contiguous floats
|
||||
@@ -146,20 +64,14 @@ public:
|
||||
++m_entity_count;
|
||||
}
|
||||
|
||||
// Convenience overload accepting an entt::entity handle directly
|
||||
void write_entity(entt::entity entity, const glm::vec3& pos) noexcept {
|
||||
write_entity(static_cast<uint32_t>(entity), pos);
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch the entity_count field written in begin() and finalise the
|
||||
* buffer. Must be called exactly once after all write_entity() calls.
|
||||
*/
|
||||
void end() noexcept {
|
||||
m_w.patch_u32(m_entity_count_offset, m_entity_count);
|
||||
}
|
||||
|
||||
/** Expose the underlying buffer view (e.g. to pass to send_message). */
|
||||
std::span<const std::byte> view() noexcept {
|
||||
return m_w.buffer().view();
|
||||
}
|
||||
@@ -170,10 +82,6 @@ public:
|
||||
}
|
||||
};
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// WorldStateReader (client-side / test use)
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
struct WorldStateHeader {
|
||||
uint32_t packet_type;
|
||||
uint32_t frame_idx;
|
||||
|
||||
@@ -8,19 +8,22 @@
|
||||
|
||||
namespace tw::net {
|
||||
|
||||
/**
|
||||
* Session with the player's client.
|
||||
*/
|
||||
struct PlayerSession {
|
||||
public:
|
||||
SessionId session_id;
|
||||
|
||||
msg::MessageConnection* connection;
|
||||
|
||||
uint32_t last_frame;
|
||||
uint32_t last_received_frame;
|
||||
uint32_t acked_frame;
|
||||
|
||||
PlayerSession(SessionId session_id, msg::MessageConnection* connection) :
|
||||
session_id(session_id),
|
||||
connection(connection),
|
||||
last_frame(0),
|
||||
last_received_frame(0),
|
||||
acked_frame(0)
|
||||
{ }
|
||||
};
|
||||
|
||||
@@ -12,11 +12,6 @@
|
||||
|
||||
namespace tw::net {
|
||||
|
||||
// Listens on a dedicated port for incoming zone-server peer connections and
|
||||
// opens outgoing connections to known peers.
|
||||
//
|
||||
// Incoming and outgoing peers are held together so that broadcast messages
|
||||
// (ZoneHello, ZoneBye) reach every peer uniformly.
|
||||
class ZoneClusterLink {
|
||||
std::unique_ptr<msg::MessageEndpoint> m_endpoint;
|
||||
ProtobufMessages m_messages;
|
||||
@@ -24,13 +19,10 @@ class ZoneClusterLink {
|
||||
public:
|
||||
explicit ZoneClusterLink(int port);
|
||||
|
||||
// Poll for messages and accept any pending peer connections.
|
||||
void update();
|
||||
|
||||
// Connect to a remote zone peer and register it in the peer list.
|
||||
msg::MessageConnection* connect_to_peer(const std::string& host, int port);
|
||||
|
||||
// Send a protobuf message to a single peer.
|
||||
template<typename T>
|
||||
void send_mesg(msg::MessageConnection* peer, const T& msg) {
|
||||
auto send_r = m_messages.send(peer, msg, false);
|
||||
|
||||
@@ -19,8 +19,6 @@ ZoneManager::ZoneManager(im::AreaBounds zone_bounds)
|
||||
))
|
||||
{}
|
||||
|
||||
// ── ZoneProxy interface ───────────────────────────────────────────────────────
|
||||
|
||||
im::AreaBounds ZoneManager::area() const {
|
||||
return {
|
||||
{ (float)m_spatial_backend->world_min_x(), (float)m_spatial_backend->world_min_z() },
|
||||
@@ -33,8 +31,6 @@ void ZoneManager::transfer_entity(EntityInfo&& info) {
|
||||
spawn_entity(std::move(info));
|
||||
}
|
||||
|
||||
// ── Zone management ───────────────────────────────────────────────────────────
|
||||
|
||||
entt::entity ZoneManager::spawn_entity(EntityInfo&& info) {
|
||||
entt::entity entity = m_world->registry().create();
|
||||
|
||||
@@ -104,8 +100,6 @@ uint32_t ZoneManager::acked_input_frame(im::InterestId interest_id) const {
|
||||
return it != m_session_acked_frame.end() ? it->second : 0;
|
||||
}
|
||||
|
||||
// ── Private helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
void ZoneManager::check_neighbor_transfers() {
|
||||
ZoneScopedN("ZoneManager::check_neighbor_transfers");
|
||||
|
||||
@@ -161,9 +155,7 @@ void ZoneManager::check_neighbor_transfers() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tick ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
void ZoneManager::tick(uint32_t frame_idx, float delta_time) {
|
||||
void ZoneManager::update(uint32_t frame_idx, float delta_time) {
|
||||
ZoneScopedN("ZoneManager::tick");
|
||||
|
||||
FrameMarkStart("Interest System");
|
||||
@@ -176,21 +168,7 @@ void ZoneManager::tick(uint32_t frame_idx, float delta_time) {
|
||||
m_world->step(delta_time);
|
||||
FrameMarkEnd("World step");
|
||||
|
||||
// Re-stamp each client entity's newest input onto the server's frame index.
|
||||
// The input ring is keyed by client frame numbers (independent counter),
|
||||
// but physics lookup uses the server's frame counter. By re-stamping onto
|
||||
// the server frame, input(server_frame_idx) resolves by exact match rather
|
||||
// than fallback, ensuring deterministic and correct input consumption.
|
||||
for (const auto& [interest_id, entity] : m_client_entities) {
|
||||
auto* controller = m_world->registry().try_get<CharacterController>(entity);
|
||||
if (!controller) continue;
|
||||
controller->set_input(frame_idx, controller->input());
|
||||
}
|
||||
|
||||
FrameMarkStart("Physics step");
|
||||
// Fixed step, not the measured interval: the simulation has to advance by the
|
||||
// same amount every frame for a replay of the same inputs to land in the same
|
||||
// place.
|
||||
m_physics_world.step(frame_idx, JoltPhysicsWorld::FIXED_DELTA_TIME);
|
||||
FrameMarkEnd("Physics step");
|
||||
|
||||
|
||||
@@ -18,30 +18,28 @@
|
||||
|
||||
namespace tw::net {
|
||||
|
||||
// 500-unit cells, 500-unit view radius → 3×3 neighbourhood, no per-entity distance checks.
|
||||
// World bounds are supplied to the constructor at runtime.
|
||||
using SpatialBackendType = im::FixedGrid<500, 500>;
|
||||
|
||||
// How far into this zone a neighbour's interest area is expanded.
|
||||
// Entities within this margin are tracked by the interest system before transfer.
|
||||
static constexpr float kNeighborBorderOverlap = 100.0f;
|
||||
|
||||
/**
|
||||
* Manages one zone: contains state of all entities inside and does logic for them.
|
||||
*/
|
||||
class ZoneManager : public ZoneProxy {
|
||||
struct NeighborEntry {
|
||||
ZoneProxy* proxy;
|
||||
im::InterestId interest_id;
|
||||
};
|
||||
|
||||
// Declaration order == initialisation order.
|
||||
std::unique_ptr<World> m_world;
|
||||
JoltPhysicsWorld m_physics_world;
|
||||
std::unique_ptr<SpatialBackendType> m_spatial_backend;
|
||||
std::unique_ptr<im::InterestSystem<SpatialBackendType>> m_interest_system;
|
||||
|
||||
std::unordered_map<im::InterestId, entt::entity> m_client_entities;
|
||||
std::unordered_map<entt::entity, im::InterestId> m_entity_sessions; // reverse map
|
||||
std::unordered_map<im::InterestId, uint32_t> m_session_input_frame; // most recent input frame received
|
||||
std::unordered_map<im::InterestId, uint32_t> m_session_acked_frame; // input frame consumed by last tick
|
||||
std::unordered_map<entt::entity, im::InterestId> m_entity_sessions;
|
||||
std::unordered_map<im::InterestId, uint32_t> m_session_input_frame;
|
||||
std::unordered_map<im::InterestId, uint32_t> m_session_acked_frame;
|
||||
std::vector<NeighborEntry> m_neighbors;
|
||||
uint32_t m_next_neighbor_id = 0x80000000u;
|
||||
|
||||
@@ -57,39 +55,23 @@ public:
|
||||
|
||||
ZoneManager(im::AreaBounds zone_bounds);
|
||||
|
||||
// ── ZoneProxy interface ───────────────────────────────────────────────────
|
||||
|
||||
// Returns the geographic area this zone owns, derived from the spatial backend bounds.
|
||||
im::AreaBounds area() const override;
|
||||
|
||||
// Receive an entity transferred from a neighbouring zone.
|
||||
// Spawns the entity in this zone's world; wires up input routing if session_id is set.
|
||||
// NOTE: ZoneServer must also update its session→zone routing table after this call.
|
||||
void transfer_entity(EntityInfo&& info) override;
|
||||
|
||||
// ── Zone management ───────────────────────────────────────────────────────
|
||||
|
||||
entt::entity spawn_entity(EntityInfo&& info);
|
||||
|
||||
// Registers an interest for the given entity and begins tracking entities near their position.
|
||||
// Uses session_id as the InterestId; the interest system is unaware of this mapping.
|
||||
// Can fetch the interest using `get_interest(interest_id)`.
|
||||
void add_client(im::InterestId interest_id, entt::entity entity);
|
||||
|
||||
void on_player_move(SessionId session_id, mmo::PlayerMoveMessage&& message);
|
||||
|
||||
// Registers a neighbouring zone. Its geographic area is added to the interest
|
||||
// manager and checked each tick; entities that cross into it are transferred.
|
||||
void register_neighbor_zone(ZoneProxy* neighbor);
|
||||
|
||||
// Returns the current interest state for any registered id, or nullptr.
|
||||
const im::Interest* get_interest(im::InterestId id) const;
|
||||
|
||||
// Returns the input frame that this session's entity was last simulated with, or 0 if unknown.
|
||||
uint32_t acked_input_frame(im::InterestId interest_id) const;
|
||||
|
||||
// Runs one tick: interest queries, neighbour transfer checks, world step, physics step.
|
||||
void tick(uint32_t frame_idx, float delta_time);
|
||||
void update(uint32_t frame_idx, float delta_time);
|
||||
};
|
||||
|
||||
} // namespace tw::net
|
||||
|
||||
@@ -61,10 +61,8 @@ void ZoneServer::player_update_handler(SessionId session_id, mmo::PlayerMoveMess
|
||||
auto it = m_session_zone.find(session_id);
|
||||
if (it == m_session_zone.end()) return;
|
||||
|
||||
// Echoed back in the next snapshot, so the client can tell how long its
|
||||
// input took to come back.
|
||||
if (auto* session = m_player_session_registry->session(session_id)) {
|
||||
session->last_frame = message.frame_idx();
|
||||
session->last_received_frame = message.frame_idx();
|
||||
}
|
||||
|
||||
it->second->on_player_move(session_id, std::move(message));
|
||||
@@ -134,11 +132,8 @@ void ZoneServer::run() {
|
||||
FrameMarkEnd("Update clients");
|
||||
|
||||
for (auto& zone : m_zones) {
|
||||
zone->tick(frame_idx, lock_step.delta_time());
|
||||
zone->update(frame_idx, lock_step.delta_time());
|
||||
|
||||
// Copy acked frames from zone to sessions before replication.
|
||||
// This ensures snapshots carry the input frame actually consumed by this tick,
|
||||
// not frames that arrive in the trailing network update.
|
||||
for (const auto& [session_id, session_zone] : m_session_zone) {
|
||||
if (session_zone != zone.get()) continue;
|
||||
if (auto* session = m_player_session_registry->session(session_id)) {
|
||||
|
||||
@@ -7,28 +7,6 @@
|
||||
|
||||
namespace tw::net::im {
|
||||
|
||||
/**
|
||||
* Bounded-world spatial grid backend.
|
||||
*
|
||||
* World area bounds are supplied at construction time so the same grid type
|
||||
* can be reused for differently-sized zones without recompiling.
|
||||
* CELL_SIZE and VIEW_RADIUS remain template parameters so kNeighborRadius
|
||||
* can be computed at compile time, keeping the hot-path loop bounds constant.
|
||||
*
|
||||
* Template parameters:
|
||||
* CELL_SIZE: size of each cell (meters). Recommended: CELL_SIZE == VIEW_RADIUS
|
||||
* for maximum efficiency (no distance checks needed).
|
||||
* VIEW_RADIUS: the subscription radius around a player (meters).
|
||||
*
|
||||
* Constructor parameters:
|
||||
* min_x, max_x, min_z, max_z — world area bounds (meters).
|
||||
*
|
||||
* Complexity:
|
||||
* begin_frame(): O(cols × rows) clearing
|
||||
* insert(): O(1)
|
||||
* query_neighbors(): O(kNeighborRadius²) cells × avg entities per cell
|
||||
* query_area(): O(cells overlapping AABB) × avg entities per cell
|
||||
*/
|
||||
template<uint32_t CELL_SIZE, uint32_t VIEW_RADIUS>
|
||||
class FixedGrid {
|
||||
public:
|
||||
@@ -45,9 +23,6 @@ private:
|
||||
int32_t m_world_min_z, m_world_max_z;
|
||||
uint32_t m_cols, m_rows;
|
||||
|
||||
// Flat 2D vector of cell vectors indexed as [cz * m_cols + cx].
|
||||
// Outer vector is allocated once at construction; inner vectors retain
|
||||
// capacity across frames so no heap allocations occur in steady state.
|
||||
std::vector<std::vector<entt::entity>> m_cells;
|
||||
|
||||
[[nodiscard]] inline std::pair<uint32_t, uint32_t> world_to_cell(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#pragma once
|
||||
|
||||
#include "Entity.pb.h"
|
||||
#include "MessageRegistry.hpp"
|
||||
#include "WorldState.pb.h"
|
||||
#include "network/NetworkReceiver.hpp"
|
||||
#include "network/PlayerSessionRegistry.hpp"
|
||||
#include "systems/Interest.hpp"
|
||||
@@ -18,14 +20,6 @@ namespace tw::net {
|
||||
|
||||
/**
|
||||
* Decides what to replicate and to whom. Does not mutate world state.
|
||||
*
|
||||
* Per-frame flow:
|
||||
* 1. For each session: reset its BinaryBuffer and write the header,
|
||||
* spawns and despawns from the InterestSystem.
|
||||
* 2. For each session: iterate its interest set and write entity positions
|
||||
* 3. Patch entity_count and send each raw buffer
|
||||
*
|
||||
* Memory: one BinaryBuffer per session, pre-allocated and reused every frame.
|
||||
*/
|
||||
template<im::SpatialBackend Backend>
|
||||
class StateReplicator {
|
||||
@@ -51,7 +45,10 @@ public:
|
||||
/**
|
||||
* Replicates the current world state for one zone to its connected clients.
|
||||
*/
|
||||
void replicate(const entt::registry& registry, const im::InterestSystem<Backend>* interest_manager) {
|
||||
void replicate(
|
||||
const entt::registry& registry,
|
||||
const im::InterestSystem<Backend>* interest_manager
|
||||
) {
|
||||
ZoneScopedN("Replicator");
|
||||
|
||||
const auto& sessions = m_client_registry->sessions();
|
||||
@@ -59,16 +56,12 @@ public:
|
||||
|
||||
if (session_count == 0) return;
|
||||
|
||||
// ── Grow buffer pool if needed (only on new connections) ──────────
|
||||
if (m_frames.size() < session_count) {
|
||||
m_frames.resize(session_count);
|
||||
for (auto& buf : m_frames)
|
||||
buf.reserve(kInitialCapacity);
|
||||
}
|
||||
|
||||
// Build one WorldStateWriter per client referencing the pre-allocated
|
||||
// buffer. reserve() is called before this loop so no reallocation
|
||||
// occurs and the buffer references inside the writers stay valid.
|
||||
std::vector<tw::serial::WorldStateWriter> writers;
|
||||
writers.reserve(session_count);
|
||||
for (std::size_t i = 0; i < session_count; ++i)
|
||||
@@ -76,7 +69,6 @@ public:
|
||||
|
||||
std::vector<const im::Interest*> client_states(session_count);
|
||||
|
||||
// ── 1. Write headers / spawns / despawns ──────────────────────────
|
||||
{
|
||||
ZoneScopedN("Preparing messages");
|
||||
|
||||
@@ -96,26 +88,37 @@ public:
|
||||
|
||||
m_frames[i].reserve(needed);
|
||||
writers[i].reset();
|
||||
writers[i].begin(session->acked_frame, Message<mmo::WorldStateMessage>::value);
|
||||
writers[i].begin(session->last_received_frame, Message<mmo::WorldStateMessage>::value);
|
||||
writers[i].write_spawns(state->spawn());
|
||||
writers[i].write_despawns(state->despawn());
|
||||
|
||||
mmo::EntitySpawnMessage spawn_message = {};
|
||||
|
||||
for(auto& spawn : state->spawn()) {
|
||||
auto spawn_item = spawn_message.add_spawns();
|
||||
spawn_item->set_entity_id((uint32_t)spawn);
|
||||
spawn_item->set_is_player(false);
|
||||
spawn_item->set_name("Test");
|
||||
}
|
||||
|
||||
m_network->send_mesg(sessions[i]->session_id, spawn_message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. Write entity positions (hot path, O(entities × sessions)) ──
|
||||
{
|
||||
ZoneScopedN("Putting transforms into messages");
|
||||
|
||||
auto view = registry.view<Transform>();
|
||||
view.each([&](entt::entity e, const Transform& t) {
|
||||
for (std::size_t i = 0; i < session_count; ++i) {
|
||||
if (client_states[i] && client_states[i]->is_interested_in_entity(e))
|
||||
if (client_states[i] && client_states[i]->is_interested_in_entity(e)) {
|
||||
writers[i].write_entity(e, t.position());
|
||||
// spdlog::info("Entity {} is at {} {} {}", (uint32_t)e, t.position().x, t.position().y, t.position().z);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── 3. Finalise and dispatch ───────────────────────────────────────
|
||||
{
|
||||
ZoneScopedN("Sending messages");
|
||||
|
||||
|
||||
@@ -12,28 +12,10 @@
|
||||
|
||||
namespace tw::net::im {
|
||||
|
||||
/**
|
||||
* Spatial interest management: maintains per-subscription spawn/despawn deltas.
|
||||
*
|
||||
* Each subscription is identified by a caller-chosen InterestId (uint32_t).
|
||||
* Two kinds of subscription:
|
||||
* set_entity_interest(id, entity) — tracks entities in the neighbourhood of
|
||||
* a world entity's position (e.g. a player).
|
||||
* set_area_interest(id, bounds) — tracks entities inside an XZ quad
|
||||
* (e.g. a zone border region).
|
||||
*
|
||||
* The caller decides how ids map to sessions, zones, or anything else.
|
||||
* InterestSystem has no knowledge of that mapping.
|
||||
*
|
||||
* Two-phase update per frame:
|
||||
* Phase 1 — Spatial rebuild (O(E)): insert all Transform entities into the backend.
|
||||
* Phase 2 — Per-subscription deltas (O(N × K)): for each subscription query the
|
||||
* backend, sort results, compute spawn/despawn via set_difference.
|
||||
*/
|
||||
template<SpatialBackend Backend>
|
||||
class InterestSystem {
|
||||
const World* m_world;
|
||||
Backend* m_backend; // non-owning; caller manages lifetime
|
||||
Backend* m_backend;
|
||||
std::unordered_map<InterestId, Interest> m_interests;
|
||||
|
||||
public:
|
||||
@@ -42,33 +24,26 @@ public:
|
||||
m_backend(backend)
|
||||
{}
|
||||
|
||||
// Registers or replaces a subscription tracking the neighbourhood of an entity.
|
||||
void set_entity_interest(InterestId id, entt::entity entity) {
|
||||
m_interests.insert_or_assign(id, Interest(entity));
|
||||
}
|
||||
|
||||
// Registers or replaces a subscription tracking entities inside an XZ quad.
|
||||
void set_area_interest(InterestId id, AreaBounds bounds) {
|
||||
m_interests.insert_or_assign(id, Interest(bounds));
|
||||
}
|
||||
|
||||
// Removes a subscription. No-op if id is not registered.
|
||||
void remove_interest(InterestId id) {
|
||||
m_interests.erase(id);
|
||||
}
|
||||
|
||||
// Returns the current interest state for the given id, or nullptr.
|
||||
const Interest* get_interest(InterestId id) const {
|
||||
auto it = m_interests.find(id);
|
||||
return it != m_interests.end() ? &it->second : nullptr;
|
||||
}
|
||||
|
||||
// Updates all subscriptions for the current frame.
|
||||
// Must be called once per frame after world positions have been updated.
|
||||
void update() {
|
||||
ZoneScopedN("InterestSystem::update");
|
||||
|
||||
// ── Phase 1: Rebuild spatial backend ─────────────────────────────────
|
||||
{
|
||||
ZoneScopedN("InterestSystem::Phase1_GridRebuild");
|
||||
m_backend->begin_frame();
|
||||
@@ -79,7 +54,6 @@ public:
|
||||
});
|
||||
}
|
||||
|
||||
// ── Phase 2: Per-subscription deltas ─────────────────────────────────
|
||||
{
|
||||
ZoneScopedN("InterestSystem::Phase2_Deltas");
|
||||
|
||||
|
||||
@@ -39,16 +39,16 @@ int main() {
|
||||
tw::Transform* transformA = zoneA.registry().try_get<tw::Transform>(entityA);
|
||||
transformA->set_position(glm::vec3(970.0f, 0.0f, 0.0f));
|
||||
|
||||
zoneA.tick(0, 0.1f);
|
||||
zoneB.tick(0, 0.1f);
|
||||
zoneA.update(0, 0.1f);
|
||||
zoneB.update(0, 0.1f);
|
||||
|
||||
interest = zoneA.get_interest(1);
|
||||
|
||||
transformA = zoneA.registry().try_get<tw::Transform>(entityA);
|
||||
transformA->set_position(glm::vec3(1020.0f, 0.0f, 0.0f));
|
||||
|
||||
zoneA.tick(1, 0.1f);
|
||||
zoneB.tick(1, 0.1f);
|
||||
zoneA.update(1, 0.1f);
|
||||
zoneB.update(1, 0.1f);
|
||||
|
||||
interest = zoneA.get_interest(1);
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ public:
|
||||
return m_last_interval_ms / (double)IN_SECOND;
|
||||
}
|
||||
|
||||
bool update() {
|
||||
bool has_ticked() {
|
||||
auto now = Clock::now();
|
||||
|
||||
m_last_interval_ms = duration_cast<Millis>(now - m_last_point).count();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
#include <vector>
|
||||
#include <glm/glm.hpp>
|
||||
|
||||
#include "metrics/HistoryBuffer.hpp"
|
||||
@@ -20,16 +20,15 @@ private:
|
||||
HistoryBuffer<Clock::time_point, glm::vec3> m_history;
|
||||
HistoryBuffer<Clock::time_point, glm::vec3> m_position_history;
|
||||
|
||||
// Frame-indexed input ring, capacity 64
|
||||
struct InputSlot {
|
||||
uint32_t frame;
|
||||
glm::vec3 input;
|
||||
bool valid;
|
||||
};
|
||||
std::array<InputSlot, 64> m_input_ring;
|
||||
// buffers inputs with history frame_idx stamp. Can be then read with get_input(frame_idx)
|
||||
std::vector<InputSlot> m_input_ring;
|
||||
|
||||
uint32_t m_last_input_frame;
|
||||
glm::vec3 m_last_input;
|
||||
|
||||
uint32_t m_frame_idx;
|
||||
|
||||
@@ -42,11 +41,10 @@ public:
|
||||
m_speed(speed),
|
||||
m_history(Clock::now(), glm::vec3(), 10 * 20),
|
||||
m_position_history(Clock::now(), glm::vec3(), 10 * 20),
|
||||
m_input_ring(64),
|
||||
m_last_input_frame(0),
|
||||
m_last_input(0.0f),
|
||||
m_frame_idx(0)
|
||||
{
|
||||
// Initialize input ring
|
||||
for(auto& slot : m_input_ring) {
|
||||
slot.frame = 0;
|
||||
slot.input = glm::vec3(0.0f);
|
||||
@@ -61,31 +59,34 @@ public:
|
||||
m_input_ring[idx].valid = true;
|
||||
|
||||
m_last_input_frame = frame_idx;
|
||||
m_last_input = input;
|
||||
}
|
||||
|
||||
void set_frame_idx(uint32_t idx) {
|
||||
m_frame_idx = idx;
|
||||
}
|
||||
|
||||
glm::vec3 input() const {
|
||||
return m_last_input;
|
||||
/**
|
||||
* Returns the most recently received input, or zero vector if none was ever set.
|
||||
* This is what the simulation consumes: frame stamps come from the client's own
|
||||
* counter, so they cannot be looked up by a server-side frame number.
|
||||
*/
|
||||
glm::vec3 latest_input() const {
|
||||
const auto& slot = m_input_ring[m_last_input_frame % m_input_ring.size()];
|
||||
return slot.valid ? slot.input : glm::vec3(0.0f);
|
||||
}
|
||||
|
||||
// Returns the input for the specified frame, or falls back to the most recently set input
|
||||
// if the frame slot has been overwritten or never written
|
||||
/**
|
||||
* Returns input for specific frame or zero vector if not yet set
|
||||
*/
|
||||
glm::vec3 input(uint32_t frame_idx) const {
|
||||
size_t idx = frame_idx % m_input_ring.size();
|
||||
const auto& slot = m_input_ring[idx];
|
||||
|
||||
// If slot contains the exact frame we're looking for, return it
|
||||
if(slot.valid && slot.frame == frame_idx) {
|
||||
return slot.input;
|
||||
}
|
||||
|
||||
// Otherwise, fall back to the most recent input
|
||||
// This handles dropped packets (slot never written) or wraparound (slot overwritten)
|
||||
return m_last_input;
|
||||
return glm::vec3(0.0f);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -133,7 +133,7 @@ void JoltPhysicsWorld::update(uint32_t frame_idx, double delta_time) {
|
||||
bool mAllowSliding = true;
|
||||
|
||||
if (player_controls_horizontal_velocity) {
|
||||
glm::vec3 input = controller.input(frame_idx);
|
||||
glm::vec3 input = controller.latest_input();
|
||||
// Smooth the player input
|
||||
JPH::Vec3 vel = JPH::Vec3(input.x, input.y, input.z) * controller.speed();
|
||||
float inertia_factor = 1.0f - std::exp(-delta_time * 10.0f); // 10.0 = responsiveness tuning knob
|
||||
|
||||
Reference in New Issue
Block a user