#1 - ClientWorldController refactoring

This commit is contained in:
Martin Slachta
2026-08-03 23:30:18 +02:00
parent f4174eb0c7
commit a097f4d4b0
35 changed files with 899 additions and 1010 deletions
+3 -2
View File
@@ -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}
+2 -2
View File
@@ -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);
};
}
@@ -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) {
@@ -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);
};
}
+6 -1
View File
@@ -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;
+5 -1
View File
@@ -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;
+5 -2
View File
@@ -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)
{ }
};
-8
View File
@@ -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);
+1 -23
View File
@@ -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");
+7 -25
View File
@@ -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
+2 -7
View File
@@ -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");
+1 -27
View File
@@ -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");
+4 -4
View File
@@ -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);
+1 -1
View File
@@ -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();
+16 -15
View File
@@ -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);
}
};
+1 -1
View File
@@ -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