diff --git a/kernel/src/kernel/event.cpp b/kernel/src/kernel/event.cpp new file mode 100644 index 0000000..290b34a --- /dev/null +++ b/kernel/src/kernel/event.cpp @@ -0,0 +1,43 @@ +#include "event.hpp" +#include + +namespace aethera { +Event_Statistics_Accumulator::Event_Statistics_Accumulator( + std::size_t capacity) { + if (capacity == 0) + throw std::invalid_argument( + "event statistics capacity must be greater than zero"); + for (auto& event : values_) + for (auto& statistic : event) + statistic = Sliding_Statistics{capacity}; +} + +void Event_Statistics_Accumulator::submit( + Event_Type type, std::uint64_t created_steady_ns, + std::uint64_t started_steady_ns, + std::uint64_t completed_steady_ns) { + const auto type_index = static_cast(type); + if (type_index >= event_type_count || created_steady_ns == 0 || + started_steady_ns < created_steady_ns || + completed_steady_ns < started_steady_ns) + return; + const std::array samples{ + static_cast(started_steady_ns - created_steady_ns) / 1'000'000.0, + static_cast(completed_steady_ns - started_steady_ns) / 1'000'000.0, + static_cast(completed_steady_ns - created_steady_ns) / 1'000'000.0}; + for (std::size_t index = 0; index < samples.size(); ++index) + state_.values[type_index][index] = + values_[type_index][index].submit(samples[index]); +} + +const Event_Statistics_State& Event_Statistics_Accumulator::state() const noexcept { + return state_; +} + +void Event_Statistics_Accumulator::reset() noexcept { + for (auto& event : values_) + for (auto& statistic : event) + statistic.reset(); + state_ = {}; +} +} diff --git a/kernel/src/kernel/event.hpp b/kernel/src/kernel/event.hpp index adef7ed..3ad5de8 100644 --- a/kernel/src/kernel/event.hpp +++ b/kernel/src/kernel/event.hpp @@ -1,4 +1,6 @@ #pragma once +#include "frame_statistics.hpp" +#include #include #include #include @@ -16,10 +18,39 @@ enum class Event_Type : std::uint8_t { key_press, key_release }; +inline constexpr std::size_t event_type_count = + static_cast(Event_Type::key_release) + 1; +enum class Event_Statistic : std::uint8_t { + queue_wait_ms, + dispatch_ms, + total_ms, + count +}; +inline constexpr std::size_t event_statistic_count = + static_cast(Event_Statistic::count); +struct Event_Statistics_State { + std::array, + event_type_count> values{}; /* 按事件业务类型发布的定长延迟统计结果。 */ + bool operator==(const Event_Statistics_State&) const = default; +}; +class Event_Statistics_Accumulator final { +public: + explicit Event_Statistics_Accumulator(std::size_t capacity = 600); + void submit(Event_Type type, std::uint64_t created_steady_ns, + std::uint64_t started_steady_ns, + std::uint64_t completed_steady_ns); + [[nodiscard]] const Event_Statistics_State& state() const noexcept; + void reset() noexcept; +private: + std::array, + event_type_count> values_; /* Scene 单帧准入域内增量维护的统计器。 */ + Event_Statistics_State state_{}; /* 下一次 Scene State 交换时发布的结果。 */ +}; /* 所有输入事件的公共业务基类。 */ struct Event { struct Dispatch_Timing { std::uint64_t frame_sequence{}; + std::uint64_t created_steady_ns{}; std::uint64_t started_steady_ns{}; std::uint64_t completed_steady_ns{}; }; @@ -32,6 +63,10 @@ struct Event { [[nodiscard]] Dispatch_Timing dispatch_timing() const noexcept; Event_Type type; /* 事件种类;构造后保持不变。 */ private: + friend struct Scene; + void observe_with(Event_Statistics_Accumulator* accumulator) noexcept; + std::uint64_t created_steady_ns_{}; /* 事件对象进入 Scene 前的内核单调时刻。 */ + Event_Statistics_Accumulator* statistics_{}; /* 所属 Scene 的统计器;不拥有且不得跨 Scene。 */ mutable bool accepted{}; /* 处理链是否已经消费事件。 */ mutable std::atomic_uint64_t dispatch_frame_sequence_{}; mutable std::atomic_uint64_t dispatch_started_steady_ns_{}; diff --git a/kernel/src/kernel/event.ipp b/kernel/src/kernel/event.ipp index 29e38a0..0a7e2e5 100644 --- a/kernel/src/kernel/event.ipp +++ b/kernel/src/kernel/event.ipp @@ -7,7 +7,8 @@ inline std::uint64_t event_steady_time_ns() noexcept { std::chrono::steady_clock::now().time_since_epoch()).count()); } } -inline Event::Event(Event_Type value) : type(value) {} +inline Event::Event(Event_Type value) + : type(value), created_steady_ns_(detail::event_steady_time_ns()) {} inline Event::~Event() = default; inline void Event::accept() const noexcept { accepted = true; } inline bool Event::is_accepted() const noexcept { return accepted; } @@ -17,13 +18,24 @@ inline void Event::mark_dispatch_started(std::uint64_t frame_sequence) const noe dispatch_started_steady_ns_.store(detail::event_steady_time_ns(), std::memory_order_release); } inline void Event::mark_dispatch_completed() const noexcept { - dispatch_completed_steady_ns_.store(detail::event_steady_time_ns(), std::memory_order_release); + const auto completed = detail::event_steady_time_ns(); + dispatch_completed_steady_ns_.store(completed, std::memory_order_release); + if (statistics_) + statistics_->submit( + type, created_steady_ns_, + dispatch_started_steady_ns_.load(std::memory_order_acquire), + completed); } inline Event::Dispatch_Timing Event::dispatch_timing() const noexcept { return {dispatch_frame_sequence_.load(std::memory_order_relaxed), + created_steady_ns_, dispatch_started_steady_ns_.load(std::memory_order_acquire), dispatch_completed_steady_ns_.load(std::memory_order_acquire)}; } +inline void Event::observe_with( + Event_Statistics_Accumulator* accumulator) noexcept { + statistics_ = accumulator; +} constexpr Keyboard_Modifier operator|(Keyboard_Modifier left, Keyboard_Modifier right) noexcept { return static_cast(static_cast(left) | static_cast(right)); } template Basic_Pointer_Event::Basic_Pointer_Event(Event_Type value) : Event(value) {} template double Basic_Pointer_Event::position_x() const noexcept { return static_cast(position.x); } diff --git a/kernel/src/kernel/scene.hpp b/kernel/src/kernel/scene.hpp index adff3c8..8ea50a6 100644 --- a/kernel/src/kernel/scene.hpp +++ b/kernel/src/kernel/scene.hpp @@ -22,6 +22,7 @@ struct Scene : Def runtime; /* Scene 唯一运行时构建产物的所有权。 */ + Event_Statistics_Accumulator event_statistics{}; /* Scene 单帧域内按事件类型增量计算。 */ Private(); ~Private(); template - [[nodiscard]] Event_Batch take_events(Object* object, std::uint64_t frame_sequence); + [[nodiscard]] Event_Batch take_events(Object* object); template void bind_private_crtp(Object* object); /* Def CRTP hook:所有缓冲推进后重建必要的总 Taskflow,并更新 Scene_State_Tag 状态层。 */ template @@ -39,23 +40,22 @@ void Scene::Private::bind_private_crtp(Object* object) { runtime = std::make_unique(); } template -Scene::Event_Batch Scene::Private::take_events(Object* object, std::uint64_t frame_sequence) { +Scene::Event_Batch Scene::Private::take_events(Object* object) { object->template exchange_stream(); return object->template access_rendering_stream( [&](std::span events) { Event_Batch result{object->memory_resource()}; result.reserve(events.size()); - for (const auto& event : events) { - event->mark_dispatch_started(frame_sequence); - result.push_back(event); - } + result.insert(result.end(), events.begin(), events.end()); return result; }); } template std::shared_ptr Scene::make_event(Arguments&&... arguments) { - return std::allocate_shared( + auto event = std::allocate_shared( allocator(), std::forward(arguments)...); + event->observe_with(&static_cast(*d).event_statistics); + return event; } template void Scene::Private::process(Object* object, Callback&& callback) requires std::invocable { @@ -69,6 +69,7 @@ void Scene::Private::process(Object* object, Render_Frame* frame, Callback&& cal if (frame) frame->mark(Frame_Trace_Marker::prepare_started); if (runtime->taskflow && !runtime->taskflow->empty()) state.taskflow_execution_time_ns = detail::run_taskflow(*runtime->taskflow); if (frame) frame->mark(Frame_Trace_Marker::prepare_finished); + state.event_statistics = event_statistics.state(); private_data.state.advance(); object->template notify_state(); Result result; diff --git a/render_2D/render_2D/scene/Render_Scene_2D.cpp b/render_2D/render_2D/scene/Render_Scene_2D.cpp index 25f96fd..9d79a88 100644 --- a/render_2D/render_2D/scene/Render_Scene_2D.cpp +++ b/render_2D/render_2D/scene/Render_Scene_2D.cpp @@ -1,4 +1,4 @@ -#include "Render_Scene_2D.hpp" /* 二维 Paint Taskflow、缓存失效与合成实现。 */ +#include "Render_Scene_2D.hpp" /* 二维 Paint Taskflow、批次事件路由、缓存失效与合成实现。 */ namespace aethera::render_2d { Render_Scene_2D::Private::~Private() = default; bool Render_Scene_2D::State::operator==(const State&) const = default; diff --git a/render_2D/render_2D/scene/Render_Scene_2D.ipp b/render_2D/render_2D/scene/Render_Scene_2D.ipp index 16b39f0..5725765 100644 --- a/render_2D/render_2D/scene/Render_Scene_2D.ipp +++ b/render_2D/render_2D/scene/Render_Scene_2D.ipp @@ -353,22 +353,39 @@ Render_Scene_2D::Private::render(Object* object, Frame_2D* frame) { template void Render_Scene_2D::Private::dispatch_events(Object* object, Size viewport, std::uint64_t frame_sequence) { - auto events = this->take_events(object, frame_sequence); + auto events = this->take_events(object); + if (events.empty()) return; + /* Paint 拓扑在一帧事件批次内不会变化。只解析一次候选对象;每个事件仍按 + * 当时的动态区域重新筛选、排序并完整分发,不合并也不丢弃输入。 */ + std::vector candidates; + const auto graph = object->template current_dependency_graph(); + const auto graph_result = graph.for_each_topological_view( + [&](const auto& view, const Dependency_Graph::Node& node) { + auto* renderable = view.object(node); + auto* data = view.private_data(node); + if (renderable && data && data->event_run) + candidates.push_back({renderable, data}); + }); + if (!graph_result) + throw std::logic_error( + "render scene paint graph became invalid during event dispatch"); for (const auto& owned_event : events) { const Event& event = *owned_event; + /* 批次交换只结束等帧阶段;每个事件真正进入路由时才开始计算分发耗时。 */ + event.mark_dispatch_started(frame_sequence); const auto* pointer = dynamic_cast(&event); std::vector order; - const auto graph = object->template current_dependency_graph(); - const auto result = graph.for_each_topological_view([&](const auto& view, const Dependency_Graph::Node& node) { - auto* renderable = view.object(node); auto* data = view.private_data(node); - if (!renderable || !data || !data->event_run) return; + order.reserve(candidates.size()); + for (const auto& target : candidates) { if (pointer) { - const Rect_F region = data->event_region_run(node.object, viewport); - if (!region.contains({pointer->position_x(), pointer->position_y()})) return; + const Rect_F region = target.private_data->event_region_run( + target.object, viewport); + if (!region.contains( + {pointer->position_x(), pointer->position_y()})) + continue; } - order.push_back({renderable, data}); - }); - if (!result) throw std::logic_error("render scene paint graph became invalid during event dispatch"); + order.push_back(target); + } std::vector delivery_order(order.rbegin(), order.rend()); detail::Nearest_Event_Target_Rule::apply(delivery_order, [&](const Event_Target& target) { return target.object->event_routing_distance(event); }); for (const auto& target : delivery_order) { @@ -388,8 +405,10 @@ const Render_Scene_2D::Private::Dispatch& Render_Scene_2D::Private::dispatch_for &State::frame_statistics>( [&data](State_Access states) { data.frame_statistics.reset(); - states.template get() - .frame_statistics = {}; + data.event_statistics.reset(); + auto& state = states.template get(); + state.frame_statistics = {}; + state.event_statistics = {}; }); }, [](Root* root, Frame_2D* frame) { diff --git a/render_3D/render_3D/detail/Async_Render_Backend.cpp b/render_3D/render_3D/detail/Async_Render_Backend.cpp index bc39463..723db1c 100644 --- a/render_3D/render_3D/detail/Async_Render_Backend.cpp +++ b/render_3D/render_3D/detail/Async_Render_Backend.cpp @@ -264,13 +264,15 @@ void Async_Render_Backend::Implementation::accept(Submission submission) { void Async_Render_Backend::Implementation::prepare(Submission submission) { std::optional prepared; try { - for (const auto& event : submission.events) + const auto sequence = + submission.completions.back().output->identity().sequence; + for (const auto& event : submission.events) { + if (event) event->mark_dispatch_started(sequence); dispatch(event, submission.parameters.viewport); + } submission.events.clear(); for (const auto& completion : submission.completions) completion.output->mark(Frame_Trace_Marker::backend_prepare_started); - const auto sequence = - submission.completions.back().output->identity().sequence; const bool readback = std::ranges::any_of( submission.completions, [](const Completion& completion) { return completion.output->output() == Frame_3D_Output::pixels; diff --git a/render_3D/render_3D/scene/Render_Scene_3D.cpp b/render_3D/render_3D/scene/Render_Scene_3D.cpp index 84568af..433f694 100644 --- a/render_3D/render_3D/scene/Render_Scene_3D.cpp +++ b/render_3D/render_3D/scene/Render_Scene_3D.cpp @@ -1,4 +1,4 @@ -#include "Render_Scene_3D.hpp" /* 三维异步提交阶段实现。 */ +#include "Render_Scene_3D.hpp" /* 三维事件计时边界与异步提交阶段实现。 */ namespace aethera::render_3d { Render_Scene_3D::Private::~Private() { /* 先停止后端产生新回调,再等待已经触发的 completion topology 释放借用帧。 */ diff --git a/render_3D/render_3D/scene/Render_Scene_3D.ipp b/render_3D/render_3D/scene/Render_Scene_3D.ipp index 7ae7400..1ef02a7 100644 --- a/render_3D/render_3D/scene/Render_Scene_3D.ipp +++ b/render_3D/render_3D/scene/Render_Scene_3D.ipp @@ -184,9 +184,10 @@ void Render_Scene_3D::Private::initialize_backend( object->template publish_state( [this, frame](State_Access states) { - states.template get() - .frame_statistics = frame_statistics.submit( - *frame, Frame_Dimension::three_dimensional); + auto& state = states.template get(); + state.frame_statistics = frame_statistics.submit( + *frame, Frame_Dimension::three_dimensional); + state.event_statistics = event_statistics.state(); }); { std::lock_guard lock(render_mutex); @@ -225,7 +226,7 @@ void Render_Scene_3D::Private::process(Object* object, Callback&& callback) requ if (!prop.view_active || prop.viewport.empty() || !backend || !backend->available()) return; frame->mark(Frame_Trace_Marker::scene_render_started); frame->mark(Frame_Trace_Marker::event_dispatch_started); - auto events = this->take_events(object, frame->identity().sequence); + auto events = this->take_events(object); frame->mark(Frame_Trace_Marker::event_dispatch_finished); auto context = std::static_pointer_cast>(paint_context); context->parameters = parameters(object); @@ -348,6 +349,6 @@ Render_Scene_3D::Render_Result Render_Scene_3D::Private::render(Object* object, if (prop.viewport.empty()) return Render_Result::empty_viewport; return Render_Result::backend_unavailable; } -template const Render_Scene_3D::Private::Dispatch& Render_Scene_3D::Private::dispatch_for() { static const Dispatch value{[](Root* root) { auto* object = static_cast(root); auto& data = static_cast(*object->d); object->template publish_state([&data](State_Access states) { data.frame_statistics.reset(); states.template get().frame_statistics = {}; }); }, [](Root* root, Frame_3D* frame) { auto* object = static_cast(root); return static_cast(*object->d).render(object, frame); }, [](Root* root, Frame_Callback callback) { auto* object = static_cast(root); auto& data = static_cast(*object->d); std::lock_guard lock(data.render_mutex); data.frame_callback = std::move(callback); }, [](Root* root, bool active) { static_cast(root)->template set<&Prop::view_active>(active); }}; return value; } +template const Render_Scene_3D::Private::Dispatch& Render_Scene_3D::Private::dispatch_for() { static const Dispatch value{[](Root* root) { auto* object = static_cast(root); auto& data = static_cast(*object->d); object->template publish_state([&data](State_Access states) { data.frame_statistics.reset(); data.event_statistics.reset(); auto& state = states.template get(); state.frame_statistics = {}; state.event_statistics = {}; }); }, [](Root* root, Frame_3D* frame) { auto* object = static_cast(root); return static_cast(*object->d).render(object, frame); }, [](Root* root, Frame_Callback callback) { auto* object = static_cast(root); auto& data = static_cast(*object->d); std::lock_guard lock(data.render_mutex); data.frame_callback = std::move(callback); }, [](Root* root, bool active) { static_cast(root)->template set<&Prop::view_active>(active); }}; return value; } template void Render_Scene_3D::Private::bind_private_crtp(Object* object) { Prev_Private::bind_private_crtp(object); dispatch = &dispatch_for(); } } diff --git a/web_server/src/Graph_WebSocket.cpp b/web_server/src/Graph_WebSocket.cpp index 50d4e7b..c3c13f5 100644 --- a/web_server/src/Graph_WebSocket.cpp +++ b/web_server/src/Graph_WebSocket.cpp @@ -70,7 +70,6 @@ void Graph_WebSocket::deliver_frame( } void Graph_WebSocket::receive(std::string_view message) { - const auto received_time = std::chrono::steady_clock::now(); const auto json = nlohmann::json::parse(message, nullptr, false); if (json.is_discarded() || !json.is_object()) return; const auto kind = json.value("kind", std::string{}); @@ -107,11 +106,7 @@ void Graph_WebSocket::receive(std::string_view message) { height = d->height; } Plot_Input_Event decoded; - decoded.coalesced_event_count = std::clamp( - input->value("coalesced_event_count", std::size_t{1}), - std::size_t{1}, std::size_t{1'000}); decoded.type = *type; - decoded.received_time = received_time; const auto read_point = [&](std::string_view key, render_2d::Point_F& point, bool viewport_relative) { diff --git a/web_server/src/Plot.cpp b/web_server/src/Plot.cpp index 9b71e9e..5a3691c 100644 --- a/web_server/src/Plot.cpp +++ b/web_server/src/Plot.cpp @@ -35,27 +35,6 @@ constexpr std::uint16_t plot_stream_protocol_version{9}; constexpr std::size_t diagnostic_window_capacity{600}; -struct Web_Input_Metadata { - Plot_Input_Event input; - std::chrono::steady_clock::time_point scene_dispatched; -}; -struct Web_Input_Event { - virtual ~Web_Input_Event() = default; - [[nodiscard]] virtual const Web_Input_Metadata& web_input_metadata() const noexcept = 0; -}; -template -struct Observed_Input_Event final : Event_Object_Type, Web_Input_Event { - template - Observed_Input_Event(Web_Input_Metadata value_metadata, - Arguments&&... arguments) - : Event_Object_Type(std::forward(arguments)...), - metadata(std::move(value_metadata)) {} - [[nodiscard]] const Web_Input_Metadata& web_input_metadata() const noexcept override { - return metadata; - } - Web_Input_Metadata metadata; -}; - std::string exception_description(const std::exception_ptr& failure) { try { if (failure) std::rethrow_exception(failure); @@ -202,10 +181,30 @@ void append_statistic_json(nlohmann::json& output, } } +void append_event_statistics_json(nlohmann::json& output, + const Event_Statistics_State& state) { + for (const auto type : magic_enum::enum_values()) { + auto& event = output[magic_enum::enum_name(type)]; + const auto& values = state.values[static_cast(type)]; + for (const auto statistic : magic_enum::enum_values()) { + if (statistic == Event_Statistic::count) continue; + const auto& value = values[static_cast(statistic)]; + if (value.count == 0) continue; + event[magic_enum::enum_name(statistic)] = { + {"count", value.count}, {"latest", value.latest}, + {"minimum", value.minimum}, {"maximum", value.maximum}, + {"average", value.average}, + {"trimmed_average", value.trimmed_average}, + {"variability", value.variability}, {"p50", value.p50}, + {"p95", value.p95}, {"p99", value.p99}}; + } + if (event.empty()) output.erase(std::string{magic_enum::enum_name(type)}); + } +} + template void dispatch_plot_input(Scene_Object& scene, const Plot_Input_Event& input) { - Web_Input_Metadata metadata{input, std::chrono::steady_clock::now()}; const auto dispatch = [&](auto event) { scene.template submit_stream(std::move(event)); }; @@ -220,15 +219,14 @@ void dispatch_plot_input(Scene_Object& scene, const Plot_Input_Event& input) { case Event_Type::pointer_move: case Event_Type::pointer_press: case Event_Type::pointer_release: { - auto event = scene.template make_event>>( - std::move(metadata), input.type); + auto event = scene.template make_event>( + input.type); apply_pointer(*event); dispatch(std::move(event)); break; } case Event_Type::wheel: { - auto event = scene.template make_event>>( - std::move(metadata)); + auto event = scene.template make_event>(); apply_pointer(*event); event->pixel_delta_x = input.pixel_delta_x; event->pixel_delta_y = input.pixel_delta_y; @@ -239,8 +237,7 @@ void dispatch_plot_input(Scene_Object& scene, const Plot_Input_Event& input) { } case Event_Type::key_press: case Event_Type::key_release: { - auto event = scene.template make_event>( - std::move(metadata), input.type); + auto event = scene.template make_event(input.type); event->key = input.key; event->native_key = input.native_key; event->modifiers = input.modifiers; @@ -249,8 +246,7 @@ void dispatch_plot_input(Scene_Object& scene, const Plot_Input_Event& input) { break; } default: - dispatch(scene.template make_event>( - std::move(metadata), input.type)); + dispatch(scene.template make_event(input.type)); break; } } @@ -728,12 +724,11 @@ void Plot::render_once() { void Plot::submit_input(Plot_Input_Event event) { ensure_started(); if (d->terminal_failure.load(std::memory_order_acquire)) return; - if (event.received_time == std::chrono::steady_clock::time_point{}) - event.received_time = std::chrono::steady_clock::now(); /* * WebSocket 线程只向 Scene 的当前事件缓冲追加一个由 Scene * memory_resource 分配的基类指针。Prepare 边界交换完整批次, - * 处理结果再由反向双缓冲交给 Plot 统计;事件处理不执行 Web 回调。 + * Scene 在 Renderable 完成消费时按 Event_Type 增量统计,并随自身 + * State 双缓冲发布;Web 层只在低频 diagnostics 请求中读取结果。 */ try { if (auto* scene_2d = std::get_if>(&d->scene)) @@ -767,6 +762,7 @@ nlohmann::json Plot::generate_data(const nlohmann::json& input) { nlohmann::json Plot::diagnostics() const { nlohmann::json frame_statistics = nlohmann::json::object(); + nlohmann::json input_statistics = nlohmann::json::object(); Frame_Identity identity{}; std::uint64_t created_time_unix_ns{}; std::uint64_t dropped_sequences{}; @@ -775,6 +771,7 @@ nlohmann::json Plot::diagnostics() const { const auto read_statistics = [&](const auto& state) { const auto& statistics = state.frame_statistics; append_statistic_json(frame_statistics, statistics); + append_event_statistics_json(input_statistics, state.event_statistics); identity = statistics.identity; created_time_unix_ns = statistics.created_time_unix_ns; dropped_sequences = statistics.dropped_sequences; @@ -836,7 +833,7 @@ nlohmann::json Plot::diagnostics() const { {"render_enabled", pacing.render_enabled}, {"video_enabled", pacing.video_enabled}}}, {"frame_statistics", std::move(frame_statistics)}, - {"input_statistics", nlohmann::json::object()}}; + {"input_statistics", std::move(input_statistics)}}; if (is_3d) { const auto gpu = gpu_completion_state(); const auto milliseconds = [](std::uint64_t nanoseconds) { diff --git a/web_server/src/Plot.hpp b/web_server/src/Plot.hpp index 9ad9329..47c5b93 100644 --- a/web_server/src/Plot.hpp +++ b/web_server/src/Plot.hpp @@ -2,7 +2,6 @@ #include #include #include -#include #include #include #include @@ -13,7 +12,6 @@ namespace aethera::web { struct Plot_Input_Event { - std::size_t coalesced_event_count{1}; /* 浏览器显示周期内合并的原始输入数量。 */ Event_Type type{Event_Type::pointer_move}; /* 输入事件业务类型。 */ render_2d::Point_F position{}; /* Plot 像素坐标。 */ render_2d::Point_F global_position{}; /* 浏览器屏幕像素坐标。 */ @@ -27,7 +25,6 @@ struct Plot_Input_Event { Key key{Key::unknown}; /* 标准化键盘按键。 */ std::uint32_t native_key{}; /* 浏览器原生按键码。 */ bool auto_repeat{}; /* 是否为系统重复按键。 */ - std::chrono::steady_clock::time_point received_time{}; /* WebSocket 完成解析的服务端单调时刻。 */ }; struct Plot_Render_Tick { diff --git a/webapp_gallery/src/app.tsx b/webapp_gallery/src/app.tsx index 2f7a148..7f3bbbb 100644 --- a/webapp_gallery/src/app.tsx +++ b/webapp_gallery/src/app.tsx @@ -32,15 +32,22 @@ type Pixel_Format = "rgba8" | "bgra8_premultiplied"; type Frame_Stage_Values = Record; type Statistic_Snapshot = {count: number; latest: number; minimum: number; maximum: number; average: number; trimmed_average: number; variability: number; p50: number; p95: number; p99: number}; +type Event_Statistics = Record>; +type Browser_Input_Statistic = {count: number; latest_ms: number; average_ms: number; + maximum_ms: number; sent_count: number; disconnected_count: number; + websocket_buffered_bytes: number}; +type Browser_Input_Statistics = Record; type Plot_Diagnostics = {protocol: "aethera.plot.diagnostics"; version: 2; dimension: "2D" | "3D"; sequence: number; correlation_id: number; rendered_sequence: number; rendered_correlation_id: number; generated_time_unix_ms: number; delivery: Frame_Delivery; frame_rate_fps: number; dropped_sequence_count: number; window_capacity: number; pixel: {width: number; height: number; format: Pixel_Format; native_format: Pixel_Format; supported_formats: Pixel_Format[]; byte_length: number}; pacing: {mode: Frame_Pacing_Mode; fixed_rate_fps: number; render_enabled: boolean; video_enabled: boolean}; - frame_statistics: Record; input_statistics: Record}; + frame_statistics: Record; input_statistics: Event_Statistics}; type Frame_Sample = {sequence: number; generated_at_ms: number; received_at_ms: number; values: Frame_Stage_Values}; -type Video_Playback_Metrics = {frame_rate_fps: number; presented_frames: number; dropped_frames: number; current_time_seconds: number; ready_state: number}; +type Video_Playback_Metrics = {frame_rate_fps: number; presented_frames: number; dropped_frames: number; + jitter_buffer_ms: number; decode_processing_ms: number; estimated_playout_delay_ms: number; + freeze_count: number; current_time_seconds: number; ready_state: number}; type Gallery_Tile = {column: number; row: number}; type Gallery_Layout = {kind: "gallery_layout"; protocol: "aethera.gallery.video"; version: 2; columns: number; rows: number; tile_width: number; tile_height: number; width: number; height: number; plots: Record}; @@ -55,7 +62,8 @@ type Gallery_Transport_Metrics = {kind: "gallery_metrics"; protocol: "aethera.ga type Gallery_Video_State = {status: Stream_Status; stream: MediaStream | null; layout: Gallery_Layout | null; playback: Video_Playback_Metrics; transport: Gallery_Transport_Metrics | null; error: string | null}; type Gallery_Video_States = Record; -type Frame_Diagnostics = {server: Plot_Diagnostics; samples: Frame_Sample[]; video_playback: Video_Playback_Metrics}; +type Frame_Diagnostics = {server: Plot_Diagnostics; samples: Frame_Sample[]; video_playback: Video_Playback_Metrics; + browser_input_statistics: Browser_Input_Statistics}; type Frame_Metrics = {sequence: number; generated_time_unix_ms: number; server_completion_ms: number; average_server_completion_ms: number; p95_server_completion_ms: number; p99_server_completion_ms: number; frame_rate_fps: number; p95_frame_interval_jitter_ms: number; pacing_mode: Frame_Pacing_Mode; fixed_rate_fps: number; delivery: Frame_Delivery; video_playback: Video_Playback_Metrics}; @@ -158,6 +166,8 @@ function average(values: number[]) { return values.length === 0 ? 0 : values.red const empty_video_playback = (): Video_Playback_Metrics => ({ frame_rate_fps: 0, presented_frames: 0, dropped_frames: 0, + jitter_buffer_ms: 0, decode_processing_ms: 0, + estimated_playout_delay_ms: 0, freeze_count: 0, current_time_seconds: 0, ready_state: 0 }); @@ -209,12 +219,29 @@ function use_gallery_videos(plots: Plot[]): Gallery_Video_States { const reports = await peer.getStats(); let decoded = 0; let dropped = 0; + let jitter_buffer_ms = 0; + let decode_processing_ms = 0; + let estimated_playout_delay_ms = 0; + let freeze_count = 0; let found = false; reports.forEach(report => { if (report.type !== "inbound-rtp" || report.kind !== "video") return; const inbound = report as RTCInboundRtpStreamStats; decoded = inbound.framesDecoded ?? 0; dropped = inbound.framesDropped ?? 0; + const extended = inbound as RTCInboundRtpStreamStats & { + jitterBufferDelay?: number; jitterBufferEmittedCount?: number; + totalProcessingDelay?: number; freezeCount?: number; + estimatedPlayoutTimestamp?: number + }; + jitter_buffer_ms = extended.jitterBufferEmittedCount + ? (extended.jitterBufferDelay ?? 0) * 1000 / + extended.jitterBufferEmittedCount : 0; + decode_processing_ms = decoded + ? (extended.totalProcessingDelay ?? 0) * 1000 / decoded : 0; + estimated_playout_delay_ms = extended.estimatedPlayoutTimestamp + ? Math.max(0, extended.estimatedPlayoutTimestamp - inbound.timestamp) : 0; + freeze_count = extended.freezeCount ?? 0; found = true; }); if (!found) return; @@ -228,6 +255,10 @@ function use_gallery_videos(plots: Plot[]): Gallery_Video_States { frame_rate_fps, presented_frames: decoded, dropped_frames: dropped, + jitter_buffer_ms, + decode_processing_ms, + estimated_playout_delay_ms, + freeze_count, current_time_seconds: 0, ready_state: current.stream ? 4 : 0 }})); @@ -333,19 +364,56 @@ function use_plot_stream(plot: Plot, const [server_diagnostics, set_server_diagnostics] = useState(null); const [error, set_error] = useState(null); const socket_ref = useRef(null); - const pending_pointer_move = useRef | null>(null); - const pointer_frame = useRef(0); const viewport_ref = useRef({width: tile_width, height: tile_height}); const playback_ref = useRef(shared_playback); + const browser_input_accumulators = useRef>({}); + const [browser_input_statistics, set_browser_input_statistics] = + useState({}); useEffect(() => { playback_ref.current = shared_playback; }, [shared_playback]); useEffect(() => { viewport_ref.current = {width: tile_width, height: tile_height}; }, [tile_width, tile_height]); const transmit = useCallback((kind: "input", event?: Record) => { const socket = socket_ref.current; - if (socket?.readyState !== WebSocket.OPEN) return; + if (socket?.readyState !== WebSocket.OPEN) return false; socket.send(JSON.stringify({kind, viewport: viewport_ref.current, ...(event ? {event} : {})})); + return true; }, []); + const record_browser_input = useCallback((type: string, event_time_stamp: number, + sent: boolean) => { + const measured = performance.now() - event_time_stamp; + /* DOMHighResTimeStamp 与 performance.now() 共用 time origin。异常时间戳不进入统计, + * 避免旧浏览器的 Unix 时间戳污染真正的浏览器事件调度延迟。 */ + const delay_ms = Number.isFinite(measured) && measured >= 0 && measured < 60_000 + ? measured : 0; + const current = browser_input_accumulators.current[type] ?? { + count: 0, sum_ms: 0, latest_ms: 0, maximum_ms: 0, + sent_count: 0, disconnected_count: 0, + websocket_buffered_bytes: 0 + }; + current.count += 1; + current.sum_ms += delay_ms; + current.latest_ms = delay_ms; + current.maximum_ms = Math.max(current.maximum_ms, delay_ms); + if (sent) current.sent_count += 1; + else current.disconnected_count += 1; + current.websocket_buffered_bytes = Math.max(current.websocket_buffered_bytes, + socket_ref.current?.bufferedAmount ?? 0); + browser_input_accumulators.current[type] = current; + }, []); + const read_browser_input_statistics = useCallback((): Browser_Input_Statistics => + Object.fromEntries(Object.entries(browser_input_accumulators.current) + .map(([type, value]) => [type, { + count: value.count, + latest_ms: value.latest_ms, + average_ms: value.count ? value.sum_ms / value.count : 0, + maximum_ms: value.maximum_ms, + sent_count: value.sent_count, + disconnected_count: value.disconnected_count, + websocket_buffered_bytes: value.websocket_buffered_bytes + }])), []); useEffect(() => { let stopped = false; @@ -370,9 +438,12 @@ function use_plot_stream(plot: Plot, samples = [...samples, {sequence: server.sequence, generated_at_ms: server.generated_time_unix_ms, received_at_ms: now, values}].slice(-600); + const browser_inputs = read_browser_input_statistics(); const diagnostics: Frame_Diagnostics = { - server, samples, video_playback: {...playback_ref.current} + server, samples, video_playback: {...playback_ref.current}, + browser_input_statistics: browser_inputs }; + set_browser_input_statistics(browser_inputs); set_server_diagnostics(server); const completion = server.frame_statistics.server_completion_ms; const interval = server.frame_statistics.frame_interval_ms; @@ -414,6 +485,8 @@ function use_plot_stream(plot: Plot, const detail = (event as CustomEvent<{plot_id: string}>).detail; if (detail?.plot_id !== plot.id) return; samples = []; + browser_input_accumulators.current = {}; + set_browser_input_statistics({}); set_metrics(null); void fetch(plot.diagnostics, {method: "DELETE"}); window.dispatchEvent(new CustomEvent("aethera-frame-diagnostics-cleared", { @@ -453,12 +526,11 @@ function use_plot_stream(plot: Plot, window.removeEventListener("aethera-manual-frame", on_manual_frame); window.removeEventListener("aethera-reset-camera", on_camera_reset); window.removeEventListener("aethera-reset-frame-diagnostics", on_diagnostics_reset); - if (pointer_frame.current) cancelAnimationFrame(pointer_frame.current); - pointer_frame.current = 0; socket_ref.current = null; socket.close(); }; - }, [plot.id, plot.websocket, plot.diagnostics, surface_ref, transmit]); + }, [plot.id, plot.websocket, plot.diagnostics, surface_ref, transmit, + read_browser_input_statistics]); useEffect(() => { const surface = surface_ref.current; @@ -483,62 +555,38 @@ function use_plot_stream(plot: Plot, const pointer_payload = (type: string, event: PointerEvent) => ({ type, position: point(event), global_position: global_point(event), button: type === "pointer_move" ? active_button(event) : button(event.button), - buttons: event.buttons, modifiers: modifiers(event), coalesced_event_count: 1 + buttons: event.buttons, modifiers: modifiers(event) }); - let drag_move_started = false; - let pending_wheel: Record | null = null; - let wheel_frame = 0; const on_pointer_move = (event: PointerEvent) => { event.stopPropagation(); - const payload = pointer_payload("pointer_move", event); - if (event.buttons !== 0 && !drag_move_started) { - drag_move_started = true; - transmit("input", payload); - return; - } - const previous_count = Number( - pending_pointer_move.current?.coalesced_event_count ?? 0); - pending_pointer_move.current = { - ...payload, coalesced_event_count: previous_count + 1 - }; - if (!pointer_frame.current) - pointer_frame.current = requestAnimationFrame(() => { - pointer_frame.current = 0; - if (pending_pointer_move.current) - transmit("input", pending_pointer_move.current); - pending_pointer_move.current = null; - }); + /* 输入采集不能依赖视频合成使用的 requestAnimationFrame。多路 video + * 让浏览器渲染线程饥饿时,rAF 会把已产生的拖动事件滞留到秒级; + * Scene 自己的 MPMC 三缓冲才是事件批次的权威入口。 */ + const sent = transmit("input", pointer_payload("pointer_move", event)); + record_browser_input("pointer_move", event.timeStamp, sent); }; const on_pointer_down = (event: PointerEvent) => { event.preventDefault(); event.stopPropagation(); surface.focus({preventScroll: true}); surface.setPointerCapture(event.pointerId); - drag_move_started = false; - pending_pointer_move.current = null; - transmit("input", pointer_payload("pointer_press", event)); + const sent = transmit("input", pointer_payload("pointer_press", event)); + record_browser_input("pointer_press", event.timeStamp, sent); }; const on_pointer_up = (event: PointerEvent) => { event.preventDefault(); event.stopPropagation(); if (surface.hasPointerCapture(event.pointerId)) surface.releasePointerCapture(event.pointerId); - if (pending_pointer_move.current) - transmit("input", pending_pointer_move.current); - pending_pointer_move.current = null; - drag_move_started = false; - transmit("input", pointer_payload("pointer_release", event)); + const sent = transmit("input", pointer_payload("pointer_release", event)); + record_browser_input("pointer_release", event.timeStamp, sent); }; const on_pointer_cancel = (event: PointerEvent) => { - if (pending_pointer_move.current) - transmit("input", pending_pointer_move.current); - pending_pointer_move.current = null; - drag_move_started = false; - transmit("input", pointer_payload("pointer_release", event)); + const sent = transmit("input", pointer_payload("pointer_release", event)); + record_browser_input("pointer_release", event.timeStamp, sent); }; const on_pointer_leave = (event: PointerEvent) => { event.stopPropagation(); - pending_pointer_move.current = null; transmit("input", {type: "leave"}); }; const on_wheel = (event: WheelEvent) => { @@ -551,23 +599,9 @@ function use_plot_stream(plot: Plot, modifiers: modifiers(event), pixel_delta_x: -event.deltaX * scale, pixel_delta_y: -event.deltaY * scale, angle_delta_x: Math.max(-120, Math.min(120, -event.deltaX * scale)), - angle_delta_y: Math.max(-120, Math.min(120, -event.deltaY * scale)), - coalesced_event_count: 1}; - if (pending_wheel) { - next.pixel_delta_x += Number(pending_wheel.pixel_delta_x ?? 0); - next.pixel_delta_y += Number(pending_wheel.pixel_delta_y ?? 0); - next.angle_delta_x += Number(pending_wheel.angle_delta_x ?? 0); - next.angle_delta_y += Number(pending_wheel.angle_delta_y ?? 0); - next.coalesced_event_count = - Number(pending_wheel.coalesced_event_count ?? 0) + 1; - } - pending_wheel = next; - if (!wheel_frame) - wheel_frame = requestAnimationFrame(() => { - wheel_frame = 0; - if (pending_wheel) transmit("input", pending_wheel); - pending_wheel = null; - }); + angle_delta_y: Math.max(-120, Math.min(120, -event.deltaY * scale))}; + const sent = transmit("input", next); + record_browser_input("wheel", event.timeStamp, sent); }; const key_name = (key: string) => ({Escape: "escape", Enter: "enter", " ": "space", Delete: "delete_key", Backspace: "backspace", @@ -576,9 +610,10 @@ function use_plot_stream(plot: Plot, const on_key = (type: "key_press" | "key_release") => (event: KeyboardEvent) => { event.stopPropagation(); - transmit("input", {type, key: key_name(event.key), + const sent = transmit("input", {type, key: key_name(event.key), native_key: event.keyCode, modifiers: modifiers(event), auto_repeat: event.repeat}); + record_browser_input(type, event.timeStamp, sent); }; const on_key_down = on_key("key_press"); const on_key_up = on_key("key_release"); @@ -600,10 +635,6 @@ function use_plot_stream(plot: Plot, surface.addEventListener("blur", on_blur); surface.addEventListener("contextmenu", on_context); return () => { - pending_pointer_move.current = null; - pending_wheel = null; - if (wheel_frame) cancelAnimationFrame(wheel_frame); - wheel_frame = 0; surface.removeEventListener("pointermove", on_pointer_move); surface.removeEventListener("pointerdown", on_pointer_down); surface.removeEventListener("pointerup", on_pointer_up); @@ -616,8 +647,8 @@ function use_plot_stream(plot: Plot, surface.removeEventListener("blur", on_blur); surface.removeEventListener("contextmenu", on_context); }; - }, [surface_ref, transmit]); - return {status, metrics, server_diagnostics, error}; + }, [surface_ref, transmit, record_browser_input]); + return {status, metrics, server_diagnostics, browser_input_statistics, error}; } const member_labels: Record = {x: "横坐标", y: "纵坐标", z: "深度", width: "宽度", height: "高度", origin: "起点", target: "终点", @@ -922,6 +953,21 @@ function Frame_Diagnostics_View({diagnostics, dimension, on_reset}: {diagnostics const server_completion = displayed.server.frame_statistics.server_completion_ms; const frame_interval = displayed.server.frame_statistics.frame_interval_ms; const input = displayed.server.input_statistics; + const browser_input = displayed.browser_input_statistics; + const event_labels: Record = { + pointer_press: "指针按下", pointer_move: "指针拖动", pointer_release: "指针释放", + wheel: "滚轮", key_press: "按键", key_release: "松键", + leave: "指针离开", resize: "尺寸变化", show: "显示", hide: "隐藏" + }; + const event_latency_rows = Object.entries(input) + .filter(([, statistics]) => statistics.total_ms?.count) + .map(([type, statistics]) => ({type, label: event_labels[type] ?? type, + queue: statistics.queue_wait_ms, dispatch: statistics.dispatch_ms, + total: statistics.total_ms})); + const browser_event_latency_rows = Object.entries(browser_input) + .filter(([, statistic]) => statistic.count) + .map(([type, statistic]) => ({type, label: event_labels[type] ?? type, + statistic})); const poll_intervals = displayed.samples.slice(1).map((sample, index) => sample.received_at_ms - displayed.samples[index].received_at_ms); const summaries: Array<[string, string, string]> = [ @@ -930,6 +976,10 @@ function Frame_Diagnostics_View({diagnostics, dimension, on_reset}: {diagnostics ["浏览器解码帧率", `${displayed.video_playback.frame_rate_fps.toFixed(1)} FPS`, "由共享 WebRTC receiver 的 framesDecoded 增量统计;它不受显示器刷新率限制,可直接判断图集视频是否稳定收到一百帧。"], ["浏览器累计解码", displayed.video_playback.presented_frames.toLocaleString("zh-CN"), `共享 WebRTC receiver 已解码帧数;readyState=${displayed.video_playback.ready_state}。`], ["浏览器累计丢帧", displayed.video_playback.dropped_frames.toLocaleString("zh-CN"), "浏览器 inbound-rtp 统计报告的累计丢弃视频帧。"], + ["WebRTC 抖动缓冲", `${displayed.video_playback.jitter_buffer_ms.toFixed(2)} ms`, "浏览器 inbound-rtp 的累计 jitterBufferDelay / jitterBufferEmittedCount;这是服务端消费后到浏览器可播放之间的重要延迟。"], + ["浏览器解码处理", `${displayed.video_playback.decode_processing_ms.toFixed(2)} ms`, "浏览器 inbound-rtp 的累计 totalProcessingDelay / framesDecoded。"], + ["预计呈现等待", `${displayed.video_playback.estimated_playout_delay_ms.toFixed(2)} ms`, "浏览器估计的当前 RTP 帧距实际播放时刻的等待;浏览器不提供该字段时为 0。"], + ["浏览器冻结次数", displayed.video_playback.freeze_count.toLocaleString("zh-CN"), "浏览器 inbound-rtp 报告的累计视频冻结次数。"], ["服务端完成平均", `${(server_completion?.average ?? 0).toFixed(2)} ms`, `服务端滑动窗口中从创建帧到${completion}的平均耗时。`], ["服务端完成裁剪平均", `${(server_completion?.trimmed_average ?? 0).toFixed(2)} ms`, "服务端舍弃窗口两端各 5% 极端样本后的平均耗时。"], ["服务端完成 P50", `${(server_completion?.p50 ?? 0).toFixed(2)} ms`, "一半样本不超过该服务端完成耗时。"], @@ -937,8 +987,8 @@ function Frame_Diagnostics_View({diagnostics, dimension, on_reset}: {diagnostics ["服务端完成 P99", `${(server_completion?.p99 ?? 0).toFixed(2)} ms`, "99% 样本不超过该服务端完成耗时,用于观察极端长尾。"], ["帧间隔平均", `${(frame_interval?.average ?? 0).toFixed(2)} ms`, `服务端相邻两次${completion}的滑动平均间隔。`], ["帧间隔波动", `${(frame_interval?.variability ?? 0).toFixed(2)} ms`, "服务端帧间隔窗口的总体标准差。"], - ["服务端事件消费 P95", `${(input.input_server_consume_ms?.p95 ?? 0).toFixed(2)} ms`, "浏览器事件抵达服务端到 Renderable 完成消费的服务端 P95;不等待渲染、编码或画面显示。"], - ["Scene 等帧 P95", `${(input.input_scene_wait_ms?.p95 ?? 0).toFixed(2)} ms`, "事件进入 Scene 后等待下一次 Prepare 消费的服务端 P95。"], + ["拖动事件总延迟 P95", `${(input.pointer_move?.total_ms?.p95 ?? 0).toFixed(2)} ms`, "pointer_move 进入 Scene 到 Renderable 完成消费的 P95;不等待编码或画面显示。"], + ["拖动事件等帧 P95", `${(input.pointer_move?.queue_wait_ms?.p95 ?? 0).toFixed(2)} ms`, "pointer_move 等待下一次 Scene Prepare 消费的 P95。"], ["逻辑序列缺口", displayed.server.dropped_sequence_count.toLocaleString("zh-CN"), "服务端滑动统计期间未完成的逻辑帧序号数量。"] ]; const open_context_menu = (event: React.MouseEvent) => { @@ -954,6 +1004,20 @@ function Frame_Diagnostics_View({diagnostics, dimension, on_reset}: {diagnostics return
当前为{pixel_delivery ? "WebRTC H.264 视频" : "后台诊断"}模式,浏览器保留 {displayed.samples.length} 个低频快照点;服务端窗口含 {server_completion?.count ?? 0} 个真实帧样本。{pixel_delivery ? "视频轨与按需诊断 GET 相互独立。" : "后台诊断仍执行真实渲染,但跳过 H.264 编码与视频发送。"}
{summaries.map(([label, value, description]) =>
{label}
{value}
)}
+
输入事件分类延迟服务端滑动统计
+
{event_latency_rows.length ? event_latency_rows.flatMap(row => [ +
{row.label} · 等帧 P95
{(row.queue?.p95 ?? 0).toFixed(2)} ms
, +
{row.label} · 分发 P95
{(row.dispatch?.p95 ?? 0).toFixed(2)} ms
, +
{row.label} · 总计 P95
{(row.total?.p95 ?? 0).toFixed(2)} ms
+ ]) :
等待输入
--
}
+
浏览器事件调度延迟按诊断周期读取
+
{browser_event_latency_rows.length ? browser_event_latency_rows.flatMap(row => [ +
{row.label} · 最近
{row.statistic.latest_ms.toFixed(2)} ms
, +
{row.label} · 平均
{row.statistic.average_ms.toFixed(2)} ms
, +
{row.label} · 最大
{row.statistic.maximum_ms.toFixed(2)} ms
, +
{row.label} · 已发/断连丢弃
{row.statistic.sent_count} / {row.statistic.disconnected_count}
, +
{row.label} · WS 积压峰值
{row.statistic.websocket_buffered_bytes.toLocaleString("zh-CN")} B
+ ]) :
等待输入
--
}
流水线阶段统计#{displayed.server.sequence} / 时钟 {displayed.server.correlation_id}
@@ -1096,7 +1160,20 @@ const Plot_Card = memo(function Plot_Card({plot, selected, policy, gallery, on_p : graph.status === "LIVE" && gallery.status === "LIVE" ? "LIVE" : "CONNECTING"; const metrics = graph.metrics; const server_input = graph.server_diagnostics?.input_statistics; + const browser_input = graph.browser_input_statistics; const source_transport = gallery.transport?.sources[plot.id] ?? null; + const event_labels: Record = { + pointer_press: "按下", pointer_move: "拖动", pointer_release: "释放", + wheel: "滚轮", key_press: "按键", key_release: "松键", + leave: "离开", resize: "缩放", show: "显示", hide: "隐藏" + }; + const input_latency_summary = Object.entries(server_input ?? {}) + .filter(([, statistics]) => statistics.total_ms?.count) + .map(([type, statistics]) => + {event_labels[type] ?? type}总延迟 P95 {(statistics.total_ms?.p95 ?? 0).toFixed(1)} ms + ); + const browser_move = browser_input.pointer_move; const encoder_label = gallery.transport?.encoder_backend === "nvenc" ? "NVENC" : gallery.transport?.encoder_backend === "vulkan_video" ? "Vulkan Video" : "硬件编码"; @@ -1114,11 +1191,10 @@ const Plot_Card = memo(function Plot_Card({plot, selected, policy, gallery, on_p } : {}; const generated_time = metrics ? new Date(metrics.generated_time_unix_ms).toLocaleTimeString("zh-CN", {hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, fractionalSecondDigits: 3}) : "尚未生成帧"; return
on_select(plot)}>
绘图组件 · {plot.dimension}

{plot_labels[plot.id] ?? plot.title}

@@ -1132,22 +1208,16 @@ const Plot_Card = memo(function Plot_Card({plot, selected, policy, gallery, on_p 完成 {metrics ? metrics.server_completion_ms.toFixed(1) : "--.-"} ms {encoder_label} {gallery.transport ? gallery.transport.encode_average_ms.toFixed(1) : "--.-"} ms 落后 {source_transport?.has_rendered_frame ? source_transport.clock_lag_ticks : "--"} tick - {plot.dimension === "3D" ? <> - 事件准入 P95 {server_input?.input_admission_ms ? server_input.input_admission_ms.p95.toFixed(1) : "--.-"} ms - Scene 等帧 P95 {server_input?.input_scene_wait_ms ? server_input.input_scene_wait_ms.p95.toFixed(1) : "--.-"} ms - 事件分发 P95 {server_input?.input_dispatch_ms ? server_input.input_dispatch_ms.p95.toFixed(1) : "--.-"} ms - 服务端消费 P95 {server_input?.input_server_consume_ms ? server_input.input_server_consume_ms.p95.toFixed(1) : "--.-"} ms - 平均合并 {server_input?.input_coalesced_event_count ? server_input.input_coalesced_event_count.average.toFixed(1) : "--.-"} - : null} + {plot.dimension === "3D" ? input_latency_summary : null}
{plot.dimension === "2D" ?
- 事件准入 P95 {server_input?.input_admission_ms ? server_input.input_admission_ms.p95.toFixed(1) : "--.-"} ms - Scene 等帧 P95 {server_input?.input_scene_wait_ms ? server_input.input_scene_wait_ms.p95.toFixed(1) : "--.-"} ms - 事件分发 P95 {server_input?.input_dispatch_ms ? server_input.input_dispatch_ms.p95.toFixed(1) : "--.-"} ms - 服务端消费 P95 {server_input?.input_server_consume_ms ? server_input.input_server_consume_ms.p95.toFixed(1) : "--.-"} ms - 平均合并 {server_input?.input_coalesced_event_count ? server_input.input_coalesced_event_count.average.toFixed(1) : "--.-"} + {browser_move ? + 浏览器拖动最大 {browser_move.maximum_ms.toFixed(1)} ms · 已发/断连 {browser_move.sent_count}/{browser_move.disconnected_count} + : null} + {input_latency_summary.length ? input_latency_summary + : 等待输入事件}
: null}
event.stopPropagation()}>