延迟低了很多

This commit is contained in:
2026-08-24 17:28:17 +08:00
parent f463a756fc
commit c0d5a13350
14 changed files with 335 additions and 162 deletions
+43
View File
@@ -0,0 +1,43 @@
#include "event.hpp"
#include <stdexcept>
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<std::size_t>(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<double>(started_steady_ns - created_steady_ns) / 1'000'000.0,
static_cast<double>(completed_steady_ns - started_steady_ns) / 1'000'000.0,
static_cast<double>(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_ = {};
}
}
+35
View File
@@ -1,4 +1,6 @@
#pragma once
#include "frame_statistics.hpp"
#include <array>
#include <atomic>
#include <concepts>
#include <cstdint>
@@ -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<std::size_t>(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<std::size_t>(Event_Statistic::count);
struct Event_Statistics_State {
std::array<std::array<Statistic_State, event_statistic_count>,
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<std::array<Sliding_Statistics, event_statistic_count>,
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_{};
+14 -2
View File
@@ -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<Keyboard_Modifier>(static_cast<std::uint8_t>(left) | static_cast<std::uint8_t>(right)); }
template <Event_Point Point> Basic_Pointer_Event<Point>::Basic_Pointer_Event(Event_Type value) : Event(value) {}
template <Event_Point Point> double Basic_Pointer_Event<Point>::position_x() const noexcept { return static_cast<double>(position.x); }
+1
View File
@@ -22,6 +22,7 @@ struct Scene : Def<Scene, Root,
struct Prop : Prev_Prop {};
/* Scene 每次 process(...) 后发布的总图结构与执行统计。 */
struct State : Prev_State {
Event_Statistics_State event_statistics{}; /* 输入进入 Scene 到 Renderable 消费完成的分类统计。 */
bool taskflow_rebuilt{}; /* 本次 process(...) 前的 advance 是否重新构建了总 Taskflow。 */
std::size_t renderable_count{}; /* Prepare 数据依赖图中的 Renderable 数量。 */
std::size_t taskflow_task_count{}; /* 当前 Prepare Taskflow 中的任务节点数量。 */
+8 -7
View File
@@ -10,10 +10,11 @@ struct Scene::Private : Prev_Private {
struct Result {}; /* process(...) 完成回调的结果类型;当前仅表示完成。 */
struct Runtime; /* Scene 的 Taskflow 构建产物;完整定义位于本文件下方。 */
std::unique_ptr<Runtime> runtime; /* Scene 唯一运行时构建产物的所有权。 */
Event_Statistics_Accumulator event_statistics{}; /* Scene 单帧域内按事件类型增量计算。 */
Private();
~Private();
template <Attached Object>
[[nodiscard]] Event_Batch take_events(Object* object, std::uint64_t frame_sequence);
[[nodiscard]] Event_Batch take_events(Object* object);
template <Attached Object> void bind_private_crtp(Object* object);
/* Def CRTP hook:所有缓冲推进后重建必要的总 Taskflow,并更新 Scene_State_Tag 状态层。 */
template <Attached Object>
@@ -39,23 +40,22 @@ void Scene::Private::bind_private_crtp(Object* object) {
runtime = std::make_unique<Runtime>();
}
template <Attached Object>
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<Scene_Event_Stream_Tag>();
return object->template access_rendering_stream<Scene_Event_Stream_Tag>(
[&](std::span<Event_Pointer> 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 <Event_Object Event_Object_Type, typename... Arguments>
std::shared_ptr<Event_Object_Type> Scene::make_event(Arguments&&... arguments) {
return std::allocate_shared<Event_Object_Type>(
auto event = std::allocate_shared<Event_Object_Type>(
allocator<Event_Object_Type>(), std::forward<Arguments>(arguments)...);
event->observe_with(&static_cast<Private&>(*d).event_statistics);
return event;
}
template <Attached Object, typename Callback>
void Scene::Private::process(Object* object, Callback&& callback) requires std::invocable<Callback, const Result&> {
@@ -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<Scene::Base_Tag>();
Result result;
@@ -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;
+31 -12
View File
@@ -353,22 +353,39 @@ Render_Scene_2D::Private::render(Object* object, Frame_2D* frame) {
template <Attached Object>
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<Event_Target> candidates;
const auto graph = object->template current_dependency_graph<Paint_Tag>();
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<const Pointer_Event_Capability*>(&event);
std::vector<Event_Target> order;
const auto graph = object->template current_dependency_graph<Paint_Tag>();
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<Event_Target> 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<typename Object::State> states) {
data.frame_statistics.reset();
states.template get<Render_Scene_2D::Base_Tag>()
.frame_statistics = {};
data.event_statistics.reset();
auto& state = states.template get<Render_Scene_2D::Base_Tag>();
state.frame_statistics = {};
state.event_statistics = {};
});
},
[](Root* root, Frame_2D* frame) {
@@ -264,13 +264,15 @@ void Async_Render_Backend::Implementation::accept(Submission submission) {
void Async_Render_Backend::Implementation::prepare(Submission submission) {
std::optional<Datoviz_Visual_Backend::Pending_Frame> 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;
@@ -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 释放借用帧。 */
@@ -184,9 +184,10 @@ void Render_Scene_3D::Private::initialize_backend(
object->template publish_state<Render_Scene_3D::Base_Tag,
&State::frame_statistics>(
[this, frame](State_Access<typename Object::State> states) {
states.template get<Render_Scene_3D::Base_Tag>()
.frame_statistics = frame_statistics.submit(
*frame, Frame_Dimension::three_dimensional);
auto& state = states.template get<Render_Scene_3D::Base_Tag>();
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<detail::Scene_Paint_Context<Object>>(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 <Attached Object> const Render_Scene_3D::Private::Dispatch& Render_Scene_3D::Private::dispatch_for() { static const Dispatch value{[](Root* root) { auto* object = static_cast<Object*>(root); auto& data = static_cast<typename Object::Private&>(*object->d); object->template publish_state<Render_Scene_3D::Base_Tag, &State::frame_statistics>([&data](State_Access<typename Object::State> states) { data.frame_statistics.reset(); states.template get<Render_Scene_3D::Base_Tag>().frame_statistics = {}; }); }, [](Root* root, Frame_3D* frame) { auto* object = static_cast<Object*>(root); return static_cast<typename Object::Private&>(*object->d).render(object, frame); }, [](Root* root, Frame_Callback callback) { auto* object = static_cast<Object*>(root); auto& data = static_cast<typename Object::Private&>(*object->d); std::lock_guard lock(data.render_mutex); data.frame_callback = std::move(callback); }, [](Root* root, bool active) { static_cast<Object*>(root)->template set<&Prop::view_active>(active); }}; return value; }
template <Attached Object> const Render_Scene_3D::Private::Dispatch& Render_Scene_3D::Private::dispatch_for() { static const Dispatch value{[](Root* root) { auto* object = static_cast<Object*>(root); auto& data = static_cast<typename Object::Private&>(*object->d); object->template publish_state<Render_Scene_3D::Base_Tag, &State::frame_statistics>([&data](State_Access<typename Object::State> states) { data.frame_statistics.reset(); data.event_statistics.reset(); auto& state = states.template get<Render_Scene_3D::Base_Tag>(); state.frame_statistics = {}; state.event_statistics = {}; }); }, [](Root* root, Frame_3D* frame) { auto* object = static_cast<Object*>(root); return static_cast<typename Object::Private&>(*object->d).render(object, frame); }, [](Root* root, Frame_Callback callback) { auto* object = static_cast<Object*>(root); auto& data = static_cast<typename Object::Private&>(*object->d); std::lock_guard lock(data.render_mutex); data.frame_callback = std::move(callback); }, [](Root* root, bool active) { static_cast<Object*>(root)->template set<&Prop::view_active>(active); }}; return value; }
template <Attached Object> void Render_Scene_3D::Private::bind_private_crtp(Object* object) { Prev_Private::bind_private_crtp(object); dispatch = &dispatch_for<Object>(); }
}
-5
View File
@@ -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) {
+31 -34
View File
@@ -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 <typename Event_Object_Type>
struct Observed_Input_Event final : Event_Object_Type, Web_Input_Event {
template <typename... Arguments>
Observed_Input_Event(Web_Input_Metadata value_metadata,
Arguments&&... arguments)
: Event_Object_Type(std::forward<Arguments>(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<Event_Type>()) {
auto& event = output[magic_enum::enum_name(type)];
const auto& values = state.values[static_cast<std::size_t>(type)];
for (const auto statistic : magic_enum::enum_values<Event_Statistic>()) {
if (statistic == Event_Statistic::count) continue;
const auto& value = values[static_cast<std::size_t>(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 <typename Scene_Object>
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<aethera::Scene_Event_Stream_Tag>(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<Observed_Input_Event<Basic_Pointer_Event<Point_F>>>(
std::move(metadata), input.type);
auto event = scene.template make_event<Basic_Pointer_Event<Point_F>>(
input.type);
apply_pointer(*event);
dispatch(std::move(event));
break;
}
case Event_Type::wheel: {
auto event = scene.template make_event<Observed_Input_Event<Basic_Wheel_Event<Point_F>>>(
std::move(metadata));
auto event = scene.template make_event<Basic_Wheel_Event<Point_F>>();
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<Observed_Input_Event<Key_Event>>(
std::move(metadata), input.type);
auto event = scene.template make_event<Key_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<Observed_Input_Event<Event>>(
std::move(metadata), input.type));
dispatch(scene.template make_event<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<std::unique_ptr<Scene_2D>>(&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) {
-3
View File
@@ -2,7 +2,6 @@
#include <nlohmann/json_fwd.hpp>
#include <render_2D/scene/Render_Scene_2D.hpp>
#include <render_3D/scene/Render_Scene_3D.hpp>
#include <chrono>
#include <cstddef>
#include <cstdint>
#include <functional>
@@ -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 {
+159 -89
View File
@@ -32,15 +32,22 @@ type Pixel_Format = "rgba8" | "bgra8_premultiplied";
type Frame_Stage_Values = Record<string, number>;
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<string, Record<string, Statistic_Snapshot>>;
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<string, Browser_Input_Statistic>;
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<string, Statistic_Snapshot>; input_statistics: Record<string, Statistic_Snapshot>};
frame_statistics: Record<string, Statistic_Snapshot>; 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<string, Gallery_Tile>};
@@ -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<string, Gallery_Video_State>;
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<Plot_Diagnostics | null>(null);
const [error, set_error] = useState<string | null>(null);
const socket_ref = useRef<ReconnectingWebSocket | null>(null);
const pending_pointer_move = useRef<Record<string, unknown> | 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<Record<string, {count: number; sum_ms: number;
latest_ms: number; maximum_ms: number; sent_count: number;
disconnected_count: number; websocket_buffered_bytes: number}>>({});
const [browser_input_statistics, set_browser_input_statistics] =
useState<Browser_Input_Statistics>({});
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<string, unknown>) => {
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<string, unknown> | 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<string, string> = {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<string, string> = {
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<HTMLDivElement>) => {
@@ -954,6 +1004,20 @@ function Frame_Diagnostics_View({diagnostics, dimension, on_reset}: {diagnostics
return <section className="frameDiagnosticPanel">
<div className="diagnosticNotice" title="暂停只冻结分析视图,不会停止服务端帧时钟与样本采集。">{pixel_delivery ? "WebRTC H.264 视频" : "后台诊断"} {displayed.samples.length} {server_completion?.count ?? 0} {pixel_delivery ? "视频轨与按需诊断 GET 相互独立。" : "后台诊断仍执行真实渲染,但跳过 H.264 编码与视频发送。"}</div>
<dl className="frameDiagnosticSummary">{summaries.map(([label, value, description]) => <div key={label} title={description}><dt>{label}</dt><dd>{value}</dd></div>)}</dl>
<section className="frameStagePanel"><header><div><strong></strong><code></code></div></header>
<dl>{event_latency_rows.length ? event_latency_rows.flatMap(row => [
<div key={`${row.type}-queue`} title={`${row.type} 从进入 Scene 三缓冲到本帧开始分发。`}><dt>{row.label} · P95</dt><dd>{(row.queue?.p95 ?? 0).toFixed(2)} ms</dd></div>,
<div key={`${row.type}-dispatch`} title={`${row.type} 在 Renderable 接受链中的处理耗时。`}><dt>{row.label} · P95</dt><dd>{(row.dispatch?.p95 ?? 0).toFixed(2)} ms</dd></div>,
<div key={`${row.type}-total`} title={`${row.type} 进入 Scene 到 Renderable 完成消费;不含视频呈现。`}><dt>{row.label} · P95</dt><dd>{(row.total?.p95 ?? 0).toFixed(2)} ms</dd></div>
]) : <div><dt></dt><dd>--</dd></div>}</dl></section>
<section className="frameStagePanel"><header><div><strong></strong><code></code></div></header>
<dl>{browser_event_latency_rows.length ? browser_event_latency_rows.flatMap(row => [
<div key={`${row.type}-browser-latest`} title="操作系统事件的 DOM timeStamp 到 JavaScript 监听器开始执行。"><dt>{row.label} · </dt><dd>{row.statistic.latest_ms.toFixed(2)} ms</dd></div>,
<div key={`${row.type}-browser-average`} title="本次统计周期以来浏览器主线程调度延迟的平均值。"><dt>{row.label} · </dt><dd>{row.statistic.average_ms.toFixed(2)} ms</dd></div>,
<div key={`${row.type}-browser-maximum`} title="该事件类型观察到的最大浏览器主线程调度延迟,可直接暴露一两秒的卡顿。"><dt>{row.label} · </dt><dd>{row.statistic.maximum_ms.toFixed(2)} ms</dd></div>,
<div key={`${row.type}-browser-delivery`} title="事件监听器尝试发送后,图形控制 WebSocket 处于 OPEN 与非 OPEN 状态的次数。"><dt>{row.label} · /</dt><dd>{row.statistic.sent_count} / {row.statistic.disconnected_count}</dd></div>,
<div key={`${row.type}-browser-buffered`} title="发送该事件时 WebSocket 曾观察到的最大待发送字节数。"><dt>{row.label} · WS </dt><dd>{row.statistic.websocket_buffered_bytes.toLocaleString("zh-CN")} B</dd></div>
]) : <div><dt></dt><dd>--</dd></div>}</dl></section>
<Frame_Timeline_Chart diagnostics={displayed} dimension={dimension} paused={paused} on_context_menu={open_context_menu}/>
<section className="frameStagePanel"><header><div><strong title="互斥阶段由服务端从同一帧时间线计算,各阶段占比之和约为 100%。">线</strong><code>#{displayed.server.sequence} / {displayed.server.correlation_id}</code></div>
<div className="stageStatisticControls" aria-label="帧阶段统计显示方式">
@@ -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<string, string> = {
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]) => <span key={type}
title={`${type}:等帧 P95 ${(statistics.queue_wait_ms?.p95 ?? 0).toFixed(2)} msRenderable 分发 P95 ${(statistics.dispatch_ms?.p95 ?? 0).toFixed(2)} ms。`}>
{event_labels[type] ?? type} P95 {(statistics.total_ms?.p95 ?? 0).toFixed(1)} ms
</span>);
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 <article ref={card_ref} className={`card${selected ? " selected" : ""}`} data-plot-id={plot.id}
data-input-stage={server_input?.input_server_consume_ms?.count ? "aggregated" : "none"} aria-current={selected ? "true" : undefined}
data-input-admission-ms={server_input?.input_admission_ms?.latest ?? ""}
data-input-scene-wait-ms={server_input?.input_scene_wait_ms?.latest ?? ""}
data-input-dispatch-ms={server_input?.input_dispatch_ms?.latest ?? ""}
data-input-server-consume-ms={server_input?.input_server_consume_ms?.latest ?? ""}
data-input-stage={server_input?.pointer_move?.total_ms?.count ? "aggregated" : "none"} aria-current={selected ? "true" : undefined}
data-input-scene-wait-ms={server_input?.pointer_move?.queue_wait_ms?.latest ?? ""}
data-input-dispatch-ms={server_input?.pointer_move?.dispatch_ms?.latest ?? ""}
data-input-server-consume-ms={server_input?.pointer_move?.total_ms?.latest ?? ""}
onClick={() => on_select(plot)}>
<header>
<div className="cardTitle"><span className="cardDragHandle" aria-label="拖动卡片" title="拖动卡片"></span><div><span className="eyebrow"> · {plot.dimension}</span><h2>{plot_labels[plot.id] ?? plot.title}</h2></div></div>
@@ -1132,22 +1208,16 @@ const Plot_Card = memo(function Plot_Card({plot, selected, policy, gallery, on_p
<span> {metrics ? metrics.server_completion_ms.toFixed(1) : "--.-"} ms</span>
<span title={gallery.transport ? `图集合成 ${gallery.transport.compose_average_ms.toFixed(2)} ms · WebRTC 发送 ${gallery.transport.publish_average_ms.toFixed(2)} ms` : "等待媒体流水线指标"}>{encoder_label} {gallery.transport ? gallery.transport.encode_average_ms.toFixed(1) : "--.-"} ms</span>
<span> {source_transport?.has_rendered_frame ? source_transport.clock_lag_ticks : "--"} tick</span>
{plot.dimension === "3D" ? <>
<span title="服务端滑动窗口 P95;由每秒诊断 GET 更新"> P95 {server_input?.input_admission_ms ? server_input.input_admission_ms.p95.toFixed(1) : "--.-"} ms</span>
<span title="服务端滑动窗口 P95;事件等待下一次 Prepare 消费">Scene P95 {server_input?.input_scene_wait_ms ? server_input.input_scene_wait_ms.p95.toFixed(1) : "--.-"} ms</span>
<span title="服务端滑动窗口 P95Scene 内 Renderable 事件分发耗时"> P95 {server_input?.input_dispatch_ms ? server_input.input_dispatch_ms.p95.toFixed(1) : "--.-"} ms</span>
<span title="服务端滑动窗口 P95;从 WebSocket 接收到 Renderable 完成消费,不等待画面"> P95 {server_input?.input_server_consume_ms ? server_input.input_server_consume_ms.p95.toFixed(1) : "--.-"} ms</span>
<span title="服务端滑动窗口内每个提交事件所合并的原始浏览器事件平均数量"> {server_input?.input_coalesced_event_count ? server_input.input_coalesced_event_count.average.toFixed(1) : "--.-"}</span>
</> : null}
{plot.dimension === "3D" ? input_latency_summary : null}
</div>
</div>
</header>
{plot.dimension === "2D" ? <div className="frameMetrics interactionMetrics">
<span title="服务端滑动窗口 P95;由每秒诊断 GET 更新"> P95 {server_input?.input_admission_ms ? server_input.input_admission_ms.p95.toFixed(1) : "--.-"} ms</span>
<span title="服务端滑动窗口 P95;事件等待下一次 Prepare 消费">Scene P95 {server_input?.input_scene_wait_ms ? server_input.input_scene_wait_ms.p95.toFixed(1) : "--.-"} ms</span>
<span title="服务端滑动窗口 P95Scene 内 Renderable 事件分发耗时"> P95 {server_input?.input_dispatch_ms ? server_input.input_dispatch_ms.p95.toFixed(1) : "--.-"} ms</span>
<span title="服务端滑动窗口 P95;从 WebSocket 接收到 Renderable 完成消费,不等待画面"> P95 {server_input?.input_server_consume_ms ? server_input.input_server_consume_ms.p95.toFixed(1) : "--.-"} ms</span>
<span title="服务端滑动窗口内每个提交事件所合并的原始浏览器事件平均数量"> {server_input?.input_coalesced_event_count ? server_input.input_coalesced_event_count.average.toFixed(1) : "--.-"}</span>
{browser_move ? <span title={`浏览器事件处理平均 ${browser_move.average_ms.toFixed(2)} msWebSocket 待发送峰值 ${browser_move.websocket_buffered_bytes} B`}>
{browser_move.maximum_ms.toFixed(1)} ms · / {browser_move.sent_count}/{browser_move.disconnected_count}
</span> : null}
{input_latency_summary.length ? input_latency_summary
: <span title="移动指针后按事件类型显示 Scene 延迟"></span>}
</div> : null}
<div className="plotExecutionPolicy" onPointerDown={event => event.stopPropagation()}>
<label title="控制这张图的画面是否显示;不改变 Scene 自身的 visible 属性。"><input type="checkbox" checked={policy.visible} onChange={event => on_policy(plot.id, {visible: event.target.checked})}/></label>