事件处理走双缓冲了

This commit is contained in:
2026-08-24 00:55:34 +08:00
parent 933ff2ec08
commit db251b5abf
21 changed files with 497 additions and 211 deletions
+12
View File
@@ -1,4 +1,5 @@
#pragma once
#include <atomic>
#include <concepts>
#include <cstdint>
#include <type_traits>
@@ -17,13 +18,24 @@ enum class Event_Type : std::uint8_t {
};
/* 所有输入事件的公共业务基类。 */
struct Event {
struct Dispatch_Timing {
std::uint64_t frame_sequence{};
std::uint64_t started_steady_ns{};
std::uint64_t completed_steady_ns{};
};
explicit Event(Event_Type value);
virtual ~Event();
void accept() const noexcept;
[[nodiscard]] bool is_accepted() const noexcept;
void mark_dispatch_started(std::uint64_t frame_sequence) const noexcept;
void mark_dispatch_completed() const noexcept;
[[nodiscard]] Dispatch_Timing dispatch_timing() const noexcept;
Event_Type type; /* 事件种类;构造后保持不变。 */
private:
mutable bool accepted{}; /* 处理链是否已经消费事件。 */
mutable std::atomic_uint64_t dispatch_frame_sequence_{};
mutable std::atomic_uint64_t dispatch_started_steady_ns_{};
mutable std::atomic_uint64_t dispatch_completed_steady_ns_{};
};
template <typename T>
concept Event_Object = std::derived_from<std::remove_cvref_t<T>, Event>;
+20
View File
@@ -1,9 +1,29 @@
#pragma once
#include <chrono>
namespace aethera {
namespace detail {
inline std::uint64_t event_steady_time_ns() noexcept {
return static_cast<std::uint64_t>(std::chrono::duration_cast<std::chrono::nanoseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count());
}
}
inline Event::Event(Event_Type value) : type(value) {}
inline Event::~Event() = default;
inline void Event::accept() const noexcept { accepted = true; }
inline bool Event::is_accepted() const noexcept { return accepted; }
inline void Event::mark_dispatch_started(std::uint64_t frame_sequence) const noexcept {
dispatch_frame_sequence_.store(frame_sequence, std::memory_order_relaxed);
dispatch_completed_steady_ns_.store(0, std::memory_order_relaxed);
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);
}
inline Event::Dispatch_Timing Event::dispatch_timing() const noexcept {
return {dispatch_frame_sequence_.load(std::memory_order_relaxed),
dispatch_started_steady_ns_.load(std::memory_order_acquire),
dispatch_completed_steady_ns_.load(std::memory_order_acquire)};
}
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); }
+35 -8
View File
@@ -1,16 +1,43 @@
#include "scene.hpp" /* 后端共有 Prepare Scene 实现。 */
#include <new>
#include <stdexcept>
namespace aethera {
void Scene::Private::Event_Queue::push(std::shared_ptr<Event> event) {
if (!pending.enqueue(std::move(event))) throw std::bad_alloc{};
}
Scene::Private::Private() : runtime(std::make_unique<Runtime>()) {}
Scene::Private::Private() = default;
Scene::Private::~Private() {
if (runtime->taskflow) detail::clear_stage_observers(runtime->taskflow.get());
if (runtime && runtime->taskflow) detail::clear_stage_observers(runtime->taskflow.get());
}
void Scene::Private::push_event(std::unique_ptr<Event> event) {
void Scene::Private::push_event(Event_Pointer event) {
if (!event) throw std::invalid_argument("scene event ownership must not be empty");
event_queue.push(std::shared_ptr<Event>{std::move(event)});
std::lock_guard lock(runtime->event_mutex);
runtime->events.pending->push_back(std::move(event));
}
Scene::Event_Batch Scene::Private::take_events(std::uint64_t frame_sequence) {
Event_Batch result{runtime->events.current->get_allocator()};
{
std::lock_guard lock(runtime->event_mutex);
runtime->events.advance();
runtime->events.pending->clear();
result = std::move(*runtime->events.current);
}
for (const auto& event : result) event->mark_dispatch_started(frame_sequence);
if (!result.empty()) {
std::lock_guard lock(runtime->report_mutex);
runtime->reports.pending->insert(runtime->reports.pending->end(),
result.begin(), result.end());
}
return result;
}
Scene::Event_Report_Batch Scene::Private::take_event_reports() {
Event_Report_Batch result{runtime->reports.current->get_allocator()};
std::lock_guard lock(runtime->report_mutex);
runtime->reports.advance();
runtime->reports.pending->clear();
result = std::move(*runtime->reports.current);
return result;
}
void Scene::dispatch_event(Event_Pointer event) {
static_cast<Private&>(*d).push_event(std::move(event));
}
Scene::Event_Report_Batch Scene::take_event_reports() {
return static_cast<Private&>(*d).take_event_reports();
}
}
+11
View File
@@ -1,6 +1,9 @@
#pragma once
#include "frame.hpp"
#include "renderable.hpp"
#include <memory>
#include <memory_resource>
#include <vector>
namespace aethera {
/* Scene 状态标签,用于访问和订阅 Scene::State。 */
/*
@@ -8,6 +11,10 @@ namespace aethera {
* 用户最终通过 Impl<Scene> 创建可使用实例;编辑 Dependency_Graph 后调用 advance() 提交结构变化,再调用 process(...) 执行当前场景。
*/
struct Scene : Def<Scene, Root, Dependency_Graph_Type<Prepare_Data_Tag, Renderable>> {
using Event_Pointer = std::shared_ptr<Event>;
using Event_Batch = std::pmr::vector<Event_Pointer>;
using Event_Report_Pointer = std::shared_ptr<const Event>;
using Event_Report_Batch = std::pmr::vector<Event_Report_Pointer>;
/* Scene 当前没有额外发布属性;派生定义可在自己的 Prop 中继续追加字段。 */
struct Prop : Prev_Prop {};
/* Scene 每次 process(...) 后发布的总图结构与执行统计。 */
@@ -24,6 +31,10 @@ struct Scene : Def<Scene, Root, Dependency_Graph_Type<Prepare_Data_Tag, Renderab
};
/* 完整声明、字段及可覆盖 CRTP hook 见 scene.ipp 中的 Scene::Private。 */
struct Private;
template <Event_Object Event_Object_Type, typename... Arguments>
[[nodiscard]] std::shared_ptr<Event_Object_Type> make_event(Arguments&&... arguments);
void dispatch_event(Event_Pointer event);
[[nodiscard]] Event_Report_Batch take_event_reports();
};
}
#include "scene.ipp"
+20 -26
View File
@@ -1,6 +1,6 @@
#pragma once
#include <concurrentqueue-1.0.5/concurrentqueue.h>
#include <algorithm>
#include <mutex>
#include <unordered_map>
#include <unordered_set>
#include <vector>
@@ -8,23 +8,13 @@ namespace aethera {
struct Scene::Private : Prev_Private {
struct Result {}; /* process(...) 完成回调的结果类型;当前仅表示完成。 */
struct Runtime; /* Scene 的 Taskflow 构建产物;完整定义位于本文件下方。 */
/* Scene 唯一事件流:第三方 MPMC 队列负责跨线程所有权转移,当前事件仅供单消费者重试。 */
struct Event_Queue : Pinned {
moodycamel::ConcurrentQueue<std::shared_ptr<Event>> pending{}; /* 外部线程提交、Prepare 线程消费的事件队列。 */
std::shared_ptr<Event> current{}; /* 后端暂不可用时留到下一帧重试的事件。 */
void push(std::shared_ptr<Event> event);
template <typename Callback>
void consume(Callback&& callback) requires std::predicate<Callback, const std::shared_ptr<Event>&>;
};
std::unique_ptr<Runtime> runtime; /* Scene 唯一运行时构建产物的所有权。 */
Event_Queue event_queue; /* 跨输入线程与 Scene Prepare 边界交换的唯一事件队列。 */
Private();
~Private();
/* 将事件所有权无锁提交到外部待处理批次;空所有权违反调用契约。 */
void push_event(std::unique_ptr<Event> event);
/* 在 Scene 执行线程交换并按提交顺序消费一个完整批次。 */
template <typename Callback>
void consume_events(Callback&& callback) requires std::predicate<Callback, const std::shared_ptr<Event>&>;
void push_event(Event_Pointer event);
[[nodiscard]] Event_Batch take_events(std::uint64_t frame_sequence);
[[nodiscard]] Event_Report_Batch take_event_reports();
template <Attached Object> void bind_private_crtp(Object* object);
/* Def CRTP hook:所有缓冲推进后重建必要的总 Taskflow,并更新 Scene_State_Tag 状态层。 */
template <Attached Object>
void after_advance(Object* object,
@@ -42,19 +32,23 @@ struct Scene::Private : Prev_Private {
};
struct Scene::Private::Runtime {
std::unique_ptr<tf::Taskflow> taskflow; /* 当前已构建的总 Taskflow;为空表示尚未构建。 */
std::mutex event_mutex;
double_buffer::Double_Buffer<Event_Batch> events;
std::mutex report_mutex;
double_buffer::Double_Buffer<Event_Report_Batch> reports;
explicit Runtime(std::pmr::memory_resource* resource)
: events(std::allocator_arg, typename decltype(events)::allocator_type{resource}),
reports(std::allocator_arg, typename decltype(reports)::allocator_type{resource}) {}
};
template <typename Callback>
void Scene::Private::Event_Queue::consume(Callback&& callback) requires std::predicate<Callback, const std::shared_ptr<Event>&> {
if (current && !std::invoke(callback, std::as_const(current))) return;
current.reset();
while (pending.try_dequeue(current)) {
if (!std::invoke(callback, std::as_const(current))) return;
current.reset();
}
template <Attached Object>
void Scene::Private::bind_private_crtp(Object* object) {
Prev_Private::bind_private_crtp(object);
runtime = std::make_unique<Runtime>(object->memory_resource());
}
template <typename Callback>
void Scene::Private::consume_events(Callback&& callback) requires std::predicate<Callback, const std::shared_ptr<Event>&> {
event_queue.consume(std::forward<Callback>(callback));
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>(
allocator<Event_Object_Type>(), std::forward<Arguments>(arguments)...);
}
template <Attached Object, typename Callback>
void Scene::Private::process(Object* object, Callback&& callback) requires std::invocable<Callback, const Result&> {
@@ -5,9 +5,6 @@ bool Render_Scene_2D::Prop::operator==(const Prop&) const = default;
Render_Scene_2D::Render_Result Render_Scene_2D::render(Frame_2D* frame) { return static_cast<Private&>(*d).dispatch->render(this, frame); }
void Render_Scene_2D::set_frame_callback(Frame_Callback callback) { static_cast<Private&>(*d).dispatch->set_frame_callback(this, std::move(callback)); }
void Render_Scene_2D::dispatch_event(std::unique_ptr<Event> event) {
static_cast<Private&>(*d).dispatch->push_event(this, std::move(event));
}
void Render_Scene_2D::activate_view() {
static_cast<Private&>(*d).dispatch->set_active(this, true);
}
@@ -39,8 +39,6 @@ struct Render_Scene_2D : Def<Render_Scene_2D, Scene,
[[nodiscard]] Render_Result render(Frame_2D* frame);
/* 安装完成帧回调;回调收到的就是对应 render(frame) 传入的对象。 */
void set_frame_callback(Frame_Callback callback);
/* 无锁提交事件所有权;下一次 Prepare 开始时按事件区域和 Paint 逆序派发,首个接受者终止传播。 */
void dispatch_event(std::unique_ptr<Event> event);
/* 激活后 render 才会执行。 */
void activate_view();
/* 停止后续 render 调用,不清除最后一帧。 */
+10 -12
View File
@@ -34,7 +34,6 @@ std::expected<std::unique_ptr<Object>, Dependency_Graph_Error> Render_Scene_2D::
struct Render_Scene_2D::Private : Prev_Private {
using Render_Run = Render_Result (*)(Root*, Frame_2D*);
using Callback_Run = void (*)(Root*, Frame_Callback);
using Event_Run = void (*)(Root*, std::unique_ptr<Event>);
using Active_Run = void (*)(Root*, bool);
struct Paint_Node {
Root* object{}; /* Paint 拓扑位置对应的最终对象;Scene 不拥有。 */
@@ -55,7 +54,6 @@ struct Render_Scene_2D::Private : Prev_Private {
struct Dispatch {
Render_Run render; /* 向外部帧执行最终 Scene 并合成颜色层。 */
Callback_Run set_frame_callback; /* 安装最终完成帧回调。 */
Event_Run push_event; /* 向最终 Scene 的无锁待处理批次转移事件所有权。 */
Active_Run set_active; /* 修改最终 Scene 的视图活动状态。 */
};
const Dispatch* dispatch{}; /* Builder 绑定最终 Scene 类型后的静态分派表。 */
@@ -74,7 +72,9 @@ struct Render_Scene_2D::Private : Prev_Private {
/* 每帧在 Prepare 完成后计算组级 dirty,并为所有二维节点指定唯一绘制目标。 */
template <Attached Object> void prepare_paint_targets(Object* object, Blend2D_Cache& frame, Size viewport);
/* Prepare 开始时交换 Scene 事件批次,按区域、Paint 逆序和最近目标规则逐事件完成接受链。 */
template <Attached Object> void dispatch_events(Object* object, Size viewport);
template <Attached Object>
void dispatch_events(Object* object, Size viewport,
std::uint64_t frame_sequence);
template <Attached Object> [[nodiscard]] Render_Result render(Object* object, Frame_2D* frame);
template <Attached Object>
[[nodiscard]] static const Dispatch& dispatch_for();
@@ -234,7 +234,7 @@ void Render_Scene_2D::Private::process(Object* object, Callback&& callback)
if (!state.view_active || state.viewport.empty()) return;
frame_object->mark(Frame_Trace_Marker::scene_render_started);
frame_object->mark(Frame_Trace_Marker::event_dispatch_started);
dispatch_events(object, state.viewport);
dispatch_events(object, state.viewport, frame_object->identity().sequence);
frame_object->mark(Frame_Trace_Marker::event_dispatch_finished);
object->template current_dependency_graph<Paint_Cache_Tag>().for_each([](const Dependency_Graph::Node& node) {
if (!node.object->template take_dirty<Paint_Cache_Tag>()) return;
@@ -295,8 +295,10 @@ Render_Scene_2D::Render_Result Render_Scene_2D::Private::render(Object* object,
return prop.view_active ? Render_Result::empty_viewport : Render_Result::view_inactive;
}
template <Attached Object>
void Render_Scene_2D::Private::dispatch_events(Object* object, Size viewport) {
this->consume_events([&](const std::shared_ptr<Event>& owned_event) {
void Render_Scene_2D::Private::dispatch_events(Object* object, Size viewport,
std::uint64_t frame_sequence) {
auto events = this->take_events(frame_sequence);
for (const auto& owned_event : events) {
const Event& event = *owned_event;
const auto* pointer = dynamic_cast<const Pointer_Event_Capability*>(&event);
std::vector<Event_Target> order;
@@ -317,8 +319,8 @@ void Render_Scene_2D::Private::dispatch_events(Object* object, Size viewport) {
if (event.is_accepted()) break;
target.private_data->event_run(target.object, event);
}
return true;
});
event.mark_dispatch_completed();
}
}
template <Attached Object>
const Render_Scene_2D::Private::Dispatch& Render_Scene_2D::Private::dispatch_for() {
@@ -331,10 +333,6 @@ const Render_Scene_2D::Private::Dispatch& Render_Scene_2D::Private::dispatch_for
auto* object = static_cast<Object*>(root);
static_cast<typename Object::Private&>(*object->d).frame_callback = std::move(callback);
},
[](Root* root, std::unique_ptr<Event> event) {
auto* object = static_cast<Object*>(root);
static_cast<typename Object::Private&>(*object->d).push_event(std::move(event));
},
[](Root* root, bool active) { static_cast<Object*>(root)->template set<&Prop::view_active>(active); }
};
return value;
+10 -10
View File
@@ -117,19 +117,19 @@ TEST(axis_event, numeric_axis_wheel_zoom_and_drag_update_authoritative_range) {
}).has_value()));
scene->activate_view();
render_once(scene.get());
auto wheel = std::make_unique<Wheel_Event>();
auto wheel = scene->make_event<Wheel_Event>();
wheel->position = {50.0, 0.0};
wheel->angle_delta_y = 120.0;
scene->dispatch_event(std::move(wheel));
scene->dispatch_event(wheel);
render_once(scene.get());
EXPECT_EQ(axis->coordinate_range(), (Axis_Range{0.5, 9.5}));
auto press = std::make_unique<Pointer_Event>(Event_Type::pointer_press);
auto press = scene->make_event<Pointer_Event>(Event_Type::pointer_press);
press->position = {50.0, 0.0};
press->button = Mouse_Button::left;
scene->dispatch_event(std::move(press));
auto move = std::make_unique<Pointer_Event>(Event_Type::pointer_move);
scene->dispatch_event(press);
auto move = scene->make_event<Pointer_Event>(Event_Type::pointer_move);
move->position = {60.0, 0.0};
scene->dispatch_event(std::move(move));
scene->dispatch_event(move);
render_once(scene.get());
EXPECT_EQ(axis->coordinate_range(), (Axis_Range{-0.4, 8.6}));
}
@@ -155,18 +155,18 @@ TEST(axis_event, scene_routes_pointer_interaction_to_the_nearest_axis_segment) {
scene->activate_view();
render_once(scene.get());
auto near_vertical = std::make_unique<Wheel_Event>();
auto near_vertical = scene->make_event<Wheel_Event>();
near_vertical->position = {22.0, 80.0};
near_vertical->angle_delta_y = 120.0;
scene->dispatch_event(std::move(near_vertical));
scene->dispatch_event(near_vertical);
render_once(scene.get());
EXPECT_DOUBLE_EQ(horizontal->coordinate_range().size(), 10.0);
EXPECT_DOUBLE_EQ(vertical->coordinate_range().size(), 9.0);
auto near_horizontal = std::make_unique<Wheel_Event>();
auto near_horizontal = scene->make_event<Wheel_Event>();
near_horizontal->position = {120.0, 178.0};
near_horizontal->angle_delta_y = 120.0;
scene->dispatch_event(std::move(near_horizontal));
scene->dispatch_event(near_horizontal);
render_once(scene.get());
EXPECT_DOUBLE_EQ(horizontal->coordinate_range().size(), 9.0);
EXPECT_DOUBLE_EQ(vertical->coordinate_range().size(), 9.0);
+12 -12
View File
@@ -178,20 +178,20 @@ TEST(selection_overlay, control_extends_selection_and_plain_click_clears_it) {
render_once(scene.get());
const auto drag = [&](Point_F first, Point_F second, Keyboard_Modifier modifiers) {
auto press = std::make_unique<Pointer_Event>(Event_Type::pointer_press);
auto press = scene->make_event<Pointer_Event>(Event_Type::pointer_press);
press->position = first;
press->button = Mouse_Button::left;
press->modifiers = modifiers;
scene->dispatch_event(std::move(press));
auto move = std::make_unique<Pointer_Event>(Event_Type::pointer_move);
scene->dispatch_event(press);
auto move = scene->make_event<Pointer_Event>(Event_Type::pointer_move);
move->position = second;
move->modifiers = modifiers;
scene->dispatch_event(std::move(move));
auto release = std::make_unique<Pointer_Event>(Event_Type::pointer_release);
scene->dispatch_event(move);
auto release = scene->make_event<Pointer_Event>(Event_Type::pointer_release);
release->position = second;
release->button = Mouse_Button::left;
release->modifiers = modifiers;
scene->dispatch_event(std::move(release));
scene->dispatch_event(release);
render_once(scene.get());
};
drag({30.0, 90.0}, {60.0, 60.0}, Keyboard_Modifier::none);
@@ -223,17 +223,17 @@ TEST(selection_overlay, time_axis_selection_keeps_axis_coordinates_while_window_
scene->set<&Render_Scene_2D::Prop::viewport>(canvas);
scene->activate_view();
render_once(scene.get());
auto press = std::make_unique<Pointer_Event>(Event_Type::pointer_press);
auto press = scene->make_event<Pointer_Event>(Event_Type::pointer_press);
press->position = {92.0, 88.0};
press->button = Mouse_Button::left;
scene->dispatch_event(std::move(press));
auto move = std::make_unique<Pointer_Event>(Event_Type::pointer_move);
scene->dispatch_event(press);
auto move = scene->make_event<Pointer_Event>(Event_Type::pointer_move);
move->position = {116.0, 56.0};
scene->dispatch_event(std::move(move));
auto release = std::make_unique<Pointer_Event>(Event_Type::pointer_release);
scene->dispatch_event(move);
auto release = scene->make_event<Pointer_Event>(Event_Type::pointer_release);
release->position = {116.0, 56.0};
release->button = Mouse_Button::left;
scene->dispatch_event(std::move(release));
scene->dispatch_event(release);
render_once(scene.get());
ASSERT_EQ(overlay->selected_regions().size(), 1u);
const Axis_Rectangle selected = overlay->selected_regions().front();
@@ -72,6 +72,7 @@ struct Async_Render_Backend::Implementation
Shared_Prepared_Visual_Batch visuals{}; /* Scene CPU Prepare 发布的大批量不可变快照。 */
Scene_3D_Parameters parameters{}; /* 本帧 Scene、Camera 与轴参数快照。 */
std::vector<Completion> completions{}; /* 合并到同一实际画面的逻辑帧。 */
Scene::Event_Batch events{}; /* Prepare 边界已交换的完整事件批次。 */
};
struct Pending {
Datoviz_Visual_Backend::Pending_Frame backend_frame{}; /* 三缓冲目标对应的已录制提交。 */
@@ -84,11 +85,7 @@ struct Async_Render_Backend::Implementation
std::optional<Gpu_Completion_Service::Result> result{}; /* fence 正常交付时的结果。 */
std::exception_ptr failure{}; /* 提交域或完成服务的 Unknown Failure。 */
};
struct Event_Command {
std::shared_ptr<const Event> event{}; /* Scene 转移给准备域的输入事件。 */
Extent viewport{}; /* 事件产生时的物理像素视口。 */
};
using Command = std::variant<Submission, Gpu_Completion, Event_Command>;
using Command = std::variant<Submission, Gpu_Completion>;
static constexpr std::size_t command_capacity = 64;
std::shared_ptr<Render_Domain> render_domain; /* 同 GPU 唯一的轻量 Queue Submit 域。 */
@@ -124,7 +121,7 @@ struct Async_Render_Backend::Implementation
void initialize(const Scene_3D_Parameters& parameters);
[[nodiscard]] Async_Render_Backend::Submit_Result render(
Shared_Prepared_Visual_Batch visuals, Scene_3D_Parameters parameters,
Frame_3D* frame);
Frame_3D* frame, Scene::Event_Batch events);
void accept(Submission submission);
void merge_deferred(Submission submission);
void prepare(Submission submission);
@@ -133,7 +130,7 @@ struct Async_Render_Backend::Implementation
void resolve(std::shared_ptr<Pending> pending,
std::optional<Datoviz_Visual_Backend::Completed_Frame> completed);
void complete(std::vector<Completion> completions);
void dispatch(Event_Command command);
void dispatch(const std::shared_ptr<Event>& event, Extent viewport);
};
Async_Render_Backend::Async_Render_Backend(
@@ -198,7 +195,7 @@ void Async_Render_Backend::Implementation::initialize(
Async_Render_Backend::Submit_Result
Async_Render_Backend::Implementation::render(
Shared_Prepared_Visual_Batch visuals, Scene_3D_Parameters parameters,
Frame_3D* frame) {
Frame_3D* frame, Scene::Event_Batch events) {
if (!frame)
throw std::invalid_argument("3D backend requires a non-null external frame");
if (!visuals || visuals->empty())
@@ -215,7 +212,8 @@ Async_Render_Backend::Implementation::render(
frame->mark(Frame_Trace_Marker::backend_queue_entered);
try {
enqueue(Submission{std::move(visuals), std::move(parameters),
{Completion{frame, std::move(callback)}}});
{Completion{frame, std::move(callback)}},
std::move(events)});
}
catch (...) {
fail(std::current_exception());
@@ -226,6 +224,10 @@ Async_Render_Backend::Implementation::render(
void Async_Render_Backend::Implementation::merge_deferred(
Submission submission) {
if (deferred_submission) {
submission.events.insert(
submission.events.begin(),
std::make_move_iterator(deferred_submission->events.begin()),
std::make_move_iterator(deferred_submission->events.end()));
submission.completions.insert(
submission.completions.begin(),
std::make_move_iterator(deferred_submission->completions.begin()),
@@ -253,6 +255,9 @@ void Async_Render_Backend::Implementation::prepare(Submission submission) {
std::optional<Datoviz_Visual_Backend::Pending_Frame> prepared;
try {
initialize(submission.parameters);
for (const auto& event : submission.events)
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 =
@@ -446,14 +451,21 @@ void Async_Render_Backend::Implementation::finish(Gpu_Completion completion) {
resolve(std::move(pending), std::nullopt);
}
}
void Async_Render_Backend::Implementation::dispatch(Event_Command command) {
if (!backend || !command.event) return;
void Async_Render_Backend::Implementation::dispatch(
const std::shared_ptr<Event>& event, Extent viewport) {
if (!event) return;
const auto* pointer =
dynamic_cast<const Pointer_Event_Capability*>(command.event.get());
dynamic_cast<const Pointer_Event_Capability*>(event.get());
const auto* wheel =
dynamic_cast<const Wheel_Event_Capability*>(command.event.get());
const auto* key = dynamic_cast<const Key_Event*>(command.event.get());
if (wheel) {
dynamic_cast<const Wheel_Event_Capability*>(event.get());
const auto* key = dynamic_cast<const Key_Event*>(event.get());
const bool pointer_valid = pointer &&
std::isfinite(pointer->position_x()) &&
std::isfinite(pointer->position_y()) &&
pointer->position_x() >= 0.0 && pointer->position_y() >= 0.0 &&
pointer->position_x() <= viewport.width &&
pointer->position_y() <= viewport.height;
if (wheel && pointer_valid) {
backend->dispatch_wheel(
static_cast<float>(pointer->position_x()),
static_cast<float>(pointer->position_y()),
@@ -461,20 +473,24 @@ void Async_Render_Backend::Implementation::dispatch(Event_Command command) {
wheel->angle_delta_x_value()),
wheel_step(wheel->pixel_delta_y_value(),
wheel->angle_delta_y_value()),
pointer->keyboard_modifiers(), command.viewport);
pointer->keyboard_modifiers(), viewport);
}
else if (pointer) {
else if (pointer_valid &&
(event->type == Event_Type::pointer_move ||
event->type == Event_Type::pointer_press ||
event->type == Event_Type::pointer_release)) {
backend->dispatch_pointer(
command.event->type,
event->type,
static_cast<float>(pointer->position_x()),
static_cast<float>(pointer->position_y()),
command.event->type == Event_Type::pointer_move
event->type == Event_Type::pointer_move
? held_button(pointer->pointer_buttons())
: pointer->pointer_button(),
pointer->keyboard_modifiers(), command.viewport);
pointer->keyboard_modifiers(), viewport);
}
else if (key) backend->dispatch_key(*key);
command.event->accept();
if ((wheel && pointer_valid) || pointer_valid || key) event->accept();
event->mark_dispatch_completed();
}
void Async_Render_Backend::Implementation::run() noexcept {
for (;;) {
@@ -483,12 +499,11 @@ void Async_Render_Backend::Implementation::run() noexcept {
command, std::chrono::milliseconds(10));
if (received) {
try {
if (auto* submission = std::get_if<Submission>(&command))
if (auto* submission = std::get_if<Submission>(&command)) {
accept(std::move(*submission));
}
else if (auto* completion = std::get_if<Gpu_Completion>(&command))
finish(std::move(*completion));
else if (auto* event = std::get_if<Event_Command>(&command))
dispatch(std::move(*event));
}
catch (...) {
fail(std::current_exception());
@@ -527,8 +542,9 @@ void Async_Render_Backend::Implementation::run() noexcept {
Async_Render_Backend::Submit_Result Async_Render_Backend::render(
Shared_Prepared_Visual_Batch visuals, Scene_3D_Parameters parameters,
Frame_3D* frame) {
return implementation_->render(std::move(visuals), parameters, frame);
Frame_3D* frame, Scene::Event_Batch events) {
return implementation_->render(std::move(visuals), parameters, frame,
std::move(events));
}
void Async_Render_Backend::set_frame_callback(Frame_Callback callback) {
std::lock_guard lock(implementation_->callback_mutex);
@@ -537,39 +553,4 @@ void Async_Render_Backend::set_frame_callback(Frame_Callback callback) {
bool Async_Render_Backend::available() const noexcept {
return implementation_->available.load(std::memory_order_acquire);
}
Async_Render_Backend::Dispatch_Event_Result
Async_Render_Backend::dispatch_event(std::shared_ptr<const Event> event,
Extent viewport) {
auto self = implementation_;
const auto* pointer = dynamic_cast<const Pointer_Event_Capability*>(event.get());
const auto* wheel = dynamic_cast<const Wheel_Event_Capability*>(event.get());
const auto* key = dynamic_cast<const Key_Event*>(event.get());
if (wheel && !pointer) return Dispatch_Event_Result::invalid_event;
if (pointer) {
if (!std::isfinite(pointer->position_x()) ||
!std::isfinite(pointer->position_y()))
return Dispatch_Event_Result::invalid_event;
if (pointer->position_x() < 0.0 || pointer->position_y() < 0.0 ||
pointer->position_x() > viewport.width ||
pointer->position_y() > viewport.height)
return Dispatch_Event_Result::ignored;
if (!wheel && event->type != Event_Type::pointer_move &&
event->type != Event_Type::pointer_press &&
event->type != Event_Type::pointer_release)
return Dispatch_Event_Result::invalid_event;
}
else if (!key) return Dispatch_Event_Result::ignored;
std::lock_guard admission_lock(self->admission_mutex);
if (!self->available.load(std::memory_order_acquire) ||
self->stop_requested.load(std::memory_order_acquire))
return Dispatch_Event_Result::backend_unavailable;
try {
self->enqueue(Implementation::Event_Command{std::move(event), viewport});
}
catch (...) {
self->fail(std::current_exception());
return Dispatch_Event_Result::backend_unavailable;
}
return Dispatch_Event_Result::queued;
}
}
@@ -1,6 +1,7 @@
#pragma once
#include "../base/Frame_3D.hpp"
#include "Backend_Types.hpp"
#include <scene.hpp>
#include <functional>
#include <memory>
namespace aethera::render_3d::detail {
@@ -19,19 +20,10 @@ public:
/* 将 Visual 快照所有权无等待转移给所属 GPU Render Domain。 */
[[nodiscard]] Submit_Result render(
Shared_Prepared_Visual_Batch visuals, Scene_3D_Parameters parameters,
Frame_3D* frame);
Frame_3D* frame, Scene::Event_Batch events);
/* 设置后续提交捕获的逻辑帧完成出口。 */
void set_frame_callback(Frame_Callback callback);
[[nodiscard]] bool available() const noexcept;
enum class Dispatch_Event_Result : std::uint8_t {
queued,
ignored,
invalid_event,
backend_unavailable
};
/* 将事件所有权无等待转移到 Render Domain;停止时由 Scene 在下一帧重试。 */
[[nodiscard]] Dispatch_Event_Result dispatch_event(
std::shared_ptr<const Event> event, Extent viewport);
private:
struct Implementation;
std::shared_ptr<Implementation> implementation_; /* 已入队闭包共享实现生命周期。 */
+10 -4
View File
@@ -64,7 +64,8 @@ Render_Domain::Post_Result Render_Domain::post(
active_posters_.fetch_sub(1, std::memory_order_release);
return Post_Result::stopping;
}
if (!tasks_.enqueue(std::move(task))) {
const bool enqueued = tasks_.enqueue(std::move(task));
if (!enqueued) {
active_posters_.fetch_sub(1, std::memory_order_release);
throw std::bad_alloc{};
}
@@ -75,8 +76,14 @@ void Render_Domain::run() noexcept {
current_domain_ = this;
for (;;) {
std::unique_ptr<Task> task;
const bool received = tasks_.wait_dequeue_timed(
bool received = tasks_.wait_dequeue_timed(
task, std::chrono::milliseconds(1));
if (!received && stopping_.load(std::memory_order_acquire) &&
active_posters_.load(std::memory_order_acquire) == 0) {
/* stop 边界关闭生产者后再真实探测队列,不能用
* size_approx 决定生命周期。 */
received = tasks_.try_dequeue(task);
}
if (received && task) {
try {
task->function();
@@ -95,8 +102,7 @@ void Render_Domain::run() noexcept {
// 已经消费完所有先于停止边界进入的任务。size_approx() 只能诊断,
// 不能参与线程退出正确性。
if (!received && stopping_.load(std::memory_order_acquire) &&
active_posters_.load(std::memory_order_acquire) == 0 &&
!task) {
active_posters_.load(std::memory_order_acquire) == 0) {
current_domain_ = nullptr;
if (destroy_on_exit_.load(std::memory_order_acquire)) {
if (thread_.joinable()) thread_.detach();
@@ -4,7 +4,6 @@ bool Render_Scene_3D::Prop::operator==(const Prop&) const = default;
bool Render_Scene_3D::State::operator==(const State&) const = default;
Render_Scene_3D::Render_Result Render_Scene_3D::render(Frame_3D* frame) { return static_cast<Private&>(*d).dispatch->render(this, frame); }
void Render_Scene_3D::set_frame_callback(Frame_Callback callback) { static_cast<Private&>(*d).dispatch->set_frame_callback(this, std::move(callback)); }
void Render_Scene_3D::dispatch_event(std::unique_ptr<Event> event) { static_cast<Private&>(*d).dispatch->push_event(this, std::move(event)); }
void Render_Scene_3D::activate_view() { static_cast<Private&>(*d).dispatch->set_active(this, true); }
void Render_Scene_3D::deactivate_view() { static_cast<Private&>(*d).dispatch->set_active(this, false); }
}
@@ -62,8 +62,6 @@ struct Render_Scene_3D : Def<Render_Scene_3D, Scene, Dependency_Graph_Type<Submi
[[nodiscard]] Render_Result render(Frame_3D* frame);
/* 安装完成帧回调;回调由 Datoviz Render Domain 线程传回同一外部帧。 */
void set_frame_callback(Frame_Callback callback);
/* 无锁提交事件所有权;下一次 Prepare 开始时无等待转交 Datoviz 渲染域。 */
void dispatch_event(std::unique_ptr<Event> event);
void activate_view();
void deactivate_view();
};
+5 -14
View File
@@ -17,12 +17,10 @@ struct Scene_Paint_Context {
struct Render_Scene_3D::Private : Prev_Private {
using Render_Run = Render_Result (*)(Root*, Frame_3D*);
using Callback_Run = void (*)(Root*, Frame_Callback);
using Event_Run = void (*)(Root*, std::unique_ptr<Event>);
using Active_Run = void (*)(Root*, bool);
struct Dispatch {
Render_Run render; /* 执行 CPU 图并异步提交 Paint。 */
Callback_Run set_frame_callback; /* 安装最终完成帧回调。 */
Event_Run push_event; /* 向 Kernel Scene 无锁待处理批次转移事件所有权。 */
Active_Run set_active; /* 修改最终 Scene 的活动属性。 */
};
std::shared_ptr<detail::Async_Render_Backend> backend{}; /* Scene 拥有的异步后端;已入队命令自行延长实现寿命。 */
@@ -44,8 +42,6 @@ struct Render_Scene_3D::Private : Prev_Private {
template <Attached Object> void bind_private_crtp(Object* object);
/* CRTP 覆盖:先执行 Kernel Prepare Taskflow,再运行只负责异步入队的三维 Submit 阶段。 */
template <Attached Object, typename Callback> void process(Object* object, Callback&& callback) requires std::invocable<Callback>;
/* Prepare 开始时交换公共 Scene 事件批次,并无等待提交到 Datoviz 渲染域。 */
void dispatch_events(Extent viewport);
template <Attached Object> [[nodiscard]] Render_Result render(Object* object, Frame_3D* frame);
template <Attached Object> [[nodiscard]] static const Dispatch& dispatch_for();
};
@@ -164,12 +160,6 @@ void Render_Scene_3D::Private::initialize_backend(
backend, object, nullptr, initial,
std::make_shared<detail::Prepared_Visual_Batch>()});
}
inline void Render_Scene_3D::Private::dispatch_events(Extent viewport) {
this->consume_events([&](const std::shared_ptr<Event>& event) {
return backend->dispatch_event(event, viewport) !=
detail::Async_Render_Backend::Dispatch_Event_Result::backend_unavailable;
});
}
template <Attached Object, typename Callback>
void Render_Scene_3D::Private::process(Object* object, Callback&& callback) requires std::invocable<Callback> {
Frame_3D* frame = active_frame;
@@ -178,7 +168,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);
dispatch_events(prop.viewport);
auto events = this->take_events(frame->identity().sequence);
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);
@@ -202,7 +192,7 @@ void Render_Scene_3D::Private::process(Object* object, Callback&& callback) requ
frame->mark(Frame_Trace_Marker::prepare_finished);
frame->mark(Frame_Trace_Marker::paint_started);
const auto result = context->backend->render(
context->visuals, context->parameters, frame);
context->visuals, context->parameters, frame, std::move(events));
frame->mark(Frame_Trace_Marker::paint_finished);
if (result == detail::Async_Render_Backend::Submit_Result::queued)
std::invoke(std::forward<Callback>(callback));
@@ -254,7 +244,8 @@ void Render_Scene_3D::Private::process(Object* object, Callback&& callback) requ
if (context->visuals->empty())
throw std::logic_error("render scene submit graph published no Visual snapshots");
backend_submitted = context->backend->render(
context->visuals, context->parameters, context->frame) ==
context->visuals, context->parameters, context->frame,
std::move(events)) ==
detail::Async_Render_Backend::Submit_Result::queued;
frame->mark(Frame_Trace_Marker::paint_finished);
});
@@ -278,6 +269,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, 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); if (data.backend) data.backend->set_frame_callback(std::move(callback)); }, [](Root* root, std::unique_ptr<Event> event) { auto* object = static_cast<Object*>(root); static_cast<typename Object::Private&>(*object->d).push_event(std::move(event)); }, [](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, 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); if (data.backend) data.backend->set_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>(); }
}
+41 -1
View File
@@ -69,7 +69,35 @@ void Graph_WebSocket::deliver_frame(
}
}
void Graph_WebSocket::deliver_input_observation(
Plot_Input_Observation observation) {
if (!d->attached.load(std::memory_order_acquire)) return;
const auto connection = d->connection.lock();
if (!connection || !connection->connected()) return;
const nlohmann::json message{
{"kind", "input_observation"},
{"protocol", "aethera.input.latency"},
{"version", 1},
{"sequence", observation.sequence},
{"event_type", magic_enum::enum_name(observation.type)},
{"stage", magic_enum::enum_name(observation.stage)},
{"plot_queue_ms", observation.plot_queue_ms},
{"scene_queue_ms", observation.scene_queue_ms},
{"backend_queue_ms", observation.backend_queue_ms},
{"frame_completion_ms", observation.frame_completion_ms},
{"server_total_ms", observation.server_total_ms},
{"target_frame_sequence", observation.target_frame_sequence},
{"rendered_frame_sequence", observation.rendered_frame_sequence},
{"coalesced_event_count", observation.coalesced_event_count}
};
try {
connection->send(message.dump(), drogon::WebSocketMessageType::Text);
}
catch (...) {}
}
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{});
@@ -106,7 +134,13 @@ void Graph_WebSocket::receive(std::string_view message) {
height = d->height;
}
Plot_Input_Event decoded;
decoded.sequence = input->value("sequence", std::uint64_t{});
if (decoded.sequence == 0) return;
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) {
@@ -141,7 +175,13 @@ void Graph_WebSocket::receive(std::string_view message) {
input->value("key", std::string{"unknown"})).value_or(Key::unknown);
decoded.native_key = input->value("native_key", 0U);
decoded.auto_repeat = input->value("auto_repeat", false);
d->plot->submit_input(std::move(decoded));
const auto weak = weak_from_this();
d->plot->submit_input(
std::move(decoded),
[weak](Plot_Input_Observation observation) {
if (const auto socket = weak.lock())
socket->deliver_input_observation(std::move(observation));
});
}
catch (const nlohmann::json::exception&) {}
catch (...) {}
+1
View File
@@ -19,6 +19,7 @@ public:
void close() noexcept;
private:
void deliver_frame(std::shared_ptr<const Plot_Stream_Frame> frame);
void deliver_input_observation(Plot_Input_Observation observation);
struct Private;
std::unique_ptr<Private> d;
};
+117 -23
View File
@@ -32,6 +32,35 @@ using Scene_3D = Impl<Render_Scene_3D>;
constexpr std::uint16_t frame_protocol_version{9};
constexpr std::uint64_t diagnostic_sample_period{100};
double elapsed_milliseconds(std::chrono::steady_clock::time_point start,
std::chrono::steady_clock::time_point finish) {
if (start == std::chrono::steady_clock::time_point{} || finish < start)
return 0.0;
return std::chrono::duration<double, std::milli>(finish - start).count();
}
struct Web_Input_Metadata {
Plot_Input_Event input;
Plot::Input_Handler handler;
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);
@@ -209,7 +238,10 @@ nlohmann::json frame_metadata(Render_Frame& frame, std::uint32_t width,
}
template <typename Scene_Object>
void dispatch_plot_input(Scene_Object& scene, const Plot_Input_Event& input) {
void dispatch_plot_input(Scene_Object& scene, const Plot_Input_Event& input,
Plot::Input_Handler handler) {
Web_Input_Metadata metadata{input, std::move(handler),
std::chrono::steady_clock::now()};
const auto dispatch = [&](auto event) { scene.dispatch_event(std::move(event)); };
const auto apply_pointer = [&](auto& event) {
event.position = input.position;
@@ -222,13 +254,15 @@ 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 = std::make_unique<Basic_Pointer_Event<Point_F>>(input.type);
auto event = scene.template make_event<Observed_Input_Event<Basic_Pointer_Event<Point_F>>>(
std::move(metadata), input.type);
apply_pointer(*event);
dispatch(std::move(event));
break;
}
case Event_Type::wheel: {
auto event = std::make_unique<Basic_Wheel_Event<Point_F>>();
auto event = scene.template make_event<Observed_Input_Event<Basic_Wheel_Event<Point_F>>>(
std::move(metadata));
apply_pointer(*event);
event->pixel_delta_x = input.pixel_delta_x;
event->pixel_delta_y = input.pixel_delta_y;
@@ -239,7 +273,8 @@ void dispatch_plot_input(Scene_Object& scene, const Plot_Input_Event& input) {
}
case Event_Type::key_press:
case Event_Type::key_release: {
auto event = std::make_unique<Key_Event>(input.type);
auto event = scene.template make_event<Observed_Input_Event<Key_Event>>(
std::move(metadata), input.type);
event->key = input.key;
event->native_key = input.native_key;
event->modifiers = input.modifiers;
@@ -248,7 +283,8 @@ void dispatch_plot_input(Scene_Object& scene, const Plot_Input_Event& input) {
break;
}
default:
dispatch(std::make_unique<Event>(input.type));
dispatch(scene.template make_event<Observed_Input_Event<Event>>(
std::move(metadata), input.type));
break;
}
}
@@ -301,6 +337,7 @@ struct Plot::Private {
bool tick_task_scheduled{}; /* Plot strand 上是否已有唯一消费任务。 */
double last_clock_render_time_ms{-std::numeric_limits<double>::infinity()};
std::chrono::steady_clock::time_point clock_origin{std::chrono::steady_clock::now()};
std::vector<aethera::Scene::Event_Report_Pointer> pending_event_reports; /* Scene 已处理、等待目标画面完成的报告。 */
template <typename Scene_Object>
Private(asio::any_io_executor executor, std::unique_ptr<Scene_Object> value_scene,
@@ -322,6 +359,7 @@ struct Plot::Private {
void clock_tick(const Plot_Render_Tick& tick);
void render_frame(Plot_Render_Tick tick);
void queue_completed_frame(Render_Frame* frame);
void complete_event_reports(Frame_Identity rendered_identity);
void fail(std::exception_ptr failure) noexcept;
};
@@ -422,6 +460,12 @@ void Plot::Private::render_frame(Plot_Render_Tick tick) {
if (!pacing.render_enabled || streams.consumers.empty()) return;
{
std::lock_guard lock(frame_mutex);
const auto in_flight = std::ranges::count_if(
frame_slots, [](const Managed_Frame& slot) {
return slot.state == Frame_State::in_flight;
});
if (std::holds_alternative<std::unique_ptr<Scene_3D>>(scene) &&
in_flight >= scene_frame_capacity - 1) return;
if (std::ranges::none_of(frame_slots, [](const Managed_Frame& slot) {
return slot.state == Frame_State::available;
})) return;
@@ -493,6 +537,54 @@ void Plot::Private::render_frame(Plot_Render_Tick tick) {
}
}
void Plot::Private::complete_event_reports(Frame_Identity rendered_identity) {
aethera::Scene::Event_Report_Batch reports = std::visit(
[](auto& value) { return value->take_event_reports(); }, scene);
pending_event_reports.insert(pending_event_reports.end(),
std::make_move_iterator(reports.begin()),
std::make_move_iterator(reports.end()));
const auto now = std::chrono::steady_clock::now();
const auto first_complete = std::stable_partition(
pending_event_reports.begin(), pending_event_reports.end(),
[&](const aethera::Scene::Event_Report_Pointer& event) {
const auto timing = event->dispatch_timing();
return timing.completed_steady_ns == 0 ||
timing.frame_sequence > rendered_identity.sequence;
});
for (auto iterator = first_complete;
iterator != pending_event_reports.end(); ++iterator) {
const auto* web_event = dynamic_cast<const Web_Input_Event*>(iterator->get());
if (!web_event) continue;
const auto& metadata = web_event->web_input_metadata();
const auto timing = (*iterator)->dispatch_timing();
const auto started = std::chrono::steady_clock::time_point{
std::chrono::nanoseconds(timing.started_steady_ns)};
const auto completed = std::chrono::steady_clock::time_point{
std::chrono::nanoseconds(timing.completed_steady_ns)};
Plot_Input_Observation observation{};
observation.sequence = metadata.input.sequence;
observation.type = metadata.input.type;
observation.stage = Plot_Input_Stage::frame_completed;
observation.plot_queue_ms = elapsed_milliseconds(
metadata.input.received_time, metadata.scene_dispatched);
observation.scene_queue_ms = elapsed_milliseconds(
metadata.scene_dispatched, started);
observation.backend_queue_ms = elapsed_milliseconds(started, completed);
observation.frame_completion_ms = elapsed_milliseconds(completed, now);
observation.server_total_ms = elapsed_milliseconds(
metadata.input.received_time, now);
observation.target_frame_sequence = timing.frame_sequence;
observation.rendered_frame_sequence = rendered_identity.sequence;
observation.coalesced_event_count =
metadata.input.coalesced_event_count;
try {
if (metadata.handler) metadata.handler(std::move(observation));
}
catch (...) {}
}
pending_event_reports.erase(first_complete, pending_event_reports.end());
}
void Plot::Private::queue_completed_frame(Render_Frame* frame) {
if (!frame)
throw std::invalid_argument("Plot received a null completed frame");
@@ -574,6 +666,8 @@ void Plot::Private::queue_completed_frame(Render_Frame* frame) {
pixel_storage = frame_3d->share_pixels();
}
complete_event_reports(rendered_identity);
auto pixels = std::make_shared<const Plot_Pixel_Frame>(Plot_Pixel_Frame{
std::move(pixel_storage), managed->presentation_time,
identity.sequence, identity.correlation_id,
@@ -734,26 +828,26 @@ void Plot::render_once() {
});
}
void Plot::submit_input(Plot_Input_Event event) {
void Plot::submit_input(Plot_Input_Event event, Input_Handler handler) {
ensure_started();
if (d->terminal_failure.load(std::memory_order_acquire)) return;
const auto weak = weak_from_this();
asio::post(d->strand, [weak, event = std::move(event)] {
const auto owner = weak.lock();
if (!owner || owner->d->terminal_failure.load(std::memory_order_acquire))
return;
try {
if (auto* scene = std::get_if<std::unique_ptr<Scene_2D>>(
&owner->d->scene))
dispatch_plot_input(**scene, event);
else
dispatch_plot_input(
*std::get<std::unique_ptr<Scene_3D>>(owner->d->scene), event);
}
catch (...) {
owner->d->fail(std::current_exception());
}
});
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 回调。
*/
try {
if (auto* scene_2d = std::get_if<std::unique_ptr<Scene_2D>>(&d->scene))
dispatch_plot_input(**scene_2d, event, std::move(handler));
else
dispatch_plot_input(*std::get<std::unique_ptr<Scene_3D>>(d->scene),
event, std::move(handler));
}
catch (...) {
d->fail(std::current_exception());
}
}
void Plot::async_schema(Json_Handler handler) {
+36 -14
View File
@@ -14,19 +14,40 @@
namespace aethera::web {
struct Plot_Input_Event {
Event_Type type{Event_Type::pointer_move};
render_2d::Point_F position{};
render_2d::Point_F global_position{};
Mouse_Button button{Mouse_Button::none};
Mouse_Button_Mask buttons{};
Keyboard_Modifier modifiers{Keyboard_Modifier::none};
double pixel_delta_x{};
double pixel_delta_y{};
double angle_delta_x{};
double angle_delta_y{};
Key key{Key::unknown};
std::uint32_t native_key{};
bool auto_repeat{};
std::size_t coalesced_event_count{1}; /* 浏览器显示周期内合并的原始输入数量。 */
std::uint64_t sequence{}; /* 页面为本次原始输入分配的单调身份。 */
Event_Type type{Event_Type::pointer_move}; /* 输入事件业务类型。 */
render_2d::Point_F position{}; /* Plot 像素坐标。 */
render_2d::Point_F global_position{}; /* 浏览器屏幕像素坐标。 */
Mouse_Button button{Mouse_Button::none}; /* 本次变化涉及的鼠标按键。 */
Mouse_Button_Mask buttons{}; /* 事件产生时保持按下的按键集合。 */
Keyboard_Modifier modifiers{Keyboard_Modifier::none}; /* 事件产生时的修饰键集合。 */
double pixel_delta_x{}; /* 水平高精度滚轮增量。 */
double pixel_delta_y{}; /* 垂直高精度滚轮增量。 */
double angle_delta_x{}; /* 水平离散滚轮增量。 */
double angle_delta_y{}; /* 垂直离散滚轮增量。 */
Key key{Key::unknown}; /* 标准化键盘按键。 */
std::uint32_t native_key{}; /* 浏览器原生按键码。 */
bool auto_repeat{}; /* 是否为系统重复按键。 */
std::chrono::steady_clock::time_point received_time{}; /* WebSocket 完成解析的服务端单调时刻。 */
};
enum class Plot_Input_Stage : std::uint8_t {
frame_completed
};
struct Plot_Input_Observation {
std::uint64_t sequence{}; /* 对应页面原始输入身份。 */
Event_Type type{Event_Type::pointer_move}; /* 对应输入事件业务类型。 */
Plot_Input_Stage stage{Plot_Input_Stage::frame_completed}; /* 对外只发布完整画面闭环。 */
double plot_queue_ms{}; /* WebSocket 接收到 Scene 接收的等待。 */
double scene_queue_ms{}; /* Scene 接收事件到下一次 Render 消费事件的等待。 */
double backend_queue_ms{}; /* 事件进入渲染后端到实际应用的等待。 */
double frame_completion_ms{}; /* 后端应用事件到包含该状态的画面完成的等待。 */
double server_total_ms{}; /* WebSocket 接收到画面完成或当前阶段的总耗时。 */
std::uint64_t target_frame_sequence{}; /* 首个应包含本事件状态的逻辑帧。 */
std::uint64_t rendered_frame_sequence{}; /* 实际包含本事件状态的物理画面帧。 */
std::size_t coalesced_event_count{}; /* 合并到本事件的连续输入数量,包含自身。 */
};
struct Plot_Render_Tick {
@@ -57,6 +78,7 @@ public:
using Stream_Id = std::uint64_t;
using Stream_Handler = std::function<void(std::shared_ptr<const Plot_Stream_Frame>)>;
using Json_Handler = std::function<void(nlohmann::json)>;
using Input_Handler = std::function<void(Plot_Input_Observation)>;
class Scene_View {
public:
@@ -85,7 +107,7 @@ public:
void configure_stream(Stream_Id stream, std::uint32_t width, std::uint32_t height);
void schedule_render(Plot_Render_Tick tick);
void render_once();
void submit_input(Plot_Input_Event event);
void submit_input(Plot_Input_Event event, Input_Handler handler);
void async_schema(Json_Handler handler);
void async_write_prop(std::string component, std::string key,
nlohmann::json value, Json_Handler handler);
+113 -8
View File
@@ -60,6 +60,11 @@ type Frame_Diagnostics = {metadata: Frame_Metadata; samples: Frame_Sample[]; fra
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};
type Input_Stage = "queued" | "scene_dispatched" | "backend_queued" | "backend_applied" | "frame_completed";
type Input_Observation = {kind: "input_observation"; protocol: "aethera.input.latency"; version: 1; sequence: number;
event_type: string; stage: Input_Stage; plot_queue_ms: number; scene_queue_ms: number; backend_queue_ms: number; frame_completion_ms: number;
server_total_ms: number; target_frame_sequence: number; rendered_frame_sequence: number; coalesced_event_count: number};
type Input_Latency = Input_Observation & {browser_created_time_ms: number; browser_observation_ms: number};
type Stage_Statistic = "average" | "variability" | "p95" | "p99";
type Stage_Unit = "value" | "percentage";
@@ -100,6 +105,14 @@ function valid_frame_metadata(value: unknown): value is Frame_Metadata {
&& Boolean(frame.trace);
}
function valid_input_observation(value: unknown): value is Input_Observation {
if (!value || typeof value !== "object") return false;
const observation = value as Partial<Input_Observation>;
return observation.kind === "input_observation" && observation.protocol === "aethera.input.latency" &&
observation.version === 1 && typeof observation.sequence === "number" &&
typeof observation.server_total_ms === "number" && typeof observation.stage === "string";
}
function valid_gallery_layout(value: unknown): value is Gallery_Layout {
if (!value || typeof value !== "object") return false;
const layout = value as Partial<Gallery_Layout>;
@@ -442,10 +455,13 @@ function use_plot_stream(plot: Plot,
tile_width = 720, tile_height = 420) {
const [status, set_status] = useState<Stream_Status>("CONNECTING");
const [metrics, set_metrics] = useState<Frame_Metrics | null>(null);
const [input_latency, set_input_latency] = useState<Input_Latency | 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 next_input_sequence = useRef(1);
const input_origins = useRef(new Map<number, number>());
const viewport_ref = useRef({width: tile_width, height: tile_height});
const playback_ref = useRef(shared_playback);
useEffect(() => { playback_ref.current = shared_playback; }, [shared_playback]);
@@ -454,8 +470,10 @@ function use_plot_stream(plot: Plot,
const transmit = useCallback((kind: "input", event?: Record<string, unknown>) => {
const socket = socket_ref.current;
if (socket?.readyState !== WebSocket.OPEN) return;
const sequence = next_input_sequence.current++;
input_origins.current.set(sequence, performance.now());
socket.send(JSON.stringify({kind, viewport: viewport_ref.current,
...(event ? {event} : {})}));
...(event ? {event: {...event, sequence}} : {})}));
}, []);
useEffect(() => {
@@ -575,6 +593,17 @@ function use_plot_stream(plot: Plot,
complete_metadata(decoded);
return;
}
if (valid_input_observation(decoded)) {
const created = input_origins.current.get(decoded.sequence);
if (created !== undefined) {
const now = performance.now();
set_input_latency({...decoded, browser_created_time_ms: created,
browser_observation_ms: Math.max(0, now - created)});
if (decoded.stage === "frame_completed")
input_origins.current.delete(decoded.sequence);
}
return;
}
const message = decoded as {kind?: string; message?: string};
if (message?.kind === "plot_error") {
stopped = true;
@@ -597,6 +626,7 @@ function use_plot_stream(plot: Plot,
window.removeEventListener("aethera-reset-frame-diagnostics", on_diagnostics_reset);
if (pointer_frame.current) cancelAnimationFrame(pointer_frame.current);
pointer_frame.current = 0;
input_origins.current.clear();
socket_ref.current = null;
socket.close();
};
@@ -625,9 +655,11 @@ 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)
buttons: event.buttons, modifiers: modifiers(event), coalesced_event_count: 1
});
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);
@@ -636,7 +668,11 @@ function use_plot_stream(plot: Plot,
transmit("input", payload);
return;
}
pending_pointer_move.current = payload;
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;
@@ -682,12 +718,28 @@ function use_plot_stream(plot: Plot,
event.stopPropagation();
const scale = event.deltaMode === WheelEvent.DOM_DELTA_LINE ? 16
: event.deltaMode === WheelEvent.DOM_DELTA_PAGE ? surface.clientHeight : 1;
transmit("input", {type: "wheel", position: point(event),
const next = {type: "wheel", position: point(event),
global_position: global_point(event), button: "none", buttons: 0,
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))});
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;
});
};
const key_name = (key: string) => ({Escape: "escape", Enter: "enter",
" ": "space", Delete: "delete_key", Backspace: "backspace",
@@ -721,6 +773,9 @@ function use_plot_stream(plot: Plot,
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);
@@ -734,7 +789,7 @@ function use_plot_stream(plot: Plot,
surface.removeEventListener("contextmenu", on_context);
};
}, [surface_ref, transmit]);
return {status, metrics, error};
return {status, metrics, input_latency, error};
}
const member_labels: Record<string, string> = {x: "横坐标", y: "纵坐标", z: "深度", width: "宽度", height: "高度", origin: "起点", target: "终点",
@@ -1201,6 +1256,7 @@ const Plot_Card = memo(function Plot_Card({plot, selected, policy, gallery, on_p
const tile_width = gallery.layout?.tile_width ?? 720;
const tile_height = gallery.layout?.tile_height ?? 420;
const graph = use_plot_stream(plot, surface_ref, gallery.playback, tile_width, tile_height);
const [input_visible_latency, set_input_visible_latency] = useState<number | null>(null);
const status: Stream_Status = graph.status === "OFFLINE" || gallery.status === "OFFLINE" ? "OFFLINE"
: graph.status === "LIVE" && gallery.status === "LIVE" ? "LIVE" : "CONNECTING";
const metrics = graph.metrics;
@@ -1214,6 +1270,17 @@ const Plot_Card = memo(function Plot_Card({plot, selected, policy, gallery, on_p
video.srcObject = gallery.stream;
if (gallery.stream) void video.play();
}, [gallery.stream]);
useEffect(() => {
const input = graph.input_latency;
const video = video_ref.current;
if (!input || input.stage !== "frame_completed" || !video ||
typeof video.requestVideoFrameCallback !== "function") return;
const callback = video.requestVideoFrameCallback(() => {
set_input_visible_latency(Math.max(
0, performance.now() - input.browser_created_time_ms));
});
return () => video.cancelVideoFrameCallback(callback);
}, [graph.input_latency]);
const atlas_style: React.CSSProperties = gallery.layout && tile ? {
width: `${gallery.layout.columns * 100}%`,
height: `${gallery.layout.rows * 100}%`,
@@ -1221,8 +1288,46 @@ const Plot_Card = memo(function Plot_Card({plot, selected, policy, gallery, on_p
top: `${-tile.row * 100}%`
} : {};
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} aria-current={selected ? "true" : undefined}
onClick={() => on_select(plot)}><header className="cardDragHandle"><div><span className="eyebrow"> · {plot.dimension}</span><h2>{plot_labels[plot.id] ?? plot.title}</h2></div><div className="cardRuntime" title={`帧序号 ${metrics?.sequence ?? 0} · 创建于 ${generated_time}`}><div><span className="status">{{IDLE: "已停止", CONNECTING: "重连中", LIVE: "实时", OFFLINE: "已离线"}[status]}</span><span className="framePolicy">{metrics?.delivery === "diagnostics" ? "无像素传输" : metrics ? enum_label(metrics.pacing_mode) : "等待策略"}</span></div><div className="frameMetrics"><span> {source_transport ? source_transport.rendered_frame_rate_fps.toFixed(1) : metrics ? metrics.frame_rate_fps.toFixed(1) : "--.-"} FPS</span><span> {source_transport ? source_transport.logical_completion_rate_fps.toFixed(1) : "--.-"} FPS</span><span> {gallery.transport ? gallery.transport.encoded_frame_rate_fps.toFixed(1) : "--.-"} FPS</span><span> {gallery.playback.frame_rate_fps.toFixed(1)} FPS</span><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></div></div></header>
return <article ref={card_ref} className={`card${selected ? " selected" : ""}`} data-plot-id={plot.id}
data-input-stage={graph.input_latency?.stage ?? "none"} aria-current={selected ? "true" : undefined}
data-input-plot-queue-ms={graph.input_latency?.plot_queue_ms ?? ""}
data-input-scene-queue-ms={graph.input_latency?.scene_queue_ms ?? ""}
data-input-backend-queue-ms={graph.input_latency?.backend_queue_ms ?? ""}
data-input-server-total-ms={graph.input_latency?.server_total_ms ?? ""}
data-input-browser-total-ms={graph.input_latency?.browser_observation_ms ?? ""}
data-input-visible-ms={input_visible_latency ?? ""}
onClick={() => on_select(plot)}>
<header className="cardDragHandle">
<div><span className="eyebrow"> · {plot.dimension}</span><h2>{plot_labels[plot.id] ?? plot.title}</h2></div>
<div className="cardRuntime" title={`帧序号 ${metrics?.sequence ?? 0} · 创建于 ${generated_time}`}>
<div><span className="status">{{IDLE: "已停止", CONNECTING: "重连中", LIVE: "实时", OFFLINE: "已离线"}[status]}</span><span className="framePolicy">{metrics?.delivery === "diagnostics" ? "无像素传输" : metrics ? enum_label(metrics.pacing_mode) : "等待策略"}</span></div>
<div className="frameMetrics">
<span> {source_transport ? source_transport.rendered_frame_rate_fps.toFixed(1) : metrics ? metrics.frame_rate_fps.toFixed(1) : "--.-"} FPS</span>
<span> {source_transport ? source_transport.logical_completion_rate_fps.toFixed(1) : "--.-"} FPS</span>
<span> {gallery.transport ? gallery.transport.encoded_frame_rate_fps.toFixed(1) : "--.-"} FPS</span>
<span> {gallery.playback.frame_rate_fps.toFixed(1)} FPS</span>
<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="WebSocket 解码后直接转移到 Kernel Scene concurrentqueue 的耗时"> {graph.input_latency ? graph.input_latency.plot_queue_ms.toFixed(1) : "--.-"} ms</span>
<span title="事件进入 Scene 后等待下一次正常 Render 在 Prepare 前消费的时间">Scene {graph.input_latency ? graph.input_latency.scene_queue_ms.toFixed(1) : "--.-"} ms</span>
<span title="Scene 消费事件后等待 Datoviz 控制器实际应用的时间"> {graph.input_latency ? graph.input_latency.backend_queue_ms.toFixed(1) : "--.-"} ms</span>
<span title="从浏览器原始事件产生到服务端确认对应画面完成"> {graph.input_latency ? graph.input_latency.browser_observation_ms.toFixed(1) : "--.-"} ms</span>
<span title="从浏览器原始事件产生到下一张视频画面实际呈现"> {input_visible_latency !== null ? input_visible_latency.toFixed(1) : "--.-"} ms</span>
<span title="浏览器显示周期内合并的连续输入数量"> {graph.input_latency?.coalesced_event_count ?? 0}</span>
</> : null}
</div>
</div>
</header>
{plot.dimension === "2D" ? <div className="frameMetrics interactionMetrics">
<span title="WebSocket 解码后直接转移到 Kernel Scene concurrentqueue 的耗时"> {graph.input_latency ? graph.input_latency.plot_queue_ms.toFixed(1) : "--.-"} ms</span>
<span title="事件进入 Scene 后等待下一次正常 Render 在 Prepare 前消费的时间">Scene {graph.input_latency ? graph.input_latency.scene_queue_ms.toFixed(1) : "--.-"} ms</span>
<span title="Scene 内完成矩形选择、轴交互等事件路由的时间"> {graph.input_latency ? graph.input_latency.backend_queue_ms.toFixed(1) : "--.-"} ms</span>
<span title="浏览器原始事件到对应画面服务端完成"> {graph.input_latency ? graph.input_latency.browser_observation_ms.toFixed(1) : "--.-"} ms</span>
<span title="浏览器原始事件到视频画面实际呈现"> {input_visible_latency !== null ? input_visible_latency.toFixed(1) : "--.-"} ms</span>
<span title="浏览器显示周期内合并的连续输入数量"> {graph.input_latency?.coalesced_event_count ?? 0}</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>
</div>