This commit is contained in:
Martin Slachta
2026-07-18 14:31:15 +02:00
commit a04f0dc262
3343 changed files with 1140208 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
project(tw_serialization)
# ── Library ──────────────────────────────────────────────────────────────────
# tw::serialization is a header-only INTERFACE library.
# All hot-path code is in inline functions / templates so the compiler can
# see everything in one translation unit and optimise accordingly.
add_library(tw_serialization INTERFACE)
add_library(tw::serialization ALIAS tw_serialization)
target_include_directories(tw_serialization
INTERFACE
${CMAKE_CURRENT_SOURCE_DIR}/include
${CMAKE_CURRENT_SOURCE_DIR}/include/tw/serial/
)
target_link_libraries(tw_serialization
INTERFACE
glm::glm
EnTT::EnTT
)
# Require C++20 for concepts / span
target_compile_features(tw_serialization INTERFACE cxx_std_20)
# ── Tests / benchmarks ───────────────────────────────────────────────────────
add_subdirectory(tests)
@@ -0,0 +1,90 @@
#pragma once
#include <cassert>
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <span>
#include <vector>
namespace tw::serial {
/**
* A flat, pre-allocated, non-owning-after-construction byte buffer used as the
* backing store for BinaryWriter.
*
* Design goals:
* - Zero heap allocation after the initial reserve().
* - reset() in O(1) — just rewinds the write cursor.
* - view() gives a read-only span over written bytes, usable directly as a
* UDP payload without any copy.
* - Not thread-safe: one buffer per client, reused every frame.
*/
class BinaryBuffer {
std::vector<std::byte> m_storage;
std::size_t m_pos{ 0 };
public:
BinaryBuffer() = default;
explicit BinaryBuffer(std::size_t capacity) {
m_storage.resize(capacity);
}
// Reserve at least `capacity` bytes. May only grow, never shrinks.
void reserve(std::size_t capacity) {
if (capacity > m_storage.size()) {
m_storage.resize(capacity);
}
}
// Reset write cursor to 0. Does NOT zero memory — intentional for perf.
void reset() noexcept { m_pos = 0; }
std::size_t size() const noexcept { return m_pos; }
std::size_t capacity() const noexcept { return m_storage.size(); }
bool empty() const noexcept { return m_pos == 0; }
// Returns a view over bytes written so far. Valid until the next write or reset.
std::span<const std::byte> view() const noexcept {
return { m_storage.data(), m_pos };
}
// Returns a mutable span over bytes written so far.
std::span<std::byte> mutable_view() noexcept {
return { m_storage.data(), m_pos };
}
/**
* Append `n` raw bytes from `src`.
* Asserts in debug; in release callers must pre-reserve enough space.
*/
void append(const void* src, std::size_t n) noexcept {
assert(m_pos + n <= m_storage.size() && "BinaryBuffer overflow — call reserve() with a larger capacity");
std::memcpy(m_storage.data() + m_pos, src, n);
m_pos += n;
}
/**
* Reserve `n` bytes in-place and return a pointer to them.
* Lets callers write directly (e.g. via placement algorithms) without a
* temporary.
*/
std::byte* claim(std::size_t n) noexcept {
assert(m_pos + n <= m_storage.size() && "BinaryBuffer overflow — call reserve() with a larger capacity");
std::byte* ptr = m_storage.data() + m_pos;
m_pos += n;
return ptr;
}
/**
* Patch a previously written 4-byte field at `offset`.
* Useful for writing a length prefix before the payload is known.
*/
void patch_u32(std::size_t offset, uint32_t value) noexcept {
assert(offset + sizeof(uint32_t) <= m_pos);
std::memcpy(m_storage.data() + offset, &value, sizeof(uint32_t));
}
};
} // namespace tw::serial
@@ -0,0 +1,222 @@
#pragma once
/**
* tw::serial — generic, zero-allocation binary codec
* ====================================================
*
* Extensibility model (ADL / explicit specialisation)
* ---------------------------------------------------
* To make a type T serialisable, either:
*
* a) Specialise tw::serial::Codec<T>:
*
* template<>
* struct tw::serial::Codec<MyType> {
* static void encode(BinaryWriter& w, const MyType& v);
* static MyType decode(BinaryReader& r);
* };
*
* b) Or provide free functions in the same namespace as T:
*
* void tw_serial_encode(BinaryWriter& w, const MyType& v);
* MyType tw_serial_decode(BinaryReader& r, std::type_identity<MyType>);
*
* The BinaryWriter/BinaryReader forward-declared here are defined in
* BinaryWriter.hpp / BinaryReader.hpp. Codec.hpp itself is header-only
* and has no external dependencies beyond the C++ standard library.
*/
#include "BinaryBuffer.hpp"
#include <bit>
#include <concepts>
#include <cstdint>
#include <cstring>
#include <span>
#include <type_traits>
namespace tw::serial {
// Forward declarations
class BinaryWriter;
class BinaryReader;
// ── Primary template — intentionally incomplete so missing specialisations
// produce a clear compiler error rather than silent wrong behaviour.
template<typename T>
struct Codec;
// ── Concept: a type is Encodable if Codec<T>::encode exists.
template<typename T>
concept Encodable = requires(BinaryWriter & w, const T & v) {
Codec<T>::encode(w, v);
};
// ── Concept: a type is Decodable if Codec<T>::decode exists.
template<typename T>
concept Decodable = requires(BinaryReader & r) {
{ Codec<T>::decode(r) } -> std::same_as<T>;
};
// ──────────────────────────────────────────────────────────────────────────
// BinaryWriter
// ──────────────────────────────────────────────────────────────────────────
/**
* Thin write-cursor over a BinaryBuffer.
*
* All write operations are branch-free memcpy paths for trivially-copyable
* types. The buffer itself holds the memory; the writer is just a cursor.
*/
class BinaryWriter {
BinaryBuffer& m_buf;
public:
explicit BinaryWriter(BinaryBuffer& buf) noexcept : m_buf(buf) {}
// ── Raw bytes ────────────────────────────────────────────────────────
void write_bytes(const void* src, std::size_t n) noexcept {
m_buf.append(src, n);
}
void write_bytes(std::span<const std::byte> data) noexcept {
m_buf.append(data.data(), data.size());
}
// ── Primitive scalar ─────────────────────────────────────────────────
template<typename T>
requires std::is_trivially_copyable_v<T>
void write(const T& value) noexcept {
m_buf.append(&value, sizeof(T));
}
// ── Codec-dispatched write ────────────────────────────────────────────
template<Encodable T>
void encode(const T& value) {
Codec<T>::encode(*this, value);
}
// ── Cursor helpers ────────────────────────────────────────────────────
/** Returns the current write position (useful for length-prefix patching). */
std::size_t pos() const noexcept { return m_buf.size(); }
/** Reserve a 4-byte slot at the current position, return its offset. */
std::size_t reserve_u32() noexcept {
std::size_t offset = m_buf.size();
uint32_t placeholder = 0;
m_buf.append(&placeholder, sizeof(uint32_t));
return offset;
}
/** Patch a 4-byte slot previously reserved with reserve_u32(). */
void patch_u32(std::size_t offset, uint32_t value) noexcept {
m_buf.patch_u32(offset, value);
}
BinaryBuffer& buffer() noexcept { return m_buf; }
const BinaryBuffer& buffer() const noexcept { return m_buf; }
void reset() noexcept {
m_buf.reset();
}
};
// ──────────────────────────────────────────────────────────────────────────
// BinaryReader
// ──────────────────────────────────────────────────────────────────────────
/**
* Read-cursor over an immutable span of bytes.
*
* Designed for deserialisation on the client side; does not own memory.
* All reads advance an internal cursor. Out-of-bounds reads assert in
* debug and invoke undefined behaviour in release (callers must validate
* message length before feeding it to a BinaryReader).
*/
class BinaryReader {
const std::byte* m_ptr;
std::size_t m_remaining;
public:
explicit BinaryReader(std::span<const std::byte> data) noexcept
: m_ptr(data.data()), m_remaining(data.size()) {}
// ── Raw bytes ────────────────────────────────────────────────────────
void read_bytes(void* dst, std::size_t n) noexcept {
assert(n <= m_remaining && "BinaryReader underflow");
std::memcpy(dst, m_ptr, n);
m_ptr += n;
m_remaining -= n;
}
std::span<const std::byte> read_bytes(std::size_t n) noexcept {
assert(n <= m_remaining && "BinaryReader underflow");
auto span = std::span<const std::byte>{ m_ptr, n };
m_ptr += n;
m_remaining -= n;
return span;
}
// ── Primitive scalar ─────────────────────────────────────────────────
template<typename T>
requires std::is_trivially_copyable_v<T>
T read() noexcept {
T value;
read_bytes(&value, sizeof(T));
return value;
}
// ── Codec-dispatched read ─────────────────────────────────────────────
template<Decodable T>
T decode() {
return Codec<T>::decode(*this);
}
// ── State ─────────────────────────────────────────────────────────────
std::size_t remaining() const noexcept { return m_remaining; }
bool empty() const noexcept { return m_remaining == 0; }
};
// ──────────────────────────────────────────────────────────────────────────
// Built-in Codec specialisations for C++ primitives
// ──────────────────────────────────────────────────────────────────────────
// All fixed-width integer and float types that are trivially copyable get
// a direct memcpy codec — no varint encoding, deliberately, because we are
// optimising for throughput not wire-size (and positions are floats anyway).
#define TW_SERIAL_TRIVIAL_CODEC(T) \
template<> \
struct Codec<T> { \
static void encode(BinaryWriter& w, const T& v) noexcept { \
w.write(v); \
} \
static T decode(BinaryReader& r) noexcept { \
return r.read<T>(); \
} \
}
TW_SERIAL_TRIVIAL_CODEC(bool);
TW_SERIAL_TRIVIAL_CODEC(uint8_t);
TW_SERIAL_TRIVIAL_CODEC(uint16_t);
TW_SERIAL_TRIVIAL_CODEC(uint32_t);
TW_SERIAL_TRIVIAL_CODEC(uint64_t);
TW_SERIAL_TRIVIAL_CODEC(int8_t);
TW_SERIAL_TRIVIAL_CODEC(int16_t);
TW_SERIAL_TRIVIAL_CODEC(int32_t);
TW_SERIAL_TRIVIAL_CODEC(int64_t);
TW_SERIAL_TRIVIAL_CODEC(float);
TW_SERIAL_TRIVIAL_CODEC(double);
#undef TW_SERIAL_TRIVIAL_CODEC
} // namespace tw::serial
@@ -0,0 +1,30 @@
#pragma once
/**
* Codec specialisation for entt::entity.
*
* Wire format: uint32_t (the raw entity storage value, 4 bytes).
*
* EnTT entities are 32-bit identifiers internally (version bits + index bits).
* We transmit the raw value and let the receiver reconstruct via
* static_cast<entt::entity>(id).
*/
#include "Codec.hpp"
#include <entt/entt.hpp>
namespace tw::serial {
template<>
struct Codec<entt::entity> {
static void encode(BinaryWriter& w, const entt::entity& e) noexcept {
auto raw = static_cast<uint32_t>(e);
w.write(raw);
}
static entt::entity decode(BinaryReader& r) noexcept {
return static_cast<entt::entity>(r.read<uint32_t>());
}
};
} // namespace tw::serial
@@ -0,0 +1,77 @@
#pragma once
/**
* Codec specialisations for GLM types.
*
* Include this header in any translation unit that needs to
* encode/decode GLM vectors or matrices.
*
* Wire format (little-endian, matches the host layout on x86/ARM LE):
* vec2 — 2 × float (8 bytes)
* vec3 — 3 × float (12 bytes)
* vec4 — 4 × float (16 bytes)
* mat4 — 16 × float (64 bytes, column-major, matching GLM's default)
*/
#include "Codec.hpp"
#include <glm/vec2.hpp>
#include <glm/vec3.hpp>
#include <glm/vec4.hpp>
#include <glm/mat4x4.hpp>
namespace tw::serial {
template<>
struct Codec<glm::vec2> {
static void encode(BinaryWriter& w, const glm::vec2& v) noexcept {
w.write(v.x);
w.write(v.y);
}
static glm::vec2 decode(BinaryReader& r) noexcept {
glm::vec2 v;
v.x = r.read<float>();
v.y = r.read<float>();
return v;
}
};
template<>
struct Codec<glm::vec3> {
static void encode(BinaryWriter& w, const glm::vec3& v) noexcept {
// vec3 is 3 contiguous floats in GLM's layout
w.write_bytes(&v.x, 3 * sizeof(float));
}
static glm::vec3 decode(BinaryReader& r) noexcept {
glm::vec3 v;
r.read_bytes(&v.x, 3 * sizeof(float));
return v;
}
};
template<>
struct Codec<glm::vec4> {
static void encode(BinaryWriter& w, const glm::vec4& v) noexcept {
w.write_bytes(&v.x, 4 * sizeof(float));
}
static glm::vec4 decode(BinaryReader& r) noexcept {
glm::vec4 v;
r.read_bytes(&v.x, 4 * sizeof(float));
return v;
}
};
template<>
struct Codec<glm::mat4> {
static void encode(BinaryWriter& w, const glm::mat4& m) noexcept {
// GLM mat4 is column-major; 16 contiguous floats
w.write_bytes(&m[0][0], 16 * sizeof(float));
}
static glm::mat4 decode(BinaryReader& r) noexcept {
glm::mat4 m;
r.read_bytes(&m[0][0], 16 * sizeof(float));
return m;
}
};
} // namespace tw::serial
@@ -0,0 +1,21 @@
#pragma once
/**
* tw::serial — umbrella include
*
* Include this single header to get the full serialisation API:
* - BinaryBuffer (backing store)
* - BinaryWriter (write cursor + Codec dispatch)
* - BinaryReader (read cursor + Codec dispatch)
* - Codec<T> (extensible type trait)
* - Built-in Codec specialisations for all C++ primitive types
* - GlmCodec (vec2, vec3, vec4, mat4)
* - EnttCodec (entt::entity as uint32)
* - WorldStateWriter / WorldStateReader (game-specific high-level API)
*/
#include "BinaryBuffer.hpp"
#include "Codec.hpp"
#include "GlmCodec.hpp"
#include "EnttCodec.hpp"
#include "WorldStateWriter.hpp"
@@ -0,0 +1,261 @@
#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 (12 bytes) │
* │ packet_type : uint32 (PacketType::WORLD_STATE = 3) │
* │ 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"
#include "EnttCodec.hpp"
#include <entt/entt.hpp>
#include <glm/vec3.hpp>
#include <cstdint>
#include <span>
#include <spdlog/spdlog.h>
namespace tw::serial {
// ── Packet type tag ───────────────────────────────────────────────────────
// Mirrors PacketType::WORLD_STATE_PACKET (value 3) in packets/Packet.hpp.
// Hardcoded here so the serialisation module does not depend on the network
// module — the numerical value must stay in sync if the enum changes.
inline constexpr uint32_t kWorldStatePacketType = 3; // WORLD_STATE_PACKET
// ──────────────────────────────────────────────────────────────────────────
// WorldStateWriter
// ──────────────────────────────────────────────────────────────────────────
class WorldStateWriter {
BinaryWriter m_w;
// Offsets for length-prefix patching
std::size_t m_entity_count_offset{ 0 };
uint32_t m_entity_count{ 0 };
public:
explicit WorldStateWriter(BinaryBuffer& buf) noexcept : m_w(buf) {}
/**
* Write the packet header. Call once per frame, before everything else.
* entity_count is patched in end().
*/
void begin(uint32_t frame_idx) noexcept {
m_entity_count = 0;
// packet_type — lets the receiver dispatch without peeking further
m_w.encode<uint32_t>(kWorldStatePacketType);
// frame_idx
m_w.encode<uint32_t>(frame_idx);
// entity_count placeholder — patched when end() is called
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));
m_w.encode<uint32_t>(count);
for (const entt::entity e : spawns) {
m_w.encode<entt::entity>(e);
}
}
// ── Despawns ──────────────────────────────────────────────────────────
template<typename Range>
void write_despawns(const Range& despawns) noexcept {
auto count = static_cast<uint32_t>(std::size(despawns));
// m_w.encode<uint32_t>(count);
for (const entt::entity e : despawns) {
m_w.encode<entt::entity>(e);
}
}
// ── 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
m_w.write_bytes(&pos.x, 3 * sizeof(float));
++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();
}
void reset() noexcept {
m_w.reset();
m_entity_count = 0;
}
};
// ──────────────────────────────────────────────────────────────────────────
// WorldStateReader (client-side / test use)
// ──────────────────────────────────────────────────────────────────────────
struct WorldStateHeader {
uint32_t packet_type;
uint32_t frame_idx;
uint32_t entity_count;
};
struct EntityRecord {
uint32_t id;
glm::vec3 position;
};
class WorldStateReader {
BinaryReader m_r;
WorldStateHeader m_header{};
uint32_t m_spawn_count{ 0 };
uint32_t m_spawns_read{ 0 };
uint32_t m_despawn_count{ 0 };
uint32_t m_despawns_read{ 0 };
uint32_t m_entities_read{ 0 };
public:
explicit WorldStateReader(std::span<const std::byte> data) noexcept
: m_r(data) {}
/** Read the 12-byte header. Must be called first. */
WorldStateHeader read_header() noexcept {
// m_header.packet_type = m_r.decode<uint32_t>();
m_header.frame_idx = m_r.decode<uint32_t>();
m_header.entity_count = m_r.decode<uint32_t>();
// spawn count follows immediately
m_spawn_count = m_r.decode<uint32_t>();
return m_header;
}
/** Read the next spawn entity id. Returns 0 when exhausted. */
bool has_spawn() const noexcept { return m_spawns_read < m_spawn_count; }
uint32_t read_spawn() noexcept {
assert(has_spawn());
++m_spawns_read;
uint32_t id = m_r.decode<uint32_t>();
// if (!has_spawn()) {
// // transition to despawns
// m_despawn_count = m_r.decode<uint32_t>();
// m_phase = Phase::Despawns;
// }
return id;
}
/** Skip remaining spawns and enter despawn phase. */
void skip_spawns() noexcept {
while (has_spawn()) read_spawn();
}
bool has_despawn() const noexcept { return m_despawns_read < m_despawn_count; }
uint32_t read_despawn() noexcept {
assert(has_despawn());
++m_despawns_read;
uint32_t id = m_r.decode<uint32_t>();
return id;
}
void skip_despawns() noexcept {
while (has_despawn()) read_despawn();
}
bool has_entity() const noexcept {
return m_entities_read < m_header.entity_count;
}
EntityRecord read_entity() noexcept {
assert(has_entity());
EntityRecord rec;
rec.id = m_r.read<uint32_t>();
m_r.read_bytes(&rec.position.x, 3 * sizeof(float));
++m_entities_read;
return rec;
}
};
} // namespace tw::serial
@@ -0,0 +1,53 @@
project(tw_serialization_tests)
set(LIBS
tw::network
)
file(GLOB FILES
./*.cpp
)
# add_library(${PROJECT_NAME}_sources OBJECT ${FILES})
# target_link_libraries(${PROJECT_NAME}_sources
# Catch2::Catch2WithMain
# ${LIBS}
# tl::expected
# tw::protocol
# )
# add_executable(${PROJECT_NAME})
add_executable(SerializationBenchmarks ./SerializationBenchmarks.cpp)
target_compile_definitions(SerializationBenchmarks PRIVATE TRACY_ON_DEMAND=1)
target_link_libraries(SerializationBenchmarks
PRIVATE
${LIBS}
# ${PROJECT_NAME}_sources
towards
tw::network
tw::protocol
tw::serialization
Tracy::TracyClient
TracyClient
Catch2::Catch2WithMain
tl::expected
EnTT::EnTT
)
# target_link_libraries(${PROJECT_NAME}
# PRIVATE
# ${LIBS}
# ${PROJECT_NAME}_sources
# Catch2::Catch2WithMain
# tl::expected
# EnTT::EnTT
# )
list(APPEND CMAKE_MODULE_PATH ${catch2_SOURCE_DIR}/extras)
include(CTest)
include(Catch)
# catch_discover_tests(${PROJECT_NAME})
@@ -0,0 +1,185 @@
#include <absl/strings/str_format.h>
#include <flatbuffers/flatbuffer_builder.h>
#include <spdlog/spdlog.h>
#include <sys/uio.h>
#include "WorldState.pb.h"
#include "messages/WorldState_generated.h"
// tw::serial — our custom zero-allocation codec
#include <tw/serial/Serial.hpp>
struct Position {
public:
float x, y, z;
};
const uint32_t NUM_CLIENTS = 1000;
const uint32_t NUM_ENTITIES = 300;
void protobuf() {
std::vector<mmo::WorldStateMessage> messages(NUM_CLIENTS * 100); // Simulate 100 frames
for (int frame = 0; frame < 100; ++frame) {
for (int i = 0; i < NUM_ENTITIES; ++i) {
Position pos;
pos.x = i * 1.5f + 1.0f;
pos.y = i * 2.0f + 2.0f;
pos.z = i * 3.0f + 3.0f;
for (int msg_idx = frame * NUM_CLIENTS; msg_idx < (frame + 1) * NUM_CLIENTS; ++msg_idx) {
auto entity = messages[msg_idx].mutable_entities()->Add();
entity->set_x(pos.x);
entity->set_y(pos.y);
entity->set_z(pos.z);
}
}
}
}
void flatbuffers_benchmarks(std::vector<Position> positions) {
std::vector<flatbuffers::FlatBufferBuilder> builders;
builders.reserve(NUM_CLIENTS);
std::vector<std::vector<flatbuffers::Offset<PlayerInfo>>> players(NUM_CLIENTS);
for (int i = 0; i < NUM_CLIENTS; i++) {
builders.emplace_back(4096);
players[i].resize(NUM_ENTITIES);
}
// reused per frame
std::vector<iovec> iovecs(NUM_CLIENTS);
spdlog::info("Running flatbuffers benchmarks...");
for (int frame = 0; frame < 100; ++frame) {
for(int position = 0; position < positions.size(); position++) {
for(int client = 0; client < NUM_CLIENTS; client++) {
auto& builder = builders[client];
auto id = position;
players[client][position] = (
CreatePlayerInfo(builder, id, (Vec3*)&positions[position])
);
}
}
for(int client = 0; client < NUM_CLIENTS; client++ ) {
auto& builder = builders[client];
auto players_vec2 = builder.CreateVector(players[client]);
auto world = CreateWorldState(builder, players_vec2);
builder.Finish(world);
builder.Clear();
}
}
}
void ours_benchmark(std::vector<Position>& positions) {
std::vector<std::vector<std::byte>> buffers(NUM_CLIENTS, std::vector<std::byte>(1024*64));
for(int frame = 0; frame < 100; frame++) {
for(int position = 0; position < positions.size(); position++) {
for(int client = 0; client < NUM_CLIENTS; client++) {
memcpy(&buffers[client][position * (sizeof(uint32_t) + sizeof(Position))], &position, sizeof(int32_t));
memcpy(&buffers[client][position * (sizeof(uint32_t) + sizeof(Position)) + sizeof(uint32_t)], &positions[position], sizeof(Position));
}
}
}
}
/**
* tw::serial benchmark — demonstrates the full WorldStateWriter API.
*
* Mimics the exact access pattern of StateReplicator::replicate():
* - One BinaryBuffer per client, pre-allocated and reused every frame.
* - One WorldStateWriter per client per frame.
* - Entities written in the inner loop after header/spawns/despawns.
* - entity_count patched at the end.
*/
void tw_serial_benchmark(std::vector<Position>& positions) {
// Pre-allocate one buffer per client
const std::size_t capacity = 20 + NUM_ENTITIES * 16;
std::vector<tw::serial::BinaryBuffer> buffers(NUM_CLIENTS);
for (auto& buf : buffers) {
buf.reserve(capacity);
}
for (int frame = 0; frame < 100; ++frame) {
// ── Phase 1: write header + empty spawns/despawns ─────────────────
std::vector<std::size_t> entity_count_offsets(NUM_CLIENTS);
for (int client = 0; client < NUM_CLIENTS; ++client) {
auto& buf = buffers[client];
buf.reset();
tw::serial::BinaryWriter w(buf);
// packet_type
w.encode<uint32_t>(tw::serial::kWorldStatePacketType);
// frame_idx
w.encode<uint32_t>(static_cast<uint32_t>(frame));
// entity_count placeholder
entity_count_offsets[client] = buf.size();
w.encode<uint32_t>(0u);
// no spawns/despawns in this benchmark
w.encode<uint32_t>(0u); // spawn_count
w.encode<uint32_t>(0u); // despawn_count
}
// ── Phase 2: scatter entity positions (hot path) ──────────────────
for (int p = 0; p < static_cast<int>(positions.size()); ++p) {
const uint32_t id = static_cast<uint32_t>(p);
for (int client = 0; client < NUM_CLIENTS; ++client) {
buffers[client].append(&id, sizeof(uint32_t));
buffers[client].append(&positions[p].x, 3 * sizeof(float));
}
}
// ── Phase 3: patch entity_count ───────────────────────────────────
for (int client = 0; client < NUM_CLIENTS; ++client) {
buffers[client].patch_u32(entity_count_offsets[client],
static_cast<uint32_t>(positions.size()));
}
}
}
int main() {
// create test data
spdlog::info("Running benchmarks...");
std::vector<Position> positions(NUM_ENTITIES);
// Protobuf Benchmark
auto ours_start = std::chrono::high_resolution_clock::now();
ours_benchmark(positions);
auto ours_end = std::chrono::high_resolution_clock::now();
std::chrono::duration<double> ours_elapsed = ours_end - ours_start;
spdlog::info("Ours elapsed: {} seconds", ours_elapsed.count());
// Protobuf Benchmark
auto protobuf_start = std::chrono::high_resolution_clock::now();
protobuf();
auto protobuf_end = std::chrono::high_resolution_clock::now();
std::chrono::duration<double> protobuf_elapsed = protobuf_end - protobuf_start;
spdlog::info("Protobuf elapsed: {} seconds", protobuf_elapsed.count());
// FlatBuffers Benchmark
auto flatbuffers_start = std::chrono::high_resolution_clock::now();
flatbuffers_benchmarks(positions);
auto flatbuffers_end = std::chrono::high_resolution_clock::now();
std::chrono::duration<double> flatbuffers_elapsed = flatbuffers_end - flatbuffers_start;
spdlog::info("FlatBuffers elapsed: {} seconds", flatbuffers_elapsed.count());
// tw::serial Benchmark
auto tw_serial_start = std::chrono::high_resolution_clock::now();
tw_serial_benchmark(positions);
auto tw_serial_end = std::chrono::high_resolution_clock::now();
std::chrono::duration<double> tw_serial_elapsed = tw_serial_end - tw_serial_start;
spdlog::info("tw::serial elapsed: {} seconds", tw_serial_elapsed.count());
return 0;
}