#5 - fixed interpolation & rollback

This commit is contained in:
Martin Slachta
2026-08-05 15:20:56 +02:00
parent 2e95c941ba
commit 0e639abcd2
14 changed files with 243 additions and 40 deletions
+2 -2
View File
@@ -27,8 +27,8 @@ $ conan install . --output-folder=build --build=missing -s build_type=Debug
Configure and build using the generated preset:
```bash
$ cmake --preset conan-debug
$ cmake --build --preset conan-debug
$ cmake -S . -B ./build -DCMAKE_TOOLCHAIN_FILE="build/conan_toolchain.cmake" -DCMAKE_BUILD_TYPE=Debug
$ cmake --build ./build
```
For a release build, repeat both steps with `-s build_type=Release` and the
+2 -11
View File
@@ -1,6 +1,6 @@
services:
timescaledb:
image: timescale/timescaledb:latest-pg16
image: timescale/timescaledb:latest-pg15
restart: unless-stopped
environment:
POSTGRES_USER: mmo
@@ -9,7 +9,7 @@ services:
ports:
- "5432:5432"
volumes:
- timescaledb_data:/var/lib/postgresql/data
- timescaledb_data:/var/lib/towards_db/data
grafana:
image: grafana/grafana:latest
@@ -24,15 +24,6 @@ services:
depends_on:
- timescaledb
# The zone server is not containerised yet: it needs libpqxx >= 7.7 for
# pqxx::params and jammy carries 6.4.
# ── e2e harness ────────────────────────────────────────────────────────────
# Opt in with `--profile e2e`, so a plain `docker compose up` still brings up
# the database and dashboards on their own.
#
# docker compose --profile e2e up --build \
# --abort-on-container-exit --exit-code-from chat-mock-client
chat-server:
profiles: [e2e]
build:
+3
View File
@@ -2,6 +2,9 @@
layout(location = 0) out vec4 outColor;
layout(location = 0) in vec3 inNormal;
void main() {
float diffuse = max(0.1, dot(vec3(0.8, 1.5, -1.0), inNormal));
outColor = vec4(1.0, 0.0, 0.0, 1.0);
}
+2
View File
@@ -2,6 +2,7 @@
layout(location = 0) in vec3 pos;
layout(location = 1) in vec3 norm;
layout(location = 0) out vec3 outNormal;
layout(set = 0, binding = 0) uniform Camera {
mat4 proj;
@@ -21,5 +22,6 @@ void main() {
// outPos = pos.xyz * 0.05;
// outNormal = norm.xyz;
// outUV = uv;
outNormal = norm;
gl_Position = cam.proj * cam.view * PushConstants.transform * vec4(pos, 1.0);
}
@@ -101,10 +101,54 @@ class tw::dbg::ComponentGui<tw::net::EntityPositionInterpolation> {
private:
using Clock = std::chrono::high_resolution_clock;
/** How far back the plot reaches. */
static constexpr float WINDOW_IN_SECONDS = 10.0f;
/** Ten seconds of frames at sixty a second. */
static constexpr size_t CAPACITY = 600;
inline static const net::EntityPositionInterpolation* m_subject = nullptr;
inline static int m_last_frame = -1;
inline static size_t m_head = 0;
inline static size_t m_count = 0;
inline static std::vector<Clock::time_point> m_read_times;
inline static std::vector<glm::vec3> m_read_values;
static int64_t millis_since(Clock::time_point point) {
return std::chrono::duration_cast<std::chrono::milliseconds>(Clock::now() - point).count();
}
void restart(const net::EntityPositionInterpolation* instance) {
m_subject = instance;
m_last_frame = -1;
m_head = 0;
m_count = 0;
m_read_times.resize(CAPACITY);
m_read_values.resize(CAPACITY);
}
/**
* Keeps one reading per frame, so drawing twice does not double up. The
* whole value is kept rather than the axis on show, so switching axis
* still shows the trace that was already gathered.
*/
void sample(Clock::time_point time, glm::vec3 value) {
const int frame = ImGui::GetFrameCount();
if(m_last_frame == frame) {
return;
}
m_last_frame = frame;
m_read_times[m_head] = time;
m_read_values[m_head] = value;
m_head = (m_head + 1) % CAPACITY;
m_count = std::min(m_count + 1, CAPACITY);
}
public:
void draw(net::EntityPositionInterpolation* instance) {
ImGui::SeparatorText("Received Positions");
@@ -122,5 +166,73 @@ public:
instance->values()[0].y,
instance->values()[0].z,
millis_since(instance->times()[0]));
static int axis = 0;
ImGui::Combo("Axis##received", &axis, "X\0Y\0Z\0");
/** How far behind the present the reader looks; matches the caller. */
static int delay_in_millis = 1000;
ImGui::SliderInt("Delay (ms)", &delay_in_millis, 0, 2000);
if(m_subject != instance) {
restart(instance);
}
// The buffer wraps, so the index says nothing about when a sample
// arrived. Placing each one at its own age puts it where it belongs
// without having to know where the ring currently starts. Slots
// nothing was pushed into are left out.
const auto now = Clock::now();
const size_t count = instance->size();
std::vector<float> ages(count);
std::vector<float> positions(count);
for(size_t i = 0; i < count; i++) {
ages[i] = -std::chrono::duration<float>(now - instance->times()[i]).count();
positions[i] = instance->values()[i][axis];
}
if(ImPlot::BeginPlot("Buffer", ImVec2(-1.0f, 180.0f))) {
ImPlot::SetupAxes("seconds ago", "position", ImPlotAxisFlags_None, ImPlotAxisFlags_AutoFit);
ImPlot::SetupAxisLimits(ImAxis_X1,
-WINDOW_IN_SECONDS, 0.0f, ImGuiCond_Always);
ImPlot::PlotScatter("Received", ages.data(), positions.data(), (int)count);
// Reading the same way the position is read for drawing shows
// which two samples the delay lands between, and how far the
// result sits from either of them.
const auto point = now - std::chrono::milliseconds(delay_in_millis);
const auto [from, to, alpha] = instance->get_values_around(point);
const float read_at = -std::chrono::duration<float>(now - point).count();
const float read_out = glm::mix(from, to, alpha)[axis];
ImPlot::SetNextMarkerStyle(ImPlotMarker_Circle, 5.0f);
ImPlot::PlotScatter("Interpolated", &read_at, &read_out, 1);
// Each reading is kept at the moment it was read for, so the trace
// ends on the marker above and runs back along the same timeline
// the received samples sit on.
sample(point, glm::mix(from, to, alpha));
std::vector<float> trace_ages(m_count);
std::vector<float> trace_positions(m_count);
const size_t oldest = m_count == CAPACITY ? m_head : 0;
for(size_t i = 0; i < m_count; i++) {
const size_t index = (oldest + i) % CAPACITY;
trace_ages[i] = -std::chrono::duration<float>(now - m_read_times[index]).count();
trace_positions[i] = m_read_values[index][axis];
}
ImPlot::PlotLine("Interpolated Trace",
trace_ages.data(), trace_positions.data(), (int)m_count);
ImPlot::EndPlot();
}
}
};
@@ -25,7 +25,7 @@ void EntityPositionInterpolator::set_position(Clock::time_point time_point, entt
return;
}
interpolation->push(Clock::now(), position);
interpolation->push(time_point, position);
}
glm::vec3 EntityPositionInterpolator::get_position(Clock::time_point time_point, entt::entity entity) {
@@ -35,9 +35,7 @@ glm::vec3 EntityPositionInterpolator::get_position(Clock::time_point time_point,
return glm::vec3();
}
auto now = Clock::now() - std::chrono::milliseconds(m_bufferingIntervalInMillis);
auto [from, to, value] = interpolation->get_values_around(now);
auto [from, to, value] = interpolation->get_values_around(time_point);
return glm::mix(from, to, value);
}
@@ -45,6 +43,10 @@ void EntityPositionInterpolator::interpolate_smoothed_entities(Clock::time_point
m_registry->view<EntityPositionInterpolation, Transform>()
.each([&](const auto entity, const EntityPositionInterpolation& interpolation, Transform& ts) {
auto [from, to, value] = interpolation.get_values_around(time_point);
spdlog::info("From: {} {} {} - To: {} {} {} - By: {}",
from.x, from.y, from.z,
to.x, to.y, to.z,
value);
ts.set_position(glm::mix(from, to, value));
});
}
@@ -5,15 +5,21 @@
#include "common.hpp"
template<typename T, int length = 3>
template<typename T, int length = 10>
class InterpolatedProperty {
using Clock = std::chrono::high_resolution_clock;
std::array<T, length> m_buffer;
std::array<Clock::time_point, length> m_time_buffer;
std::vector<T> m_buffer;
std::vector<Clock::time_point> m_time_buffer;
uint32_t m_head;
uint32_t m_size;
public:
InterpolatedProperty(T initial_value) {
InterpolatedProperty(T initial_value) :
m_buffer(length),
m_time_buffer(length),
m_size(0), m_head(0) {
for(int i = 0; i < length; i++) {
m_buffer[i] = initial_value;
m_time_buffer[i] = Clock::now();
@@ -23,7 +29,21 @@ public:
GET_REF(m_buffer, values);
GET_REF(m_time_buffer, times);
/** How many slots hold something that was pushed. */
inline uint32_t size() const {
return m_size;
}
void push(Clock::time_point point, T value) {
// m_buffer[m_head] = std::move(value);
// m_time_buffer[m_head] = point;
//
// m_head = (m_head + 1) % m_buffer.size();
//
// if (m_size < m_buffer.size())
// {
// ++m_size;
// }
if(point < m_time_buffer.at(0)) {
return;
}
@@ -64,6 +84,46 @@ public:
}
inline std::tuple<T, T, float> get_values_around(Clock::time_point point) const {
/* const std::size_t capacity = m_buffer.size();
const std::size_t oldest = (m_head + capacity - m_size) % capacity;
if (m_size == 0)
{
return { T(), T(), 0.0f };
}
// Asking for a time the buffer no longer reaches back to. Standing on
// the oldest is wrong by however much was missed, but it is wrong in
// the direction the values were heading, unlike the newest.
if (point < m_time_buffer[oldest])
{
return { m_buffer[oldest], m_buffer[oldest], 0.0f };
}
for (std::size_t i = 0; i + 1 < m_size; ++i)
{
const std::size_t idx0 = (oldest + i) % capacity;
const std::size_t idx1 = (oldest + i + 1) % capacity;
const auto t0 = m_time_buffer[idx0];
const auto t1 = m_time_buffer[idx1];
if (point >= t0 && point <= t1)
{
const auto total =
std::chrono::duration<float>(t1 - t0).count();
const auto elapsed =
std::chrono::duration<float>(point - t0).count();
const float alpha = total > 0.0f ? elapsed / total : 0.0f;
return { m_buffer[idx0], m_buffer[idx1], alpha };
}
}
// Handle exact last sample (or clamp)
const std::size_t last = (oldest + m_size - 1) % capacity;
return { m_buffer[last], m_buffer[last], 0.0f }; */
T prev = m_buffer.at(0);
Clock::time_point prev_point = m_time_buffer.at(0);
@@ -151,6 +151,10 @@ void ReplicatorClient::handle_snapshot_entity(
// return;
}
// Applies correction from record_frame_idx to current_frame_idx (if any) on entity. Compares previous position on
// frame record_frame_idx with p
m_rollback.apply_correction(record_frame_idx, p, entity.value(), &m_world->registry(), current_frame_idx);
//snap_player_to(current_frame_idx, entity.value(), p);
// bool reconcile_happened = m_reconciler.reconcile(record_frame_idx, p, entity.value(), &m_world->registry(), current_frame_idx);
@@ -168,15 +172,29 @@ void ReplicatorClient::handle_snapshot_entity(
// }
// m_network_metrics->record_ack_lag(ack_lag);
// }
}
m_entity_interpolator->set_position(std::chrono::high_resolution_clock::now(), entity.value(), p);
} else {
m_entity_interpolator->set_position(std::chrono::high_resolution_clock::now(), entity.value(), p);
}
}
void ReplicatorClient::handle_snapshot(uint32_t current_frame_idx, serial::WorldStateReader& reader) {
auto header = reader.read_header();
measure_response_time(current_frame_idx, header.frame_idx);
// Snapshots are sent unreliably, so one can overtake another. Taking a
// late one would put entities back where they have already been seen.
if(header.tick_idx <= m_latest_tick_idx) {
return;
}
m_latest_tick_idx = header.tick_idx;
// Arrival is the only time the client can be sure of, and it grows with
// every snapshot, which is what reading the buffer back relies on. What it
// costs is spacing: the gaps between samples carry the jitter of the way
// here rather than the even gaps the server sent them on.
const auto taken_at = std::chrono::high_resolution_clock::now();
while(reader.has_entity()) {
auto entity_r = reader.read_entity();
@@ -86,6 +86,7 @@ private:
net::PlayerRollback m_rollback;
World *m_world;
net::ServerConnection* m_server_connection;
uint32_t m_latest_tick_idx = 0;
std::optional<entt::entity> m_player_entity;
@@ -50,7 +50,12 @@ void ThirdPersonPlayerController::update(const tw::io::InputManager* input, doub
m_camera_rotation = yaw * m_camera_rotation * pitch;
glm::vec3 offset = m_camera_rotation * glm::vec3(0, 0, m_camera_zoom);
m_camera->view().look_at(get_target_position() + offset, get_target_position());
glm::vec3 from = m_camera->view().position();
glm::vec3 to = get_target_position() + offset;
glm::vec3 interpolated = glm::mix(from, to, 0.8f);
m_camera->view().look_at(interpolated, get_target_position());
}
}
@@ -25,8 +25,6 @@
namespace tw {
typedef HistoryBuffer<long, glm::vec3> EntityPositionHistory;
entt::entity
ClientWorldController::create_entity(const std::string& name, glm::vec3 position) {
const auto entity = m_world->registry().create();
@@ -82,8 +80,8 @@ void ClientWorldController::spawn_entity(const std::string& name, uint32_t serve
if(m_controlled_server_id.has_value() && m_controlled_server_id.value() == server_id) {
try_bind_player_entity();
} else {
m_interpolator.register_entity(entity);
}
m_interpolator.register_entity(entity);
}
ClientWorldController::ClientWorldController(
@@ -165,9 +163,9 @@ void ClientWorldController::try_bind_player_entity() {
position
));
// if(m_world->registry().all_of<net::EntityPositionInterpolation>(entity)) {
// m_world->registry().remove<net::EntityPositionInterpolation>(entity);
// }
if(m_world->registry().all_of<net::EntityPositionInterpolation>(entity)) {
m_world->registry().remove<net::EntityPositionInterpolation>(entity);
}
}
@@ -187,7 +185,6 @@ void ClientWorldController::update_network() {
});
m_replicator_client.record_prediction(m_frame_idx);
// m_world->registry().view<Transform>()
@@ -216,10 +213,11 @@ void ClientWorldController::update(double delta_time) {
if(controller) {
// controller->set_input(m_network_frame_idx, input);
m_replicator_client.set_input(m_network_frame_idx, input);
m_replicator_client.record_prediction(m_network_frame_idx);
}
}
// m_physics_world->step(m_network_frame_idx, JoltPhysicsWorld::FIXED_DELTA_TIME, true);
m_physics_world->step(m_network_frame_idx, JoltPhysicsWorld::FIXED_DELTA_TIME, true);
m_network_frame_idx++;
}
@@ -230,7 +228,7 @@ void ClientWorldController::update(double delta_time) {
// TODO: The third person controller could pull
Transform* player_transform = m_world->registry().try_get<Transform>(m_player_entity.value());
if(player_transform) {
m_player_controller.set_target(player_transform->position());
// m_player_controller.set_target(player_transform->position());
}
}
@@ -23,7 +23,7 @@ class WorldStateWriter {
public:
explicit WorldStateWriter(BinaryBuffer& buf) noexcept : m_w(buf) {}
void begin(uint32_t frame_idx, uint32_t message_type) noexcept {
void begin(uint32_t frame_idx, uint32_t tick_idx, uint32_t message_type) noexcept {
m_entity_count = 0;
// message_type — lets the receiver dispatch without peeking further
@@ -35,6 +35,10 @@ public:
// frame_idx
m_w.encode<uint32_t>(frame_idx);
// tick_idx — counts the states that went out, so the receiver can tell
// a late one from a new one. Says nothing about the frame answered.
m_w.encode<uint32_t>(tick_idx);
// entity_count placeholder — patched when end() is called
m_entity_count_offset = m_w.reserve_u32();
}
@@ -85,6 +89,7 @@ public:
struct WorldStateHeader {
uint32_t packet_type;
uint32_t frame_idx;
uint32_t tick_idx;
uint32_t entity_count;
};
@@ -109,10 +114,11 @@ public:
explicit WorldStateReader(std::span<const std::byte> data) noexcept
: m_r(data) {}
/** Read the 12-byte header. Must be called first. */
/** Read the 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.tick_idx = m_r.decode<uint32_t>();
m_header.entity_count = m_r.decode<uint32_t>();
// spawn count follows immediately
+4 -1
View File
@@ -62,6 +62,9 @@ void ZoneServer::player_update_handler(SessionId session_id, mmo::PlayerMoveMess
if (it == m_session_zone.end()) return;
if (auto* session = m_player_session_registry->session(session_id)) {
if(session->last_received_frame >= message.frame_idx()) {
return;
}
session->last_received_frame = message.frame_idx();
}
@@ -141,7 +144,7 @@ void ZoneServer::run() {
}
}
m_replicator->replicate(zone->registry(), zone->interest());
m_replicator->replicate(frame_idx, zone->registry(), zone->interest());
}
m_network_receiver->update();
@@ -29,8 +29,8 @@ class StateReplicator {
// Per-client backing buffers reused every frame.
std::vector<tw::serial::BinaryBuffer> m_frames;
// Header(16) + spawn_hdr(4) + despawn_hdr(4) + 512 entities × 16 bytes
static constexpr std::size_t kHeaderCapacity = 24;
// Header(20) + spawn_hdr(4) + despawn_hdr(4) + 512 entities × 16 bytes
static constexpr std::size_t kHeaderCapacity = 28;
static constexpr std::size_t kInitialCapacity = kHeaderCapacity + 512 * 16;
public:
@@ -46,6 +46,7 @@ public:
* Replicates the current world state for one zone to its connected clients.
*/
void replicate(
uint32_t frame_idx,
const entt::registry& registry,
const im::InterestSystem<Backend>* interest_manager
) {
@@ -88,7 +89,8 @@ public:
m_frames[i].reserve(needed);
writers[i].reset();
writers[i].begin(session->last_received_frame, Message<mmo::WorldStateMessage>::value);
writers[i].begin(session->last_received_frame, frame_idx,
Message<mmo::WorldStateMessage>::value);
writers[i].write_spawns(state->spawn());
writers[i].write_despawns(state->despawn());