diff --git a/README.md b/README.md index 05e18f7..35f799e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/compose.yaml b/compose.yaml index 5d14307..9a37e76 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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: diff --git a/modules/client/shaders/Opaque.frag b/modules/client/shaders/Opaque.frag index 74e14f4..e01b8d8 100644 --- a/modules/client/shaders/Opaque.frag +++ b/modules/client/shaders/Opaque.frag @@ -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); } diff --git a/modules/client/shaders/Opaque.vert b/modules/client/shaders/Opaque.vert index 8ab0dec..8293ea6 100644 --- a/modules/client/shaders/Opaque.vert +++ b/modules/client/shaders/Opaque.vert @@ -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); } diff --git a/modules/client/src/debug/EntityInterpolationDebug.hpp b/modules/client/src/debug/EntityInterpolationDebug.hpp index 4daca0c..ef023cc 100644 --- a/modules/client/src/debug/EntityInterpolationDebug.hpp +++ b/modules/client/src/debug/EntityInterpolationDebug.hpp @@ -101,10 +101,54 @@ class tw::dbg::ComponentGui { 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 m_read_times; + inline static std::vector m_read_values; + static int64_t millis_since(Clock::time_point point) { return std::chrono::duration_cast(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 ages(count); + std::vector positions(count); + + for(size_t i = 0; i < count; i++) { + ages[i] = -std::chrono::duration(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(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 trace_ages(m_count); + std::vector 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(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(); + } } }; diff --git a/modules/client/src/network/EntityPositionInterpolator.cpp b/modules/client/src/network/EntityPositionInterpolator.cpp index 63d432b..1253370 100644 --- a/modules/client/src/network/EntityPositionInterpolator.cpp +++ b/modules/client/src/network/EntityPositionInterpolator.cpp @@ -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() .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)); }); } diff --git a/modules/client/src/network/InterpolatedProperty.hpp b/modules/client/src/network/InterpolatedProperty.hpp index d21e575..b503ecd 100644 --- a/modules/client/src/network/InterpolatedProperty.hpp +++ b/modules/client/src/network/InterpolatedProperty.hpp @@ -5,15 +5,21 @@ #include "common.hpp" -template +template class InterpolatedProperty { using Clock = std::chrono::high_resolution_clock; - std::array m_buffer; - std::array m_time_buffer; + std::vector m_buffer; + std::vector 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 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(t1 - t0).count(); + const auto elapsed = + std::chrono::duration(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); diff --git a/modules/client/src/network/ReplicatorClient.cpp b/modules/client/src/network/ReplicatorClient.cpp index 4e9614d..4e6eccf 100644 --- a/modules/client/src/network/ReplicatorClient.cpp +++ b/modules/client/src/network/ReplicatorClient.cpp @@ -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(); diff --git a/modules/client/src/network/ReplicatorClient.hpp b/modules/client/src/network/ReplicatorClient.hpp index 00bfd9f..be8c44d 100644 --- a/modules/client/src/network/ReplicatorClient.hpp +++ b/modules/client/src/network/ReplicatorClient.hpp @@ -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 m_player_entity; diff --git a/modules/client/src/world/ThirdPersonPlayerController.cpp b/modules/client/src/world/ThirdPersonPlayerController.cpp index 39a2860..44f24f2 100644 --- a/modules/client/src/world/ThirdPersonPlayerController.cpp +++ b/modules/client/src/world/ThirdPersonPlayerController.cpp @@ -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()); } } diff --git a/modules/client/src/world/controllers/ClientWorldController.cpp b/modules/client/src/world/controllers/ClientWorldController.cpp index 3045291..8eda49c 100644 --- a/modules/client/src/world/controllers/ClientWorldController.cpp +++ b/modules/client/src/world/controllers/ClientWorldController.cpp @@ -25,8 +25,6 @@ namespace tw { -typedef HistoryBuffer 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(entity)) { - // m_world->registry().remove(entity); - // } + if(m_world->registry().all_of(entity)) { + m_world->registry().remove(entity); + } } @@ -187,7 +185,6 @@ void ClientWorldController::update_network() { }); - m_replicator_client.record_prediction(m_frame_idx); // m_world->registry().view() @@ -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(m_player_entity.value()); if(player_transform) { - m_player_controller.set_target(player_transform->position()); + // m_player_controller.set_target(player_transform->position()); } } diff --git a/modules/serialization/include/tw/serial/WorldStateWriter.hpp b/modules/serialization/include/tw/serial/WorldStateWriter.hpp index 0b9df87..f05d395 100644 --- a/modules/serialization/include/tw/serial/WorldStateWriter.hpp +++ b/modules/serialization/include/tw/serial/WorldStateWriter.hpp @@ -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(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(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 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(); m_header.frame_idx = m_r.decode(); + m_header.tick_idx = m_r.decode(); m_header.entity_count = m_r.decode(); // spawn count follows immediately diff --git a/modules/server/src/ZoneServer.cpp b/modules/server/src/ZoneServer.cpp index da1f58b..df8c1a6 100644 --- a/modules/server/src/ZoneServer.cpp +++ b/modules/server/src/ZoneServer.cpp @@ -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(); diff --git a/modules/server/src/replication/StateReplicator.hpp b/modules/server/src/replication/StateReplicator.hpp index 2266395..515bf19 100644 --- a/modules/server/src/replication/StateReplicator.hpp +++ b/modules/server/src/replication/StateReplicator.hpp @@ -29,8 +29,8 @@ class StateReplicator { // Per-client backing buffers reused every frame. std::vector 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* interest_manager ) { @@ -88,7 +89,8 @@ public: m_frames[i].reserve(needed); writers[i].reset(); - writers[i].begin(session->last_received_frame, Message::value); + writers[i].begin(session->last_received_frame, frame_idx, + Message::value); writers[i].write_spawns(state->spawn()); writers[i].write_despawns(state->despawn());