initial
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
project(tw_server)
|
||||
|
||||
# set(CMAKE_CXX_CLANG_TIDY "/usr/bin/clang-tidy;-checks=*")
|
||||
|
||||
file(GLOB FILES
|
||||
src/ZoneServer.cpp
|
||||
src/ZoneCoordinator.cpp
|
||||
src/ZoneClusterLink.cpp
|
||||
src/ZoneManager.cpp
|
||||
src/interest_management/*.cpp
|
||||
src/replication/*.cpp
|
||||
src/systems/*.cpp
|
||||
src/monitoring/*.cpp
|
||||
src/network/*.cpp
|
||||
)
|
||||
|
||||
add_library(tw_server_lib STATIC ${FILES})
|
||||
|
||||
target_include_directories(tw_server_lib
|
||||
PUBLIC
|
||||
${PROJECT_SOURCE_DIR}/src/
|
||||
)
|
||||
|
||||
target_link_libraries(tw_server_lib
|
||||
PUBLIC
|
||||
towards
|
||||
tw::network
|
||||
tw::protocol
|
||||
tw::serialization
|
||||
glm::glm
|
||||
EnTT::EnTT
|
||||
Jolt
|
||||
protobuf::libprotobuf
|
||||
${Boost_LIBRARIES}
|
||||
Tracy::TracyClient
|
||||
pqxx
|
||||
pq
|
||||
)
|
||||
|
||||
add_executable(${PROJECT_NAME} src/server.cpp)
|
||||
|
||||
target_link_libraries(tw_server
|
||||
PUBLIC
|
||||
tw_server_lib
|
||||
)
|
||||
|
||||
ADD_SUBDIRECTORY(./tests/)
|
||||
@@ -0,0 +1,3 @@
|
||||
# Server
|
||||
|
||||
The authoritative server executable source code.
|
||||
@@ -0,0 +1,24 @@
|
||||
#pragma once
|
||||
|
||||
#include "protocol/quicr/QuicrConnection.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace tw::net {
|
||||
|
||||
struct PlayerSession {
|
||||
public:
|
||||
uint32_t session_id;
|
||||
|
||||
quicr::QuicrConnection* quicr_connection;
|
||||
|
||||
uint32_t last_frame;
|
||||
|
||||
PlayerSession(uint32_t session_id, quicr::QuicrConnection* quicr_connection) :
|
||||
session_id(session_id),
|
||||
quicr_connection(std::move(quicr_connection)),
|
||||
last_frame(0)
|
||||
{ }
|
||||
};
|
||||
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
#include "ZoneClusterLink.hpp"
|
||||
|
||||
#include "Address.hpp"
|
||||
|
||||
#include <spdlog/spdlog.h>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace tw::net {
|
||||
|
||||
ZoneClusterLink::ZoneClusterLink(int port)
|
||||
: m_endpoint(quicr::QuicrEndpoint::create().value()),
|
||||
m_listener(quicr::QuicrConnectionListener::listen(m_endpoint.get()).value())
|
||||
{
|
||||
if (auto r = m_endpoint->bind(port); !r) {
|
||||
throw std::runtime_error("ZoneClusterLink: failed to bind to port " + std::to_string(port));
|
||||
}
|
||||
spdlog::info("ZoneClusterLink listening on port {}", port);
|
||||
}
|
||||
|
||||
void ZoneClusterLink::update() {
|
||||
m_endpoint->poll();
|
||||
|
||||
quicr::QuicrConnection* connection;
|
||||
while ((connection = m_listener->listen())) {
|
||||
spdlog::info("Zone peer connected from {}", connection->address().to_string());
|
||||
m_peers.push_back(connection);
|
||||
}
|
||||
}
|
||||
|
||||
quicr::QuicrConnection* ZoneClusterLink::connect_to_peer(const std::string& host, int port) {
|
||||
auto result = m_endpoint->connect(Address(host, port));
|
||||
if (!result) {
|
||||
spdlog::error("ZoneClusterLink: failed to connect to peer {}:{}", host, port);
|
||||
return nullptr;
|
||||
}
|
||||
quicr::QuicrConnection* conn = result.value();
|
||||
m_peers.push_back(conn);
|
||||
spdlog::info("ZoneClusterLink: connected to peer {}:{}", host, port);
|
||||
return conn;
|
||||
}
|
||||
|
||||
} // namespace tw::net
|
||||
@@ -0,0 +1,64 @@
|
||||
#pragma once
|
||||
|
||||
#include "MessageRegistry.hpp"
|
||||
#include "protocol/quicr/QuicrConnection.hpp"
|
||||
#include "protocol/quicr/QuicrConnectionListener.hpp"
|
||||
#include "protocol/quicr/QuicrEndpoint.hpp"
|
||||
|
||||
#include <memory>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <spdlog/spdlog.h>
|
||||
|
||||
namespace tw::net {
|
||||
|
||||
// Listens on a dedicated QUICr port for incoming zone-server peer connections
|
||||
// and opens outgoing connections to known peers.
|
||||
//
|
||||
// All connections (incoming and outgoing) are collected in m_peers so that
|
||||
// broadcast messages (ZoneHello, ZoneBye) reach every peer uniformly.
|
||||
//
|
||||
// Accepted QuicrConnection* pointers are owned by the endpoint and remain valid
|
||||
// for the lifetime of this object.
|
||||
class ZoneClusterLink {
|
||||
std::unique_ptr<quicr::QuicrEndpoint> m_endpoint;
|
||||
std::unique_ptr<quicr::QuicrConnectionListener> m_listener;
|
||||
std::vector<quicr::QuicrConnection*> m_peers;
|
||||
|
||||
public:
|
||||
explicit ZoneClusterLink(int port);
|
||||
|
||||
// Poll for datagrams and accept any pending peer connections.
|
||||
void update();
|
||||
|
||||
// Connect to a remote zone peer and register it in the peer list.
|
||||
quicr::QuicrConnection* connect_to_peer(const std::string& host, int port);
|
||||
|
||||
// Serialize and send a protobuf message to a single peer.
|
||||
template<typename T>
|
||||
void send_mesg(quicr::QuicrConnection* conn, const T& msg) {
|
||||
std::string payload;
|
||||
if (!msg.SerializeToString(&payload)) {
|
||||
spdlog::error("ZoneClusterLink: failed to serialize message");
|
||||
return;
|
||||
}
|
||||
std::vector<std::byte> bytes(payload.size() + sizeof(uint32_t));
|
||||
uint32_t type = tw::Message<T>::value;
|
||||
memcpy(bytes.data(), &type, sizeof(type));
|
||||
memcpy(bytes.data() + sizeof(uint32_t), payload.data(), payload.size());
|
||||
conn->send_message(bytes, false);
|
||||
}
|
||||
|
||||
// Broadcast a protobuf message to all connected peers.
|
||||
template<typename T>
|
||||
void broadcast(const T& msg) {
|
||||
for (auto* peer : m_peers)
|
||||
send_mesg(peer, msg);
|
||||
}
|
||||
|
||||
std::span<quicr::QuicrConnection* const> peers() const { return m_peers; }
|
||||
};
|
||||
|
||||
} // namespace tw::net
|
||||
@@ -0,0 +1,104 @@
|
||||
#include "ZoneCoordinator.hpp"
|
||||
#include "systems/Interest.hpp"
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <sys/file.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
|
||||
#include <spdlog/spdlog.h>
|
||||
|
||||
namespace tw::net {
|
||||
|
||||
ZoneCoordinator::ZoneCoordinator(std::string host, int port, std::string csv_path, im::AreaBounds bounds, uint32_t max_zones)
|
||||
: m_own_host(std::move(host)),
|
||||
m_own_port(port),
|
||||
m_csv_path(std::move(csv_path)),
|
||||
m_bounds(bounds),
|
||||
m_max_zones(max_zones)
|
||||
{
|
||||
}
|
||||
|
||||
// Recursively bisects `world` to find the cell for `index` out of `total` zones.
|
||||
// Splits along the longest axis; left/bottom half gets the lower indices.
|
||||
static im::AreaBounds compute_zone_bounds(uint32_t index, uint32_t total, im::AreaBounds world) {
|
||||
if (total <= 1) return world;
|
||||
|
||||
uint32_t left_count = total / 2;
|
||||
float dx = world.max.x - world.min.x;
|
||||
float dz = world.max.y - world.min.y;
|
||||
|
||||
im::AreaBounds left, right;
|
||||
if (dx >= dz) {
|
||||
float mid = (world.min.x + world.max.x) * 0.5f;
|
||||
left = { world.min, { mid, world.max.y } };
|
||||
right = { { mid, world.min.y }, world.max };
|
||||
} else {
|
||||
float mid = (world.min.y + world.max.y) * 0.5f;
|
||||
left = { world.min, { world.max.x, mid } };
|
||||
right = { { world.min.x, mid }, world.max };
|
||||
}
|
||||
|
||||
if (index < left_count)
|
||||
return compute_zone_bounds(index, left_count, left);
|
||||
else
|
||||
return compute_zone_bounds(index - left_count, total - left_count, right);
|
||||
}
|
||||
|
||||
ZoneRegistration ZoneCoordinator::register_zone() {
|
||||
int fd = open(m_csv_path.c_str(), O_RDWR | O_CREAT, 0644);
|
||||
if (fd == -1) {
|
||||
spdlog::error("ZoneCoordinator: failed to open registry file '{}'", m_csv_path);
|
||||
return {};
|
||||
}
|
||||
|
||||
flock(fd, LOCK_EX);
|
||||
|
||||
// Read all existing entries.
|
||||
// CSV format: id,host,port,min_x,min_z,max_x,max_z
|
||||
std::vector<ZoneAddress> peers;
|
||||
{
|
||||
std::ifstream in(m_csv_path);
|
||||
std::string line;
|
||||
while (std::getline(in, line)) {
|
||||
if (line.empty()) continue;
|
||||
std::istringstream ss(line);
|
||||
std::string id_s, host, port_s, min_x_s, min_z_s, max_x_s, max_z_s;
|
||||
if (!std::getline(ss, id_s, ',')) continue;
|
||||
if (!std::getline(ss, host, ',')) continue;
|
||||
if (!std::getline(ss, port_s, ',')) continue;
|
||||
if (!std::getline(ss, min_x_s,',')) continue;
|
||||
if (!std::getline(ss, min_z_s,',')) continue;
|
||||
if (!std::getline(ss, max_x_s,',')) continue;
|
||||
if (!std::getline(ss, max_z_s,',')) continue;
|
||||
peers.push_back({
|
||||
static_cast<uint32_t>(std::stoul(id_s)),
|
||||
std::move(host),
|
||||
std::stoi(port_s),
|
||||
{ { std::stof(min_x_s), std::stof(min_z_s) },
|
||||
{ std::stof(max_x_s), std::stof(max_z_s) } }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
uint32_t assigned_id = static_cast<uint32_t>(peers.size()) + 1;
|
||||
im::AreaBounds new_bounds = compute_zone_bounds(peers.size(), m_max_zones, m_bounds);
|
||||
|
||||
{
|
||||
std::ofstream out(m_csv_path, std::ios::app);
|
||||
out << assigned_id << ','
|
||||
<< m_own_host << ','
|
||||
<< m_own_port << ','
|
||||
<< new_bounds.min.x << ',' << new_bounds.min.y << ','
|
||||
<< new_bounds.max.x << ',' << new_bounds.max.y << '\n';
|
||||
}
|
||||
|
||||
flock(fd, LOCK_UN);
|
||||
close(fd);
|
||||
|
||||
return { assigned_id, new_bounds, std::move(peers) };
|
||||
}
|
||||
|
||||
} // namespace tw::net
|
||||
@@ -0,0 +1,56 @@
|
||||
#pragma once
|
||||
|
||||
#include "systems/Interest.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace tw::net {
|
||||
|
||||
struct ZoneAddress {
|
||||
uint32_t id;
|
||||
std::string host;
|
||||
int port;
|
||||
im::AreaBounds bounds;
|
||||
};
|
||||
|
||||
struct ZoneRegistration {
|
||||
uint32_t assigned_id;
|
||||
im::AreaBounds area_bounds;
|
||||
std::vector<ZoneAddress> peers;
|
||||
};
|
||||
|
||||
// Service-discovery stub: each zone server registers its zones here and
|
||||
// receives back the addresses of all previously registered zones so it can
|
||||
// establish peer connections.
|
||||
//
|
||||
// First implementation persists registrations to a shared CSV file
|
||||
// (id,host,port,min_x,min_z,max_x,max_z). Concurrent access is serialised
|
||||
// with an advisory flock so multiple processes can share the same file safely.
|
||||
//
|
||||
// Intended to be replaced by an etcd-backed implementation later.
|
||||
class ZoneCoordinator {
|
||||
std::string m_own_host;
|
||||
int m_own_port;
|
||||
std::string m_csv_path;
|
||||
im::AreaBounds m_bounds;
|
||||
uint32_t m_max_zones;
|
||||
|
||||
public:
|
||||
ZoneCoordinator(
|
||||
std::string host,
|
||||
int port,
|
||||
std::string csv_path = "/tmp/tw_zone_registry.csv",
|
||||
im::AreaBounds bounds = {{ -5000.f, -5000.f }, { 5000.f, 5000.f }},
|
||||
uint32_t max_zones = 2
|
||||
);
|
||||
|
||||
// Assigns this zone a unique, non-overlapping slice of the world (determined
|
||||
// by its registration index and max_zones), appends it to the registry, and
|
||||
// returns the authoritative bounds together with the addresses of all
|
||||
// previously registered peers.
|
||||
ZoneRegistration register_zone();
|
||||
};
|
||||
|
||||
} // namespace tw::net
|
||||
@@ -0,0 +1,177 @@
|
||||
#include "ZoneManager.hpp"
|
||||
|
||||
#include "world/CharacterBody.hpp"
|
||||
#include "world/CharacterController.hpp"
|
||||
#include "world/Transform.hpp"
|
||||
#include "world/WorldEntity.hpp"
|
||||
|
||||
#include <spdlog/spdlog.h>
|
||||
#include <tracy/Tracy.hpp>
|
||||
|
||||
namespace tw::net {
|
||||
|
||||
ZoneManager::ZoneManager(im::AreaBounds zone_bounds)
|
||||
: m_world(std::make_unique<World>()),
|
||||
m_physics_world(m_world.get()),
|
||||
m_spatial_backend(std::make_unique<SpatialBackendType>(zone_bounds.min.x, zone_bounds.max.x, zone_bounds.min.y, zone_bounds.max.y)),
|
||||
m_interest_system(std::make_unique<im::InterestSystem<SpatialBackendType>>(
|
||||
m_world.get(), m_spatial_backend.get()
|
||||
))
|
||||
{}
|
||||
|
||||
// ── ZoneProxy interface ───────────────────────────────────────────────────────
|
||||
|
||||
im::AreaBounds ZoneManager::area() const {
|
||||
return {
|
||||
{ (float)m_spatial_backend->world_min_x(), (float)m_spatial_backend->world_min_z() },
|
||||
{ (float)m_spatial_backend->world_max_x(), (float)m_spatial_backend->world_max_z() }
|
||||
};
|
||||
}
|
||||
|
||||
void ZoneManager::transfer_entity(EntityInfo&& info) {
|
||||
spdlog::info("Transfered entity to zone ({} {}) ({} {})", area().min.x, area().min.y, area().max.x, area().max.y);
|
||||
spawn_entity(std::move(info));
|
||||
}
|
||||
|
||||
// ── Zone management ───────────────────────────────────────────────────────────
|
||||
|
||||
entt::entity ZoneManager::spawn_entity(EntityInfo&& info) {
|
||||
entt::entity entity = m_world->registry().create();
|
||||
|
||||
m_world->registry().emplace<WorldEntity>(entity, info.name, (uint32_t)entity);
|
||||
m_world->registry().emplace<Transform>(entity, Transform(info.position));
|
||||
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)),
|
||||
info.position
|
||||
)
|
||||
);
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
void ZoneManager::add_client(im::InterestId interest_id, entt::entity entity) {
|
||||
m_client_entities[interest_id] = entity;
|
||||
m_entity_sessions[entity] = interest_id;
|
||||
m_interest_system->set_entity_interest(interest_id, entity);
|
||||
}
|
||||
|
||||
entt::entity ZoneManager::client_entity(im::InterestId interest_id) const {
|
||||
auto it = m_client_entities.find(interest_id);
|
||||
return it != m_client_entities.end() ? it->second : entt::null;
|
||||
}
|
||||
|
||||
void ZoneManager::on_player_move(SessionId session_id, mmo::PlayerMoveMessage&& message) {
|
||||
ZoneScoped;
|
||||
entt::entity entity = client_entity(session_id);
|
||||
if (entity == entt::null) return;
|
||||
|
||||
auto* controller = m_world->registry().try_get<CharacterController>(entity);
|
||||
if (controller) {
|
||||
controller->set_input(
|
||||
message.frame_idx(),
|
||||
glm::vec3(message.input().x(), message.input().y(), message.input().z())
|
||||
);
|
||||
} else {
|
||||
spdlog::error("Player {} has no character controller", (uint32_t)entity);
|
||||
}
|
||||
}
|
||||
|
||||
void ZoneManager::register_neighbor_zone(ZoneProxy* neighbor) {
|
||||
im::InterestId id = m_next_neighbor_id++;
|
||||
m_neighbors.push_back({ neighbor, id });
|
||||
|
||||
// Expand bounds so entities approaching the border appear in the interest
|
||||
// system before they cross. Transfer still uses the true (unexpanded) bounds.
|
||||
auto b = neighbor->area();
|
||||
im::AreaBounds expanded {
|
||||
{ b.min.x - kNeighborBorderOverlap, b.min.y - kNeighborBorderOverlap },
|
||||
{ b.max.x + kNeighborBorderOverlap, b.max.y + kNeighborBorderOverlap }
|
||||
};
|
||||
m_interest_system->set_area_interest(id, expanded);
|
||||
}
|
||||
|
||||
const im::Interest* ZoneManager::get_interest(im::InterestId id) const {
|
||||
return m_interest_system->get_interest(id);
|
||||
}
|
||||
|
||||
// ── Private helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
void ZoneManager::check_neighbor_transfers() {
|
||||
ZoneScopedN("ZoneManager::check_neighbor_transfers");
|
||||
|
||||
for (const auto& [proxy, interest_id] : m_neighbors) {
|
||||
const im::Interest* interest = m_interest_system->get_interest(interest_id);
|
||||
if (!interest || interest->interest().empty()) continue;
|
||||
|
||||
// Only transfer entities that have actually crossed into the neighbour's
|
||||
// true area (not just the expanded interest margin).
|
||||
auto true_bounds = proxy->area();
|
||||
|
||||
std::vector<entt::entity> to_transfer;
|
||||
for (entt::entity entity : interest->interest()) {
|
||||
const Transform* transform = m_world->registry().try_get<Transform>(entity);
|
||||
if (!transform) continue;
|
||||
|
||||
glm::vec3 pos = transform->position();
|
||||
if (pos.x >= true_bounds.min.x && pos.x <= true_bounds.max.x &&
|
||||
pos.z >= true_bounds.min.y && pos.z <= true_bounds.max.y) {
|
||||
to_transfer.push_back(entity);
|
||||
}
|
||||
}
|
||||
|
||||
for (entt::entity entity : to_transfer) {
|
||||
auto* world_entity = m_world->registry().try_get<WorldEntity>(entity);
|
||||
auto* transform = m_world->registry().try_get<Transform>(entity);
|
||||
if (!world_entity || !transform) continue;
|
||||
|
||||
EntityInfo info {
|
||||
.name = world_entity->name,
|
||||
.position = transform->position()
|
||||
};
|
||||
|
||||
// Remove client tracking on the sending side.
|
||||
// The owner (e.g. ZoneServer) is responsible for calling add_client on
|
||||
// the receiving zone with the correct session mapping.
|
||||
auto session_it = m_entity_sessions.find(entity);
|
||||
if (session_it != m_entity_sessions.end()) {
|
||||
spdlog::info(
|
||||
"Transferring interest {} entity {} to neighbour zone",
|
||||
session_it->second, (uint32_t)entity
|
||||
);
|
||||
m_interest_system->remove_interest(session_it->second);
|
||||
m_client_entities.erase(session_it->second);
|
||||
m_entity_sessions.erase(session_it);
|
||||
}
|
||||
|
||||
proxy->transfer_entity(std::move(info));
|
||||
|
||||
// TODO: notify physics world to release the Jolt CharacterVirtual.
|
||||
m_world->registry().destroy(entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tick ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
void ZoneManager::tick(uint32_t frame_idx, float delta_time) {
|
||||
ZoneScopedN("ZoneManager::tick");
|
||||
|
||||
FrameMarkStart("Interest System");
|
||||
m_interest_system->update();
|
||||
FrameMarkEnd("Interest System");
|
||||
|
||||
check_neighbor_transfers();
|
||||
|
||||
FrameMarkStart("World step");
|
||||
m_world->step(delta_time);
|
||||
FrameMarkEnd("World step");
|
||||
|
||||
FrameMarkStart("Physics step");
|
||||
m_physics_world.step(frame_idx, delta_time);
|
||||
FrameMarkEnd("Physics step");
|
||||
}
|
||||
|
||||
} // namespace tw::net
|
||||
@@ -0,0 +1,90 @@
|
||||
#pragma once
|
||||
|
||||
#include "ZoneProxy.hpp"
|
||||
#include "interest_management/FixedGrid.hpp"
|
||||
#include "network/SessionId.hpp"
|
||||
#include "systems/Interest.hpp"
|
||||
#include "systems/InterestSystem.hpp"
|
||||
#include "world/JoltPhysicsWorld.hpp"
|
||||
#include "world/World.hpp"
|
||||
|
||||
#include "PlayerMove.pb.h"
|
||||
|
||||
#include <entt/entt.hpp>
|
||||
#include <glm/glm.hpp>
|
||||
#include <memory>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
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;
|
||||
|
||||
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::vector<NeighborEntry> m_neighbors;
|
||||
uint32_t m_next_neighbor_id = 0x80000000u;
|
||||
|
||||
entt::entity client_entity(im::InterestId interest_id) const;
|
||||
void check_neighbor_transfers();
|
||||
|
||||
public:
|
||||
entt::registry& registry() { return m_world->registry(); }
|
||||
|
||||
im::InterestSystem<SpatialBackendType>* interest() const {
|
||||
return m_interest_system.get();
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// Runs one tick: interest queries, neighbour transfer checks, world step, physics step.
|
||||
void tick(uint32_t frame_idx, float delta_time);
|
||||
};
|
||||
|
||||
} // namespace tw::net
|
||||
@@ -0,0 +1,33 @@
|
||||
#pragma once
|
||||
|
||||
#include "systems/Interest.hpp"
|
||||
|
||||
#include <glm/glm.hpp>
|
||||
#include <string>
|
||||
|
||||
namespace tw::net {
|
||||
|
||||
// Pure game-state snapshot used to transfer an entity between zones.
|
||||
// Session/client mapping is the owner's (e.g. ZoneServer's) responsibility.
|
||||
struct EntityInfo {
|
||||
std::string name;
|
||||
glm::vec3 position;
|
||||
};
|
||||
|
||||
// Abstract handle to a zone from the outside.
|
||||
// Implementations may be local (same process) or remote (over the network).
|
||||
class ZoneProxy {
|
||||
public:
|
||||
virtual ~ZoneProxy() = default;
|
||||
|
||||
// The geographic area this zone owns in the XZ plane.
|
||||
virtual im::AreaBounds area() const = 0;
|
||||
|
||||
// Accept an entity transferred from another zone.
|
||||
// The callee spawns the entity in its own world.
|
||||
// Session routing (calling add_client on the receiving zone) is the
|
||||
// owner's responsibility and must happen separately.
|
||||
virtual void transfer_entity(EntityInfo&& info) = 0;
|
||||
};
|
||||
|
||||
} // namespace tw::net
|
||||
@@ -0,0 +1,154 @@
|
||||
#include "ZoneServer.hpp"
|
||||
|
||||
#include "Cluster.pb.h"
|
||||
#include "monitoring/NullMetricsReporter.hpp"
|
||||
#include "monitoring/TimescaleDbMetricsReporter.hpp"
|
||||
#include "runtime/LockStep.hpp"
|
||||
|
||||
#include <csignal>
|
||||
#include <cstdlib>
|
||||
#include <spdlog/spdlog.h>
|
||||
#include <tracy/Tracy.hpp>
|
||||
|
||||
namespace tw::net {
|
||||
|
||||
static std::unique_ptr<NetworkMetricsReporter> make_reporter(const ZoneServerConfiguration& config) {
|
||||
if (config.timescaledb)
|
||||
return std::make_unique<TimescaleDbMetricsReporter>(*config.timescaledb);
|
||||
return std::make_unique<NullMetricsReporter>();
|
||||
}
|
||||
|
||||
ZoneServer::ZoneServer(ZoneServerConfiguration config)
|
||||
:
|
||||
m_metrics_reporter(make_reporter(config)),
|
||||
m_player_session_registry(std::make_unique<PlayerSessionRegistry>()),
|
||||
m_network_receiver(std::make_unique<NetworkReceiver>(
|
||||
m_player_session_registry.get(), config.quicr_port, m_metrics_reporter.get()
|
||||
)),
|
||||
m_message_dispatcher(std::make_unique<MessageDispatcher>(m_network_receiver.get())),
|
||||
m_replicator(std::make_unique<StateReplicator<SpatialBackendType>>(
|
||||
m_player_session_registry.get(),
|
||||
m_network_receiver.get()
|
||||
)),
|
||||
m_cluster_link(config.cluster_port),
|
||||
m_coordinator("127.0.0.1", config.cluster_port)
|
||||
{
|
||||
auto [assigned_id, area_bounds, peers] = m_coordinator.register_zone();
|
||||
m_own_zone_id = assigned_id;
|
||||
|
||||
m_zones.push_back(std::make_unique<ZoneManager>(area_bounds));
|
||||
|
||||
spdlog::info("Registered as zone {} ({},{}) ({},{})",
|
||||
assigned_id,
|
||||
area_bounds.min.x, area_bounds.min.y,
|
||||
area_bounds.max.x, area_bounds.max.y);
|
||||
|
||||
mmo::cluster::ZoneHello hello;
|
||||
hello.mutable_zone()->set_id(assigned_id);
|
||||
hello.mutable_zone()->mutable_bounds()->set_min_x(area_bounds.min.x);
|
||||
hello.mutable_zone()->mutable_bounds()->set_min_z(area_bounds.min.y);
|
||||
hello.mutable_zone()->mutable_bounds()->set_max_x(area_bounds.max.x);
|
||||
hello.mutable_zone()->mutable_bounds()->set_max_z(area_bounds.max.y);
|
||||
|
||||
for (const auto& peer : peers) {
|
||||
spdlog::info("Connecting to peer zone {} at {}:{}", peer.id, peer.host, peer.port);
|
||||
auto* conn = m_cluster_link.connect_to_peer(peer.host, peer.port);
|
||||
if (conn)
|
||||
m_cluster_link.send_mesg(conn, hello);
|
||||
}
|
||||
}
|
||||
|
||||
void ZoneServer::player_update_handler(SessionId session_id, mmo::PlayerMoveMessage&& message) {
|
||||
auto it = m_session_zone.find(session_id);
|
||||
if (it == m_session_zone.end()) return;
|
||||
it->second->on_player_move(session_id, std::move(message));
|
||||
}
|
||||
|
||||
static std::atomic<bool> quit(false);
|
||||
|
||||
static void got_signal(int) {
|
||||
quit.store(true);
|
||||
}
|
||||
|
||||
static void register_signal_handler() {
|
||||
struct sigaction sa;
|
||||
memset(&sa, 0, sizeof(sa));
|
||||
sa.sa_handler = got_signal;
|
||||
sigfillset(&sa.sa_mask);
|
||||
sigaction(SIGINT, &sa, NULL);
|
||||
}
|
||||
|
||||
void ZoneServer::update_clients(uint32_t frame_idx) {
|
||||
m_network_receiver->update();
|
||||
m_message_dispatcher->drain_queue();
|
||||
|
||||
while (m_network_receiver->peek_new_session()) {
|
||||
auto session_id = m_network_receiver->pop_new_session();
|
||||
|
||||
glm::vec3 position = glm::vec3(
|
||||
(float)std::rand() / RAND_MAX * 20.0f,
|
||||
10.0f,
|
||||
(float)std::rand() / RAND_MAX * 20.0f
|
||||
);
|
||||
|
||||
ZoneManager* zone = m_zones.front().get();
|
||||
entt::entity entity = zone->spawn_entity({ .name = "Pepik", .position = position });
|
||||
zone->add_client(session_id, entity);
|
||||
m_session_zone[session_id] = zone;
|
||||
|
||||
spdlog::info("Client {} connected", session_id);
|
||||
}
|
||||
}
|
||||
|
||||
void ZoneServer::run() {
|
||||
LockStep lock_step(20);
|
||||
register_signal_handler();
|
||||
|
||||
uint32_t frame_idx = 1;
|
||||
|
||||
m_message_dispatcher->set_handler<mmo::PlayerMoveMessage>(
|
||||
[&](uint64_t session_id, mmo::PlayerMoveMessage mesg) {
|
||||
player_update_handler(static_cast<SessionId>(session_id), std::move(mesg));
|
||||
});
|
||||
|
||||
m_message_dispatcher->set_handler<mmo::chat::SendChatMessageRequest>(
|
||||
[&](uint64_t session_id, mmo::chat::SendChatMessageRequest mesg) {
|
||||
});
|
||||
|
||||
while (!quit.load()) {
|
||||
if (lock_step.wait_for_next_step()) { continue; }
|
||||
|
||||
m_cluster_link.update();
|
||||
|
||||
FrameMarkStart("Update clients");
|
||||
update_clients(frame_idx);
|
||||
FrameMarkEnd("Update clients");
|
||||
|
||||
for (auto& zone : m_zones) {
|
||||
zone->tick(frame_idx, lock_step.delta_time());
|
||||
|
||||
m_replicator->replicate(zone->registry(), zone->interest());
|
||||
}
|
||||
|
||||
m_network_receiver->update();
|
||||
|
||||
m_metrics_reporter->set_player_count(m_session_zone.size());
|
||||
m_metrics_reporter->tick();
|
||||
|
||||
frame_idx++;
|
||||
FrameMark;
|
||||
}
|
||||
|
||||
// Notify all peers that this zone server is going away.
|
||||
for (auto& zone : m_zones) {
|
||||
mmo::cluster::ZoneBye bye;
|
||||
bye.mutable_zone()->set_id(m_own_zone_id);
|
||||
bye.mutable_zone()->mutable_bounds()->set_min_x(zone->area().min.x);
|
||||
bye.mutable_zone()->mutable_bounds()->set_min_z(zone->area().min.y);
|
||||
bye.mutable_zone()->mutable_bounds()->set_max_x(zone->area().max.x);
|
||||
bye.mutable_zone()->mutable_bounds()->set_max_z(zone->area().max.y);
|
||||
m_cluster_link.broadcast(bye);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace tw::net
|
||||
@@ -0,0 +1,41 @@
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#include "PlayerMove.pb.h"
|
||||
#include "ZoneClusterLink.hpp"
|
||||
#include "ZoneCoordinator.hpp"
|
||||
#include "ZoneManager.hpp"
|
||||
#include "ZoneServerConfiguration.hpp"
|
||||
#include "monitoring/MetricsReporter.hpp"
|
||||
#include "network/MessageDispatcher.hpp"
|
||||
#include "network/NetworkReceiver.hpp"
|
||||
#include "network/PlayerSessionRegistry.hpp"
|
||||
#include "replication/StateReplicator.hpp"
|
||||
|
||||
namespace tw::net {
|
||||
|
||||
class ZoneServer {
|
||||
std::unique_ptr<NetworkMetricsReporter> m_metrics_reporter;
|
||||
std::unique_ptr<PlayerSessionRegistry> m_player_session_registry;
|
||||
std::unique_ptr<NetworkReceiver> m_network_receiver;
|
||||
std::unique_ptr<MessageDispatcher> m_message_dispatcher;
|
||||
std::unique_ptr<StateReplicator<SpatialBackendType>> m_replicator;
|
||||
ZoneClusterLink m_cluster_link;
|
||||
ZoneCoordinator m_coordinator;
|
||||
|
||||
std::vector<std::unique_ptr<ZoneManager>> m_zones;
|
||||
std::unordered_map<SessionId, ZoneManager*> m_session_zone;
|
||||
uint32_t m_own_zone_id = 0;
|
||||
|
||||
void player_update_handler(SessionId session_id, mmo::PlayerMoveMessage&& message);
|
||||
void update_clients(uint32_t frame_idx);
|
||||
|
||||
public:
|
||||
explicit ZoneServer(ZoneServerConfiguration config);
|
||||
void run();
|
||||
};
|
||||
|
||||
} // namespace tw::net
|
||||
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
struct PostgreSQLConnectionInfo {
|
||||
std::string host;
|
||||
int port = 5432;
|
||||
std::string dbname;
|
||||
std::string username;
|
||||
std::string password;
|
||||
|
||||
std::string to_string() const {
|
||||
return "host=" + host
|
||||
+ " port=" + std::to_string(port)
|
||||
+ " dbname=" + dbname
|
||||
+ " user=" + username
|
||||
+ " password=" + password;
|
||||
}
|
||||
};
|
||||
|
||||
struct TimescaleDbConfiguration {
|
||||
PostgreSQLConnectionInfo connection;
|
||||
std::string metrics_table = "zone_metrics";
|
||||
};
|
||||
|
||||
struct ZoneServerConfiguration {
|
||||
int quicr_port = 8101;
|
||||
int cluster_port = 8102;
|
||||
std::optional<TimescaleDbConfiguration> timescaledb;
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
#pragma once
|
||||
|
||||
#include "messages/PlayerMoveMessage.hpp"
|
||||
#include "world/World.hpp"
|
||||
#include "world/Transform.hpp"
|
||||
|
||||
namespace tw::server::im {
|
||||
|
||||
template<typename T>
|
||||
class InterestResult {
|
||||
public:
|
||||
const std::vector<T> m_entities;
|
||||
|
||||
InterestResult(const std::vector<T> entities) :
|
||||
m_entities(entities){
|
||||
}
|
||||
};
|
||||
|
||||
template<typename T>
|
||||
class DistanceInterestManagement {
|
||||
private:
|
||||
const World* m_world;
|
||||
|
||||
const float m_view_distance;
|
||||
|
||||
public:
|
||||
DistanceInterestManagement(const World* world, float view_distance) :
|
||||
m_world(world),
|
||||
m_view_distance(view_distance)
|
||||
{
|
||||
}
|
||||
|
||||
InterestResult<EntityPosition> query(entt::entity entity) {
|
||||
const Transform* ts = m_world->registry().try_get<Transform>(entity);
|
||||
|
||||
std::vector<EntityPosition> entities;
|
||||
m_world->registry().view<Transform>()
|
||||
.each([&](const auto e, const Transform& t) {
|
||||
if(true/* && ts->is_closer_than(t, m_view_distance) */) {
|
||||
entities.push_back(EntityPosition {
|
||||
.entity_id = (uint32_t)e,
|
||||
.position = t.position()
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return {entities};
|
||||
}
|
||||
};
|
||||
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <entt/entt.hpp>
|
||||
#include <glm/glm.hpp>
|
||||
#include <vector>
|
||||
|
||||
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:
|
||||
static constexpr uint32_t kCellSize = CELL_SIZE;
|
||||
static constexpr uint32_t kViewRadius = VIEW_RADIUS;
|
||||
static constexpr int32_t kNeighborRadius =
|
||||
static_cast<int32_t>((VIEW_RADIUS + CELL_SIZE - 1) / CELL_SIZE);
|
||||
|
||||
static_assert(kCellSize > 0, "CELL_SIZE must be positive");
|
||||
static_assert(kViewRadius > 0, "VIEW_RADIUS must be positive");
|
||||
|
||||
private:
|
||||
int32_t m_world_min_x, m_world_max_x;
|
||||
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(
|
||||
float world_x, float world_z
|
||||
) const {
|
||||
int32_t cx = static_cast<int32_t>((world_x - m_world_min_x) / CELL_SIZE);
|
||||
int32_t cz = static_cast<int32_t>((world_z - m_world_min_z) / CELL_SIZE);
|
||||
cx = std::max(0, std::min(cx, static_cast<int32_t>(m_cols) - 1));
|
||||
cz = std::max(0, std::min(cz, static_cast<int32_t>(m_rows) - 1));
|
||||
return { static_cast<uint32_t>(cx), static_cast<uint32_t>(cz) };
|
||||
}
|
||||
|
||||
[[nodiscard]] inline uint32_t cell_index(uint32_t cx, uint32_t cz) const {
|
||||
return cz * m_cols + cx;
|
||||
}
|
||||
|
||||
public:
|
||||
FixedGrid(int32_t min_x, int32_t max_x, int32_t min_z, int32_t max_z)
|
||||
: m_world_min_x(min_x), m_world_max_x(max_x),
|
||||
m_world_min_z(min_z), m_world_max_z(max_z),
|
||||
m_cols((max_x - min_x + CELL_SIZE - 1) / CELL_SIZE),
|
||||
m_rows((max_z - min_z + CELL_SIZE - 1) / CELL_SIZE),
|
||||
m_cells(m_cols * m_rows)
|
||||
{}
|
||||
|
||||
int32_t world_min_x() const { return m_world_min_x; }
|
||||
int32_t world_max_x() const { return m_world_max_x; }
|
||||
int32_t world_min_z() const { return m_world_min_z; }
|
||||
int32_t world_max_z() const { return m_world_max_z; }
|
||||
|
||||
// Clears all cell vectors without releasing their capacity.
|
||||
void begin_frame() {
|
||||
for (auto& cell : m_cells) {
|
||||
cell.clear();
|
||||
}
|
||||
}
|
||||
|
||||
void insert(entt::entity entity, glm::vec3 pos) {
|
||||
auto [cx, cz] = world_to_cell(pos.x, pos.z);
|
||||
m_cells[cell_index(cx, cz)].push_back(entity);
|
||||
}
|
||||
|
||||
// Queries all entities in cells that overlap the given XZ AABB [min, max].
|
||||
// Entities in cells that partially extend beyond the exact boundary are included —
|
||||
// acceptable for zone-border queries where slight over-inclusion is harmless.
|
||||
void query_area(glm::vec2 min, glm::vec2 max, std::vector<entt::entity>& out) const {
|
||||
auto [cx_min, cz_min] = world_to_cell(min.x, min.y);
|
||||
auto [cx_max, cz_max] = world_to_cell(max.x, max.y);
|
||||
|
||||
for (uint32_t cz = cz_min; cz <= cz_max; ++cz) {
|
||||
for (uint32_t cx = cx_min; cx <= cx_max; ++cx) {
|
||||
const auto& cell = m_cells[cell_index(cx, cz)];
|
||||
out.insert(out.end(), cell.begin(), cell.end());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void query_neighbors(glm::vec3 pos, std::vector<entt::entity>& out) {
|
||||
auto [center_x, center_z] = world_to_cell(pos.x, pos.z);
|
||||
|
||||
for (int32_t dz = -kNeighborRadius; dz <= kNeighborRadius; ++dz) {
|
||||
for (int32_t dx = -kNeighborRadius; dx <= kNeighborRadius; ++dx) {
|
||||
int32_t cx = static_cast<int32_t>(center_x) + dx;
|
||||
int32_t cz = static_cast<int32_t>(center_z) + dz;
|
||||
|
||||
if (cx < 0 || cx >= static_cast<int32_t>(m_cols) ||
|
||||
cz < 0 || cz >= static_cast<int32_t>(m_rows)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const auto& cell = m_cells[cell_index(cx, cz)];
|
||||
out.insert(out.end(), cell.begin(), cell.end());
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace tw::net::im
|
||||
@@ -0,0 +1,28 @@
|
||||
#pragma once
|
||||
|
||||
#include <concepts>
|
||||
#include <vector>
|
||||
|
||||
#include <entt/entt.hpp>
|
||||
#include <glm/glm.hpp>
|
||||
|
||||
namespace tw::net::im {
|
||||
|
||||
template<typename Backend>
|
||||
concept SpatialBackend = requires(
|
||||
Backend& backend,
|
||||
entt::entity entity,
|
||||
glm::vec3 position,
|
||||
glm::vec2 area_min,
|
||||
glm::vec2 area_max,
|
||||
std::vector<entt::entity>& out
|
||||
) {
|
||||
{ backend.begin_frame() } -> std::same_as<void>;
|
||||
{ backend.insert(entity, position) } -> std::same_as<void>;
|
||||
{ backend.query_neighbors(position, out) } -> std::same_as<void>;
|
||||
// Returns all entities in cells that overlap [area_min, area_max] (XZ plane).
|
||||
// May include entities in cells that partially extend beyond the AABB boundary.
|
||||
{ backend.query_area(area_min, area_max, out) } -> std::same_as<void>;
|
||||
};
|
||||
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <entt/entt.hpp>
|
||||
#include <glm/glm.hpp>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
namespace tw::net::im {
|
||||
|
||||
/**
|
||||
* Unbounded-world spatial hash grid backend.
|
||||
*
|
||||
* Uses an open hash map with cells indexed by packed (cell_x, cell_z) coordinates.
|
||||
* Supports any world coordinate range without pre-allocation.
|
||||
*
|
||||
* Neighborhood is always a square of (NEIGHBOR_RADIUS × NEIGHBOR_RADIUS) cells,
|
||||
* where NEIGHBOR_RADIUS = ceil(VIEW_RADIUS / CELL_SIZE).
|
||||
*
|
||||
* Template parameters:
|
||||
* CELL_SIZE: size of each cell (meters). Recommended: CELL_SIZE == VIEW_RADIUS.
|
||||
* VIEW_RADIUS: the subscription radius around a player (meters).
|
||||
*
|
||||
* Complexity:
|
||||
* begin_frame(): O(num_cells_with_entities)
|
||||
* insert(): O(1) amortized
|
||||
* query_neighbors(): O(NEIGHBOR_RADIUS²) hash lookups, O(avg entities per cell)
|
||||
*
|
||||
* ~15-20% slower than FixedGrid due to hash overhead per cell lookup,
|
||||
* but more flexible for unbounded or procedural worlds.
|
||||
*/
|
||||
template<uint32_t CELL_SIZE, uint32_t VIEW_RADIUS>
|
||||
class SpatialHashGrid {
|
||||
public:
|
||||
static constexpr uint32_t kCellSize = CELL_SIZE;
|
||||
static constexpr uint32_t kViewRadius = VIEW_RADIUS;
|
||||
static constexpr int32_t kNeighborRadius =
|
||||
static_cast<int32_t>((VIEW_RADIUS + CELL_SIZE - 1) / CELL_SIZE);
|
||||
|
||||
static_assert(kCellSize > 0, "CELL_SIZE must be positive");
|
||||
static_assert(kViewRadius > 0, "VIEW_RADIUS must be positive");
|
||||
|
||||
private:
|
||||
// Hash map: key is packed (cell_x << 32) | cell_z (both as uint32_t from int32_t)
|
||||
// value is a vector of entities in that cell.
|
||||
std::unordered_map<uint64_t, std::vector<entt::entity>> m_cells;
|
||||
|
||||
// Helper: pack cell coordinates into a single uint64_t key.
|
||||
// Negative int32_t coordinates are safely encoded via two's complement
|
||||
// cast to uint32_t.
|
||||
[[nodiscard]] static inline uint64_t make_key(int32_t cx, int32_t cz) {
|
||||
return (static_cast<uint64_t>(static_cast<uint32_t>(cx)) << 32) |
|
||||
static_cast<uint32_t>(cz);
|
||||
}
|
||||
|
||||
// Helper: convert world XZ coordinates to cell grid coordinates.
|
||||
[[nodiscard]] static inline std::pair<int32_t, int32_t> world_to_cell(
|
||||
float world_x, float world_z
|
||||
) {
|
||||
// Floor division to handle negative coordinates correctly.
|
||||
// For negative coordinates, std::floor ensures we round down, not toward zero.
|
||||
int32_t cx = static_cast<int32_t>(std::floor(world_x / CELL_SIZE));
|
||||
int32_t cz = static_cast<int32_t>(std::floor(world_z / CELL_SIZE));
|
||||
return {cx, cz};
|
||||
}
|
||||
|
||||
public:
|
||||
SpatialHashGrid() = default;
|
||||
|
||||
// Clears all cell vectors. Does not erase map entries or deallocate memory —
|
||||
// preserves bucket structure and vector capacity for reuse.
|
||||
// O(num_cells_with_entities) amortized O(1) per cell.
|
||||
void begin_frame() {
|
||||
for (auto& [key, cell] : m_cells) {
|
||||
cell.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Inserts an entity at the given world position into the appropriate cell.
|
||||
void insert(entt::entity entity, glm::vec3 pos) {
|
||||
auto [cx, cz] = world_to_cell(pos.x, pos.z);
|
||||
m_cells[make_key(cx, cz)].push_back(entity);
|
||||
}
|
||||
|
||||
// Queries all entities in cells that overlap the given XZ AABB [min, max].
|
||||
void query_area(glm::vec2 min, glm::vec2 max, std::vector<entt::entity>& out_entities) {
|
||||
auto [cx_min, cz_min] = world_to_cell(min.x, min.y);
|
||||
auto [cx_max, cz_max] = world_to_cell(max.x, max.y);
|
||||
|
||||
for (int32_t cz = cz_min; cz <= cz_max; ++cz) {
|
||||
for (int32_t cx = cx_min; cx <= cx_max; ++cx) {
|
||||
auto it = m_cells.find(make_key(cx, cz));
|
||||
if (it == m_cells.end()) continue;
|
||||
const auto& cell = it->second;
|
||||
out_entities.insert(out_entities.end(), cell.begin(), cell.end());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Queries the neighborhood around the given position.
|
||||
// Appends all entities in the neighborhood cells into out_entities.
|
||||
void query_neighbors(glm::vec3 pos, std::vector<entt::entity>& out_entities) {
|
||||
auto [center_x, center_z] = world_to_cell(pos.x, pos.z);
|
||||
|
||||
// Iterate the square neighborhood around (center_x, center_z)
|
||||
for (int32_t dz = -kNeighborRadius; dz <= kNeighborRadius; ++dz) {
|
||||
for (int32_t dx = -kNeighborRadius; dx <= kNeighborRadius; ++dx) {
|
||||
int32_t cx = center_x + dx;
|
||||
int32_t cz = center_z + dz;
|
||||
|
||||
uint64_t key = make_key(cx, cz);
|
||||
auto it = m_cells.find(key);
|
||||
if (it == m_cells.end()) {
|
||||
// No entities in this cell, skip
|
||||
continue;
|
||||
}
|
||||
|
||||
const auto& cell = it->second;
|
||||
out_entities.insert(
|
||||
out_entities.end(),
|
||||
cell.begin(),
|
||||
cell.end()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace tw::net::im
|
||||
@@ -0,0 +1,13 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
class NetworkMetricsReporter {
|
||||
public:
|
||||
virtual ~NetworkMetricsReporter() = default;
|
||||
|
||||
virtual void tick() = 0;
|
||||
virtual void add_outbound(size_t size) = 0;
|
||||
virtual void add_inbound(size_t size) = 0;
|
||||
virtual void set_player_count(size_t player_count) = 0;
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
#include "MetricsReporter.hpp"
|
||||
|
||||
class NullMetricsReporter : public NetworkMetricsReporter {
|
||||
public:
|
||||
void tick() override {}
|
||||
void add_outbound(size_t) override {}
|
||||
void add_inbound(size_t) override {}
|
||||
void set_player_count(size_t) override {}
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
#include "TimescaleDbMetricsReporter.hpp"
|
||||
#include <spdlog/spdlog.h>
|
||||
#include <tracy/Tracy.hpp>
|
||||
|
||||
TimescaleDbMetricsReporter::TimescaleDbMetricsReporter(const TimescaleDbConfiguration& config)
|
||||
: m_metrics_table(config.metrics_table),
|
||||
m_last_flush_time(std::chrono::steady_clock::now())
|
||||
{
|
||||
try {
|
||||
m_connection = pqxx::connection(config.connection.to_string());
|
||||
spdlog::info("Connected to TimescaleDB at {}:{}", config.connection.host, config.connection.port);
|
||||
} catch (const std::exception& e) {
|
||||
spdlog::error("Failed to connect to TimescaleDB: {}", e.what());
|
||||
}
|
||||
}
|
||||
|
||||
void TimescaleDbMetricsReporter::flush() {
|
||||
if (!m_connection) return;
|
||||
|
||||
ZoneScopedN("TimescaleDB Metrics Flush");
|
||||
|
||||
try {
|
||||
pqxx::work tx{*m_connection};
|
||||
|
||||
const auto table = m_connection->quote_name(m_metrics_table);
|
||||
tx.exec("INSERT INTO " + table + " (time, outbound, inbound, player_count) VALUES (NOW(), $1, $2, $3)",
|
||||
pqxx::params(m_outbound_bucket, m_inbound_bucket, m_player_count))
|
||||
.no_rows();
|
||||
tx.commit();
|
||||
|
||||
TracyPlot("outbound", (int64_t)m_outbound_bucket);
|
||||
TracyPlot("inbound", (int64_t)m_inbound_bucket);
|
||||
TracyPlot("player_count", (int64_t)m_player_count);
|
||||
} catch (const pqxx::broken_connection& e) {
|
||||
spdlog::error("TimescaleDB connection lost, disabling metrics: {}", e.what());
|
||||
m_connection.reset();
|
||||
} catch (const pqxx::sql_error& e) {
|
||||
spdlog::error("TimescaleDB insert failed (query: {}): {}", e.query(), e.what());
|
||||
} catch (const std::exception& e) {
|
||||
spdlog::error("TimescaleDB flush error: {}", e.what());
|
||||
}
|
||||
|
||||
m_outbound_bucket = 0;
|
||||
m_inbound_bucket = 0;
|
||||
m_last_flush_time = std::chrono::steady_clock::now();
|
||||
}
|
||||
|
||||
void TimescaleDbMetricsReporter::tick() {
|
||||
if (is_time_to_flush()) flush();
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
|
||||
#include "MetricsReporter.hpp"
|
||||
#include "ZoneServerConfiguration.hpp"
|
||||
#include <chrono>
|
||||
#include <optional>
|
||||
#include <pqxx/pqxx>
|
||||
|
||||
class TimescaleDbMetricsReporter : public NetworkMetricsReporter {
|
||||
std::optional<pqxx::connection> m_connection;
|
||||
std::string m_metrics_table;
|
||||
|
||||
size_t m_outbound_bucket = 0;
|
||||
size_t m_inbound_bucket = 0;
|
||||
size_t m_player_count = 0;
|
||||
std::chrono::steady_clock::time_point m_last_flush_time;
|
||||
|
||||
bool is_time_to_flush() const {
|
||||
return m_last_flush_time < std::chrono::steady_clock::now() - std::chrono::seconds(1);
|
||||
}
|
||||
|
||||
void flush();
|
||||
|
||||
public:
|
||||
explicit TimescaleDbMetricsReporter(const TimescaleDbConfiguration& config);
|
||||
|
||||
void tick() override;
|
||||
void add_outbound(size_t size) override { m_outbound_bucket += size; }
|
||||
void add_inbound(size_t size) override { m_inbound_bucket += size; }
|
||||
void set_player_count(size_t count) override { m_player_count = count; }
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
namespace tw::net {
|
||||
|
||||
class InboundMessage {
|
||||
public:
|
||||
uint32_t session_id;
|
||||
std::vector<std::byte> payload;
|
||||
|
||||
InboundMessage(uint32_t session_id, std::vector<std::byte> payload)
|
||||
: session_id(session_id), payload(std::move(payload)) {}
|
||||
};
|
||||
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
#include <span>
|
||||
#include <spdlog/spdlog.h>
|
||||
|
||||
namespace tw::net {
|
||||
|
||||
class MessageDeserializer {
|
||||
public:
|
||||
template<typename T>
|
||||
static std::optional<T> deserialize(std::span<std::byte> data) {
|
||||
T result = {};
|
||||
|
||||
result.ParseFromArray(data.data(), data.size());
|
||||
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
#pragma once
|
||||
|
||||
#include "InboundMessage.hpp"
|
||||
#include "MessageQueue.hpp"
|
||||
#include "NetworkError.hpp"
|
||||
#include "MessageDeserializer.hpp"
|
||||
#include "monitoring/TimescaleDbMetricsReporter.hpp"
|
||||
#include "network/NetworkReceiver.hpp"
|
||||
#include "packets/Packet.hpp"
|
||||
|
||||
#include <tl/expected.hpp>
|
||||
|
||||
#include <functional>
|
||||
#include <tracy/Tracy.hpp>
|
||||
|
||||
namespace tw::net {
|
||||
|
||||
typedef std::function<tl::expected<void, NetworkError>(uint32_t, std::span<std::byte>)> MessageHandler;
|
||||
|
||||
class MessageDispatcher {
|
||||
NetworkReceiver* m_network;
|
||||
|
||||
std::unordered_map<uint32_t, MessageHandler> m_handlers;
|
||||
|
||||
public:
|
||||
MessageDispatcher(NetworkReceiver* network) :
|
||||
m_network(network) {
|
||||
}
|
||||
|
||||
template<typename T>
|
||||
void set_handler(std::function<void(uint32_t, T)> handler) {
|
||||
m_handlers[static_cast<uint32_t>(Message<T>::value)] =
|
||||
[handler, this](uint32_t session_id, std::span<std::byte> data) -> tl::expected<void, NetworkError> {
|
||||
ZoneScopedN("Handling message");
|
||||
|
||||
size_t size = 0;
|
||||
auto r = MessageDeserializer::deserialize<T>(data);
|
||||
if(!r) {
|
||||
return {};
|
||||
}
|
||||
|
||||
handler(session_id, *r);
|
||||
return {};
|
||||
};
|
||||
}
|
||||
|
||||
void drain_queue() {
|
||||
while(!m_network->inbound_queue()->is_empty()) {
|
||||
auto msg = m_network->inbound_queue()->pop();
|
||||
uint32_t message_type = *(uint32_t*)msg->payload.data();
|
||||
|
||||
if(m_handlers.find(message_type) == m_handlers.end()) {
|
||||
spdlog::error("No handler for message type {}", message_type);
|
||||
continue;
|
||||
}
|
||||
|
||||
auto r = m_handlers[message_type](msg->session_id, std::span<std::byte>(msg->payload).subspan(sizeof(uint32_t)));
|
||||
if(!r) {
|
||||
spdlog::error("Failed to handle message of type {}: {}", message_type, r.error().message());
|
||||
// TODO: Add session failure
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <queue>
|
||||
|
||||
namespace tw::net {
|
||||
|
||||
template<typename T>
|
||||
class MessageQueue {
|
||||
std::queue<T> m_queue;
|
||||
|
||||
public:
|
||||
MessageQueue() : m_queue() {
|
||||
|
||||
}
|
||||
|
||||
bool is_empty() {
|
||||
return m_queue.empty();
|
||||
}
|
||||
|
||||
void push(T mesg) {
|
||||
m_queue.push(mesg);
|
||||
}
|
||||
|
||||
T pop() {
|
||||
auto mesg = m_queue.front();
|
||||
m_queue.pop();
|
||||
return mesg;
|
||||
}
|
||||
};
|
||||
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
#include "NetworkReceiver.hpp"
|
||||
#include "network/InboundMessage.hpp"
|
||||
#include "network/MessageQueue.hpp"
|
||||
#include "network/PlayerSessionRegistry.hpp"
|
||||
#include "protocol/quicr/QuicrConnectionListener.hpp"
|
||||
|
||||
namespace tw::net {
|
||||
|
||||
NetworkReceiver::NetworkReceiver(
|
||||
PlayerSessionRegistry* session_registry,
|
||||
int32_t udp_port,
|
||||
NetworkMetricsReporter* metrics_reporter
|
||||
) :
|
||||
m_session_registry(session_registry),
|
||||
m_inbound_queue(new MessageQueue<InboundMessage*>),
|
||||
m_quicr_endpoint(quicr::QuicrEndpoint::create().value()),
|
||||
m_quicr_listener(quicr::QuicrConnectionListener::listen(m_quicr_endpoint.get()).value()),
|
||||
m_metrics_reporter(metrics_reporter)
|
||||
{
|
||||
m_quicr_endpoint->bind(udp_port);
|
||||
|
||||
spdlog::info("Running on port: {}", udp_port);
|
||||
}
|
||||
|
||||
bool NetworkReceiver::listen_quicr() {
|
||||
quicr::QuicrConnection* connection = nullptr;
|
||||
while((connection = m_quicr_listener->listen())) {
|
||||
spdlog::info("Quicr client tries to connect");
|
||||
|
||||
auto session_id = m_session_registry->register_session(connection);
|
||||
|
||||
m_new_sessions.push_back(session_id);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void NetworkReceiver::listen() {
|
||||
listen_quicr();
|
||||
}
|
||||
|
||||
void NetworkReceiver::process_streams() {
|
||||
m_quicr_endpoint->poll();
|
||||
|
||||
std::vector<std::byte> buffer(64 * 1024);
|
||||
for(auto& client : m_session_registry->sessions()) {
|
||||
auto read_r = client->quicr_connection->read_into(buffer);
|
||||
if(!read_r) {
|
||||
spdlog::error("Failed to read from QUICr stream");
|
||||
continue;
|
||||
}
|
||||
|
||||
if(*read_r == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
m_metrics_reporter->add_inbound(*read_r);
|
||||
|
||||
auto data = std::vector<std::byte>(buffer.begin(), buffer.begin() + *read_r);
|
||||
m_inbound_queue->push(new InboundMessage(client->session_id, data));
|
||||
}
|
||||
}
|
||||
|
||||
void NetworkReceiver::update() {
|
||||
m_quicr_endpoint->poll();
|
||||
|
||||
listen();
|
||||
|
||||
process_streams();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
#pragma once
|
||||
|
||||
#include "MessageQueue.hpp"
|
||||
#include "InboundMessage.hpp"
|
||||
#include "MessageRegistry.hpp"
|
||||
#include "monitoring/MetricsReporter.hpp"
|
||||
#include "monitoring/TimescaleDbMetricsReporter.hpp"
|
||||
#include "network/PlayerSessionRegistry.hpp"
|
||||
#include "protocol/quicr/QuicrConnectionListener.hpp"
|
||||
#include "protocol/quicr/QuicrEndpoint.hpp"
|
||||
|
||||
#include <span>
|
||||
|
||||
namespace tw::net {
|
||||
|
||||
class NetworkReceiver {
|
||||
PlayerSessionRegistry *m_session_registry;
|
||||
|
||||
std::unique_ptr<quicr::QuicrEndpoint> m_quicr_endpoint;
|
||||
std::unique_ptr<quicr::QuicrConnectionListener> m_quicr_listener;
|
||||
|
||||
MessageQueue<InboundMessage*>* m_inbound_queue;
|
||||
|
||||
std::deque<SessionId> m_new_sessions;
|
||||
|
||||
NetworkMetricsReporter* m_metrics_reporter;
|
||||
|
||||
bool listen_quicr();
|
||||
|
||||
void listen();
|
||||
|
||||
void process_streams();
|
||||
|
||||
public:
|
||||
NetworkReceiver(
|
||||
PlayerSessionRegistry* session_registry,
|
||||
int32_t udp_port,
|
||||
NetworkMetricsReporter* metrics_reporter
|
||||
);
|
||||
|
||||
MessageQueue<InboundMessage*>* inbound_queue() {
|
||||
return m_inbound_queue;
|
||||
}
|
||||
|
||||
bool peek_new_session() const {
|
||||
return !m_new_sessions.empty();
|
||||
}
|
||||
|
||||
SessionId pop_new_session() {
|
||||
auto session = m_new_sessions.front();
|
||||
m_new_sessions.pop_front();
|
||||
return session;
|
||||
}
|
||||
|
||||
void update();
|
||||
|
||||
template<typename T>
|
||||
size_t send_mesg(SessionId session_id, const T& mesg) {
|
||||
std::string payload;
|
||||
if(!mesg.SerializeToString(&payload)) {
|
||||
spdlog::error("Failed to serialize message");
|
||||
return 0;
|
||||
}
|
||||
|
||||
int32_t length = payload.length();
|
||||
if(length == 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
std::vector<std::byte> bytes(length + sizeof(uint32_t));
|
||||
|
||||
uint32_t type = Message<T>::value;
|
||||
auto payload_bytes = std::as_writable_bytes(std::span(payload));
|
||||
memcpy(bytes.data(), &type, sizeof(type));
|
||||
memcpy(bytes.data() + sizeof(uint32_t), payload_bytes.data(), payload_bytes.size());
|
||||
|
||||
auto session = m_session_registry->session(session_id);
|
||||
m_metrics_reporter->add_outbound(bytes.size());
|
||||
|
||||
session->quicr_connection->send_message(bytes, false);
|
||||
return bytes.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a pre-serialised raw byte payload directly, with no additional
|
||||
* framing. The caller is responsible for including any type tag and
|
||||
* length prefix in the payload (as tw::serial::WorldStateWriter does).
|
||||
*
|
||||
* This avoids the Protobuf SerializeToString heap allocation entirely —
|
||||
* the span points into the caller's BinaryBuffer, which is reused every
|
||||
* frame.
|
||||
*/
|
||||
size_t send_raw(SessionId session_id, std::span<const std::byte> payload) {
|
||||
if(payload.empty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
auto* session = m_session_registry->session(session_id);
|
||||
if(!session) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// QuicrConnection::send_message takes a std::vector<std::byte>.
|
||||
// We copy the span here. If the QUICr layer is ever refactored to
|
||||
// accept a span we can remove this copy entirely.
|
||||
std::vector<std::byte> bytes(payload.begin(), payload.end());
|
||||
m_metrics_reporter->add_outbound(bytes.size());
|
||||
session->quicr_connection->send_message(bytes, false);
|
||||
return bytes.size();
|
||||
}
|
||||
};
|
||||
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
|
||||
namespace tw::net {
|
||||
|
||||
struct OutboundMessage {
|
||||
std::vector<std::byte> payload;
|
||||
|
||||
OutboundMessage(std::vector<std::byte> payload)
|
||||
: payload(std::move(payload)) {}
|
||||
};
|
||||
|
||||
} // namespace tw::net
|
||||
@@ -0,0 +1,21 @@
|
||||
#include "PlayerSessionRegistry.hpp"
|
||||
#include "PlayerSession.hpp"
|
||||
#include "protocol/quicr/QuicrConnection.hpp"
|
||||
|
||||
namespace tw::net {
|
||||
|
||||
SessionId PlayerSessionRegistry::generate_session_id() {
|
||||
static SessionId next_id = 1;
|
||||
return next_id++;
|
||||
}
|
||||
|
||||
SessionId PlayerSessionRegistry::register_session(quicr::QuicrConnection* quicr_connection) {
|
||||
auto session = new PlayerSession(quicr_connection->self_id(), quicr_connection);
|
||||
m_session_vec.emplace_back(session);
|
||||
|
||||
m_session_map.emplace(quicr_connection->self_id(), session);
|
||||
|
||||
return quicr_connection->self_id();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
|
||||
#include "PlayerSession.hpp"
|
||||
#include "SessionId.hpp"
|
||||
#include "protocol/quicr/QuicrConnection.hpp"
|
||||
#include <memory>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
namespace tw::net {
|
||||
|
||||
class PlayerSessionRegistry {
|
||||
std::vector<PlayerSession*> m_session_vec;
|
||||
|
||||
std::unordered_map<SessionId, std::unique_ptr<PlayerSession>> m_session_map;
|
||||
|
||||
SessionId generate_session_id();
|
||||
|
||||
public:
|
||||
const std::vector<PlayerSession*>& sessions() const {
|
||||
return m_session_vec;
|
||||
}
|
||||
|
||||
PlayerSession* session(SessionId id) {
|
||||
auto session = m_session_map.find(id);
|
||||
return session != m_session_map.end() ? session->second.get() : nullptr;
|
||||
}
|
||||
|
||||
SessionId register_session(quicr::QuicrConnection* quicr_connection);
|
||||
|
||||
void unregister_session(SessionId);
|
||||
};
|
||||
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
||||
namespace tw::net {
|
||||
using SessionId = uint32_t;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
#pragma once
|
||||
|
||||
#include "network/NetworkReceiver.hpp"
|
||||
#include "network/PlayerSessionRegistry.hpp"
|
||||
#include "systems/Interest.hpp"
|
||||
#include "systems/InterestSystem.hpp"
|
||||
#include "world/Transform.hpp"
|
||||
|
||||
#include <tw/serial/Serial.hpp>
|
||||
|
||||
#include <entt/entt.hpp>
|
||||
#include <spdlog/spdlog.h>
|
||||
#include <tracy/Tracy.hpp>
|
||||
#include <cassert>
|
||||
|
||||
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 {
|
||||
PlayerSessionRegistry* m_client_registry;
|
||||
NetworkReceiver* m_network;
|
||||
|
||||
// Per-client backing buffers reused every frame.
|
||||
std::vector<tw::serial::BinaryBuffer> m_frames;
|
||||
|
||||
// Header(12) + spawn_hdr(4) + despawn_hdr(4) + 512 entities × 16 bytes
|
||||
static constexpr std::size_t kInitialCapacity = 20 + 512 * 16;
|
||||
|
||||
public:
|
||||
StateReplicator(
|
||||
PlayerSessionRegistry* client_registry,
|
||||
NetworkReceiver* network
|
||||
) :
|
||||
m_client_registry(client_registry),
|
||||
m_network(network)
|
||||
{}
|
||||
|
||||
/**
|
||||
* Replicates the current world state for one zone to its connected clients.
|
||||
*/
|
||||
void replicate(const entt::registry& registry, const im::InterestSystem<Backend>* interest_manager) {
|
||||
ZoneScopedN("Replicator");
|
||||
|
||||
const auto& sessions = m_client_registry->sessions();
|
||||
const std::size_t session_count = sessions.size();
|
||||
|
||||
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)
|
||||
writers.emplace_back(m_frames[i]);
|
||||
|
||||
std::vector<const im::Interest*> client_states(session_count);
|
||||
|
||||
// ── 1. Write headers / spawns / despawns ──────────────────────────
|
||||
{
|
||||
ZoneScopedN("Preparing messages");
|
||||
|
||||
for (std::size_t i = 0; i < session_count; ++i) {
|
||||
const auto* session = sessions[i];
|
||||
const im::Interest* state =
|
||||
interest_manager->get_interest(session->session_id);
|
||||
|
||||
client_states[i] = state;
|
||||
if (!state) continue;
|
||||
|
||||
const std::size_t needed =
|
||||
20
|
||||
+ state->spawn().size() * 4
|
||||
+ state->despawn().size() * 4
|
||||
+ state->interest().size() * 16;
|
||||
|
||||
m_frames[i].reserve(needed);
|
||||
writers[i].reset();
|
||||
writers[i].begin(session->last_frame);
|
||||
writers[i].write_spawns(state->spawn());
|
||||
writers[i].write_despawns(state->despawn());
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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))
|
||||
writers[i].write_entity(e, t.position());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── 3. Finalise and dispatch ───────────────────────────────────────
|
||||
{
|
||||
ZoneScopedN("Sending messages");
|
||||
|
||||
for (std::size_t i = 0; i < session_count; ++i) {
|
||||
spdlog::info("Sending");
|
||||
if (!client_states[i]) continue;
|
||||
writers[i].end();
|
||||
m_network->send_raw(sessions[i]->session_id, writers[i].view());
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace tw::net
|
||||
@@ -0,0 +1,83 @@
|
||||
#include "ZoneServer.hpp"
|
||||
#include "ZoneServerConfiguration.hpp"
|
||||
|
||||
#include <getopt.h>
|
||||
#include <spdlog/spdlog.h>
|
||||
#include <cstdlib>
|
||||
#include <print>
|
||||
|
||||
static constexpr int kDefaultQuicrPort = 8101;
|
||||
static constexpr int kDefaultClusterPort = 8102;
|
||||
|
||||
static void print_usage(const char* argv0) {
|
||||
std::println("Usage: {} [--quicr-port <port>] [--cluster-port <port>]", argv0);
|
||||
std::println(" --quicr-port UDP port for player connections (default: {})", kDefaultQuicrPort);
|
||||
std::println(" --cluster-port UDP port for zone-server peering (default: {})", kDefaultClusterPort);
|
||||
std::println("");
|
||||
std::println("TimescaleDB (all optional, enabled when TIMESCALEDB_HOST is set):");
|
||||
std::println(" TIMESCALEDB_HOST host name");
|
||||
std::println(" TIMESCALEDB_PORT port (default: 5432)");
|
||||
std::println(" TIMESCALEDB_DB database (default: mmo)");
|
||||
std::println(" TIMESCALEDB_USER username (default: mmo)");
|
||||
std::println(" TIMESCALEDB_PASSWORD password");
|
||||
std::println(" TIMESCALEDB_TABLE metrics table (default: zone_metrics)");
|
||||
}
|
||||
|
||||
static auto env(const char* name, const char* fallback = "") -> std::string {
|
||||
const char* v = std::getenv(name);
|
||||
return v ? v : fallback;
|
||||
}
|
||||
|
||||
static std::optional<TimescaleDbConfiguration> timescaledb_from_env() {
|
||||
const std::string host = env("TIMESCALEDB_HOST");
|
||||
if (host.empty()) return std::nullopt;
|
||||
|
||||
const std::string port_str = env("TIMESCALEDB_PORT", "5432");
|
||||
return TimescaleDbConfiguration{
|
||||
.connection = {
|
||||
.host = host,
|
||||
.port = std::atoi(port_str.c_str()),
|
||||
.dbname = env("TIMESCALEDB_DB", "mmo"),
|
||||
.username = env("TIMESCALEDB_USER", "mmo"),
|
||||
.password = env("TIMESCALEDB_PASSWORD"),
|
||||
},
|
||||
.metrics_table = env("TIMESCALEDB_TABLE", "zone_metrics"),
|
||||
};
|
||||
}
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
ZoneServerConfiguration config;
|
||||
|
||||
static const option long_opts[] = {
|
||||
{ "quicr-port", required_argument, nullptr, 'q' },
|
||||
{ "cluster-port", required_argument, nullptr, 'c' },
|
||||
{ "help", no_argument, nullptr, 'h' },
|
||||
{ nullptr, 0, nullptr, 0 },
|
||||
};
|
||||
|
||||
int opt;
|
||||
while ((opt = getopt_long(argc, argv, "q:c:h", long_opts, nullptr)) != -1) {
|
||||
switch (opt) {
|
||||
case 'q': config.quicr_port = std::atoi(optarg); break;
|
||||
case 'c': config.cluster_port = std::atoi(optarg); break;
|
||||
case 'h': print_usage(argv[0]); return 0;
|
||||
default: print_usage(argv[0]); return 1;
|
||||
}
|
||||
}
|
||||
|
||||
config.timescaledb = timescaledb_from_env();
|
||||
|
||||
spdlog::info("quicr-port={} cluster-port={} timescaledb={}",
|
||||
config.quicr_port, config.cluster_port,
|
||||
config.timescaledb ? config.timescaledb->connection.host : "disabled");
|
||||
|
||||
try {
|
||||
tw::net::ZoneServer server(std::move(config));
|
||||
server.run();
|
||||
} catch (const std::exception& e) {
|
||||
std::println("Exception: {}", e.what());
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
#pragma once
|
||||
|
||||
#include "common.hpp"
|
||||
#include <entt/entt.hpp>
|
||||
#include <vector>
|
||||
|
||||
namespace tw::net::im {
|
||||
|
||||
/**
|
||||
* Tracks the interest state and entity set for a single connected client.
|
||||
*
|
||||
* The interest set is maintained as a sorted vector for efficient delta computation
|
||||
* using std::set_difference. The vector is reused across frames (capacity preserved),
|
||||
* so no heap allocation occurs in the steady state.
|
||||
*
|
||||
* Members:
|
||||
* m_entity: the player's own entity in the world
|
||||
* m_interest: sorted vector of entities the player can see (persistent)
|
||||
* m_new_interest: scratch buffer for candidate entities from spatial query (reused)
|
||||
* m_spawn: entities that entered interest this frame
|
||||
* m_despawn: entities that left interest this frame
|
||||
*/
|
||||
class ClientState {
|
||||
entt::entity m_entity;
|
||||
std::vector<entt::entity> m_interest; // sorted, persistent
|
||||
std::vector<entt::entity> m_new_interest; // scratch, reused each frame
|
||||
std::vector<entt::entity> m_spawn;
|
||||
std::vector<entt::entity> m_despawn;
|
||||
|
||||
public:
|
||||
GET(m_entity, entity);
|
||||
GET_REF(m_interest, interest);
|
||||
GET_MUT_REF(m_spawn, spawn);
|
||||
GET_MUT_REF(m_despawn, despawn);
|
||||
|
||||
// Internal accessor for InterestSystem to swap interest sets
|
||||
std::vector<entt::entity>& new_interest() {
|
||||
return m_new_interest;
|
||||
}
|
||||
|
||||
// Swaps m_interest with the provided vector. Used by InterestSystem::update()
|
||||
// to atomically finalize the new interest set for this frame.
|
||||
void swap_interest(std::vector<entt::entity>& other) {
|
||||
m_interest.swap(other);
|
||||
}
|
||||
|
||||
bool is_interested_in_entity(entt::entity e) const {
|
||||
return std::binary_search(m_interest.begin(), m_interest.end(), e);
|
||||
}
|
||||
|
||||
explicit ClientState(entt::entity entity)
|
||||
: m_entity(entity)
|
||||
{
|
||||
// Pre-allocate reasonable capacity to avoid reallocations in steady state.
|
||||
// For a typical view radius of 1000 units with cell size 500, this is
|
||||
// ~9 cells × ~50 entities per cell = ~450 entities per player.
|
||||
m_interest.reserve(512);
|
||||
m_new_interest.reserve(512);
|
||||
m_spawn.reserve(256);
|
||||
m_despawn.reserve(256);
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace tw::net::im
|
||||
@@ -0,0 +1,69 @@
|
||||
#pragma once
|
||||
|
||||
#include "common.hpp"
|
||||
#include <entt/entt.hpp>
|
||||
#include <glm/glm.hpp>
|
||||
#include <variant>
|
||||
#include <vector>
|
||||
|
||||
namespace tw::net::im {
|
||||
|
||||
using InterestId = uint32_t;
|
||||
|
||||
// Axis-aligned bounding box in the XZ plane (vec2.x = world X, vec2.y = world Z).
|
||||
struct AreaBounds {
|
||||
glm::vec2 min;
|
||||
glm::vec2 max;
|
||||
|
||||
bool is_overlapping(glm::vec2 point) {
|
||||
return point.x >= min.x && point.x <= max.x && point.y >= min.y && point.y <= max.y;
|
||||
}
|
||||
};
|
||||
|
||||
// Unified interest subscription: tracks entities near a world-space entity position
|
||||
// (entity interest) or within a quad area (area interest).
|
||||
//
|
||||
// The identity of the caller — whether it is a session, a neighbouring zone, or
|
||||
// anything else — is the caller's concern. InterestSystem only sees an InterestId.
|
||||
//
|
||||
// No heap allocations in steady state after the first frame.
|
||||
class Interest {
|
||||
std::variant<entt::entity, AreaBounds> m_source;
|
||||
std::vector<entt::entity> m_interest; // sorted, persistent
|
||||
std::vector<entt::entity> m_new_interest; // scratch, reused each frame
|
||||
std::vector<entt::entity> m_spawn;
|
||||
std::vector<entt::entity> m_despawn;
|
||||
|
||||
void reserve() {
|
||||
m_interest.reserve(512);
|
||||
m_new_interest.reserve(512);
|
||||
m_spawn.reserve(256);
|
||||
m_despawn.reserve(256);
|
||||
}
|
||||
|
||||
public:
|
||||
GET_REF(m_interest, interest);
|
||||
GET_MUT_REF(m_spawn, spawn);
|
||||
GET_MUT_REF(m_despawn, despawn);
|
||||
|
||||
bool is_entity_interest() const { return std::holds_alternative<entt::entity>(m_source); }
|
||||
bool is_area_interest() const { return std::holds_alternative<AreaBounds>(m_source); }
|
||||
|
||||
entt::entity entity() const { return std::get<entt::entity>(m_source); }
|
||||
const AreaBounds& bounds() const { return std::get<AreaBounds>(m_source); }
|
||||
|
||||
std::vector<entt::entity>& new_interest() { return m_new_interest; }
|
||||
|
||||
void swap_interest(std::vector<entt::entity>& other) {
|
||||
m_interest.swap(other);
|
||||
}
|
||||
|
||||
bool is_interested_in_entity(entt::entity e) const {
|
||||
return std::binary_search(m_interest.begin(), m_interest.end(), e);
|
||||
}
|
||||
|
||||
explicit Interest(entt::entity entity) : m_source(entity) { reserve(); }
|
||||
explicit Interest(AreaBounds bounds) : m_source(bounds) { reserve(); }
|
||||
};
|
||||
|
||||
} // namespace tw::net::im
|
||||
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
|
||||
#include "common.hpp"
|
||||
#include <entt/entt.hpp>
|
||||
|
||||
namespace tw::net::im {
|
||||
|
||||
class InterestResult {
|
||||
std::vector<entt::entity> m_entities;
|
||||
std::vector<entt::entity> m_despawn;
|
||||
std::vector<entt::entity> m_spawn;
|
||||
|
||||
public:
|
||||
GET_REF(m_entities, entities);
|
||||
GET_REF(m_despawn, despawns);
|
||||
GET_REF(m_spawn, spawns);
|
||||
|
||||
void set_update(std::vector<entt::entity> updates) {
|
||||
m_entities = updates;
|
||||
}
|
||||
|
||||
void set_despawn(std::vector<entt::entity> despawn) {
|
||||
m_despawn = despawn;
|
||||
}
|
||||
|
||||
void set_spawn(std::vector<entt::entity> spawn) {
|
||||
m_spawn = spawn;
|
||||
}
|
||||
};
|
||||
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
#pragma once
|
||||
|
||||
#include "Interest.hpp"
|
||||
#include "interest_management/SpatialBackend.hpp"
|
||||
#include "world/Transform.hpp"
|
||||
#include "world/World.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <entt/entt.hpp>
|
||||
#include <tracy/Tracy.hpp>
|
||||
#include <unordered_map>
|
||||
|
||||
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
|
||||
std::unordered_map<InterestId, Interest> m_interests;
|
||||
|
||||
public:
|
||||
explicit InterestSystem(const World* world, Backend* backend)
|
||||
: m_world(world),
|
||||
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();
|
||||
m_world->registry()
|
||||
.view<Transform>()
|
||||
.each([this](entt::entity e, const Transform& t) {
|
||||
m_backend->insert(e, t.position());
|
||||
});
|
||||
}
|
||||
|
||||
// ── Phase 2: Per-subscription deltas ─────────────────────────────────
|
||||
{
|
||||
ZoneScopedN("InterestSystem::Phase2_Deltas");
|
||||
|
||||
for (auto& [id, interest] : m_interests) {
|
||||
interest.new_interest().clear();
|
||||
|
||||
if (interest.is_entity_interest()) {
|
||||
entt::entity entity = interest.entity();
|
||||
if (entity == entt::null) {
|
||||
interest.spawn().clear();
|
||||
interest.despawn().clear();
|
||||
continue;
|
||||
}
|
||||
const Transform* t = m_world->registry().try_get<Transform>(entity);
|
||||
if (!t) {
|
||||
interest.spawn().clear();
|
||||
interest.despawn().clear();
|
||||
std::vector<entt::entity> empty;
|
||||
interest.swap_interest(empty);
|
||||
continue;
|
||||
}
|
||||
m_backend->query_neighbors(t->position(), interest.new_interest());
|
||||
} else {
|
||||
const auto& b = interest.bounds();
|
||||
m_backend->query_area(b.min, b.max, interest.new_interest());
|
||||
}
|
||||
|
||||
std::sort(interest.new_interest().begin(), interest.new_interest().end());
|
||||
|
||||
interest.spawn().clear();
|
||||
std::set_difference(
|
||||
interest.new_interest().begin(), interest.new_interest().end(),
|
||||
interest.interest().begin(), interest.interest().end(),
|
||||
std::back_inserter(interest.spawn())
|
||||
);
|
||||
|
||||
interest.despawn().clear();
|
||||
std::set_difference(
|
||||
interest.interest().begin(), interest.interest().end(),
|
||||
interest.new_interest().begin(), interest.new_interest().end(),
|
||||
std::back_inserter(interest.despawn())
|
||||
);
|
||||
|
||||
interest.swap_interest(interest.new_interest());
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace tw::net::im
|
||||
@@ -0,0 +1,20 @@
|
||||
project(tw_server_tests)
|
||||
|
||||
# set(CMAKE_CXX_CLANG_TIDY "/usr/bin/clang-tidy;-checks=*")
|
||||
|
||||
set(TEST_LIBS
|
||||
PUBLIC
|
||||
tw_server_lib
|
||||
Tracy::TracyClient
|
||||
TracyClient
|
||||
)
|
||||
|
||||
function(add_server_test target source)
|
||||
add_executable(${target} ${source})
|
||||
target_compile_options(${target} PRIVATE -UTRACY_CALLSTACK)
|
||||
target_include_directories(${target} PUBLIC ${PROJECT_SOURCE_DIR}/)
|
||||
target_link_libraries(${target} ${TEST_LIBS})
|
||||
endfunction()
|
||||
|
||||
add_server_test(tw_range_query_benchmarks RangeQueryBenchmarks.cpp)
|
||||
add_server_test(tw_zone_manager_tests ZoneManagerTests.cpp)
|
||||
@@ -0,0 +1,221 @@
|
||||
|
||||
|
||||
#include "entt/entt.hpp"
|
||||
#include "interest_management/FixedGrid.hpp"
|
||||
#include "interest_management/SpatialHashGrid.hpp"
|
||||
#include <chrono>
|
||||
#include <common/TracyQueue.hpp>
|
||||
#include <cstdint>
|
||||
#include <random>
|
||||
#include <spdlog/spdlog.h>
|
||||
#include <tracy/Tracy.hpp>
|
||||
|
||||
// #include "Quadtree.h"
|
||||
|
||||
void* operator new(std::size_t count) {
|
||||
auto ptr = malloc(count);
|
||||
TracyAlloc(ptr, count);
|
||||
return ptr;
|
||||
}
|
||||
|
||||
void operator delete(void* ptr) noexcept {
|
||||
TracyFree(ptr);
|
||||
free(ptr);
|
||||
}
|
||||
|
||||
const long SIZE = 4096*4;
|
||||
const long VIEW = 100;
|
||||
|
||||
struct Position {
|
||||
float x, y, z;
|
||||
|
||||
Position(float x, float y, float z) : x(x), y(y), z(z) {}
|
||||
};
|
||||
|
||||
struct Velocity {
|
||||
float x, y, z;
|
||||
};
|
||||
|
||||
void generate_points(entt::registry& registry, uint32_t num_entities) {
|
||||
std::mt19937 rng{std::random_device{}()};
|
||||
std::uniform_real_distribution<float> pos_dist(-SIZE, SIZE);
|
||||
std::uniform_real_distribution<float> vel_dist(-5.0f, 5.0f);
|
||||
|
||||
for (uint32_t i = 0; i < num_entities; ++i) {
|
||||
auto entity = registry.create();
|
||||
registry.emplace<Position>(entity, pos_dist(rng), pos_dist(rng), pos_dist(rng));
|
||||
registry.emplace<Velocity>(entity, vel_dist(rng), vel_dist(rng), vel_dist(rng));
|
||||
}
|
||||
}
|
||||
|
||||
float clamp(float value, float min, float max) {
|
||||
return std::max(min, std::min(max, value));
|
||||
}
|
||||
|
||||
void move_points(entt::registry& registry) {
|
||||
for (auto entity : registry.view<Position, Velocity>()) {
|
||||
auto& pos = registry.get<Position>(entity);
|
||||
auto& vel = registry.get<Velocity>(entity);
|
||||
pos.x += vel.x;
|
||||
pos.y += vel.y;
|
||||
pos.z += vel.z;
|
||||
|
||||
pos.x = clamp(pos.x, -SIZE, SIZE);
|
||||
pos.y = clamp(pos.y, -SIZE, SIZE);
|
||||
pos.z = clamp(pos.z, -SIZE, SIZE);
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<glm::vec3> generate_query_points(uint32_t num_points) {
|
||||
std::mt19937 rng{std::random_device{}()};
|
||||
std::uniform_real_distribution<float> dist(-SIZE, SIZE);
|
||||
std::vector<glm::vec3> points;
|
||||
for (uint32_t i = 0; i < num_points; ++i) {
|
||||
points.emplace_back(dist(rng), dist(rng), dist(rng));
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
const uint32_t ENTITY_COUNT = 100000;
|
||||
const uint32_t FRAME_COUNT = 100;
|
||||
|
||||
using Clock = std::chrono::high_resolution_clock;
|
||||
using TimePoint = std::chrono::time_point<Clock>;
|
||||
|
||||
void hash_grid(entt::registry& registry, const std::vector<glm::vec3>& query_points) {
|
||||
tw::net::im::SpatialHashGrid<VIEW, VIEW> hash_grid;
|
||||
ZoneScopedN("HashGrid");
|
||||
|
||||
TimePoint start = Clock::now();
|
||||
|
||||
for(int frame = 0; frame < FRAME_COUNT; frame++) {
|
||||
{ ZoneScopedN("move"); move_points(registry); }
|
||||
hash_grid.begin_frame();
|
||||
{ ZoneScopedN("insert");
|
||||
for (auto entity : registry.view<Position>()) {
|
||||
auto [x, y, z] = registry.get<Position>(entity);
|
||||
hash_grid.insert(entity, glm::vec3(x, y, z));
|
||||
}
|
||||
}
|
||||
{ ZoneScopedN("query");
|
||||
for (const auto& pos : query_points) {
|
||||
std::vector<entt::entity> out_entities;
|
||||
hash_grid.query_neighbors(pos, out_entities);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TimePoint end = Clock::now();
|
||||
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
|
||||
spdlog::info("HashGrid Elapsed time: {}s", elapsed.count());
|
||||
}
|
||||
|
||||
void fixed_grid(entt::registry& registry, const std::vector<glm::vec3>& query_points) {
|
||||
tw::net::im::FixedGrid<VIEW, VIEW> fixed_grid(-10000, 10000, -10000, 10000);
|
||||
ZoneScopedN("FixedGrid");
|
||||
|
||||
auto start = Clock::now();
|
||||
|
||||
for(int frame = 0; frame < FRAME_COUNT; frame++) {
|
||||
{ ZoneScopedN("move"); move_points(registry); }
|
||||
fixed_grid.begin_frame();
|
||||
{ ZoneScopedN("insert");
|
||||
for (auto entity : registry.view<Position>()) {
|
||||
auto [x, y, z] = registry.get<Position>(entity);
|
||||
fixed_grid.insert(entity, glm::vec3(x, y, z));
|
||||
}
|
||||
}
|
||||
{ ZoneScopedN("query");
|
||||
for (const auto& pos : query_points) {
|
||||
std::vector<entt::entity> out_entities;
|
||||
fixed_grid.query_neighbors(pos, out_entities);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
auto end = Clock::now();
|
||||
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
|
||||
spdlog::info("FixedGrid Elapsed time: {}s", elapsed.count());
|
||||
}
|
||||
|
||||
void naive(entt::registry& registry, const std::vector<glm::vec3>& query_points) {
|
||||
auto start = Clock::now();
|
||||
ZoneScopedN("Naive");
|
||||
|
||||
for(int frame = 0; frame < FRAME_COUNT; frame++) {
|
||||
{ ZoneScopedN("move"); move_points(registry); }
|
||||
{ ZoneScopedN("query");
|
||||
for(const auto& player : query_points) {
|
||||
std::vector<entt::entity> out_entities;
|
||||
for(const auto& position : registry.view<Position>()) {
|
||||
auto [x, y, z] = registry.get<Position>(position);
|
||||
Position diff(player.x - x, player.y - y, 0.0f);
|
||||
|
||||
if(diff.x * diff.x + diff.y * diff.y < VIEW * VIEW) {
|
||||
out_entities.push_back((entt::entity)position);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
auto end = Clock::now();
|
||||
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
|
||||
spdlog::info("Naive Elapsed time: {}ms", elapsed.count());
|
||||
}
|
||||
|
||||
// void quadtree_query(entt::registry& registry, const std::vector<glm::vec3>& query_points) {
|
||||
// ZoneScopedN("quadtree_query");
|
||||
// struct Node
|
||||
// {
|
||||
// quadtree::Box<float> box;
|
||||
// uint32_t id;
|
||||
// };
|
||||
|
||||
// auto getBox = [](Node* node)
|
||||
// {
|
||||
// return node->box;
|
||||
// };
|
||||
|
||||
// auto box = quadtree::Box((float)-SIZE, (float)-SIZE, (float)2*SIZE, (float)2*SIZE);
|
||||
// std::vector<Node> nodes(ENTITY_COUNT);
|
||||
|
||||
// auto start = Clock::now();
|
||||
|
||||
// for(int frame = 0; frame < FRAME_COUNT; frame++) {
|
||||
// quadtree::Quadtree<Node*, decltype(getBox)> quad_tree(box, getBox);
|
||||
// { ZoneScopedN("move");
|
||||
// move_points(registry); }
|
||||
// { ZoneScopedN("insert");
|
||||
// for (auto entity : registry.view<Position>()) {
|
||||
// auto [x, y, z] = registry.get<Position>(entity);
|
||||
// auto node = &nodes[(uint32_t)entity];
|
||||
// node->box = quadtree::Box(x, y, 0.0f, 0.0f);
|
||||
// node->id = (uint32_t)entity;
|
||||
// quad_tree.add(node);
|
||||
// }
|
||||
// }
|
||||
// { ZoneScopedN("query");
|
||||
// for (const auto& pos : query_points) {
|
||||
// quad_tree.query(quadtree::Box(pos.x - 10.0f, pos.y - 10.0f, 20.0f, 20.0f));
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// auto end = Clock::now();
|
||||
// auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
|
||||
// spdlog::info("QuadTree Elapsed time: {}ms", elapsed.count());
|
||||
// }
|
||||
|
||||
int main() {
|
||||
entt::registry registry;
|
||||
|
||||
generate_points(registry, ENTITY_COUNT);
|
||||
|
||||
auto query_points = generate_query_points(1000);
|
||||
|
||||
naive(registry, query_points);
|
||||
// quadtree_query(registry, query_points);
|
||||
fixed_grid(registry, query_points);
|
||||
hash_grid(registry, query_points);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
#include "ZoneManager.hpp"
|
||||
#include <cassert>
|
||||
|
||||
using namespace tw::net;
|
||||
|
||||
int main() {
|
||||
ZoneManager zoneA(im::AreaBounds(
|
||||
glm::vec2(0.0f, 0.0f),
|
||||
glm::vec2(1000.0f, 1000.0f)
|
||||
));
|
||||
|
||||
ZoneManager zoneB(im::AreaBounds(
|
||||
glm::vec2(1000.0f, 0.0f),
|
||||
glm::vec2(2000.0f, 1000.0f)
|
||||
));
|
||||
|
||||
zoneA.register_neighbor_zone(&zoneB);
|
||||
zoneB.register_neighbor_zone(&zoneA);
|
||||
|
||||
auto entityA = zoneA.spawn_entity({
|
||||
.name = "EntityA",
|
||||
.position = glm::vec3(0.0f, 0.0f, 0.0f),
|
||||
});
|
||||
zoneA.add_client(1, entityA);
|
||||
|
||||
|
||||
auto entityB = zoneB.spawn_entity({
|
||||
.name = "EntityB",
|
||||
.position = glm::vec3(1030.0f, 500.0f, 0.0f),
|
||||
});
|
||||
zoneB.add_client(1, entityB);
|
||||
|
||||
auto transformB = zoneB.registry().get<tw::Transform>(entityB);
|
||||
|
||||
auto interest = zoneA.get_interest(1);
|
||||
|
||||
assert(interest->interest().empty());
|
||||
|
||||
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);
|
||||
|
||||
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);
|
||||
|
||||
interest = zoneA.get_interest(1);
|
||||
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user