From a8f102bdb15f2af9102fb2a457d403f7a687b3ec Mon Sep 17 00:00:00 2001 From: wyc <1104749580@qq.com> Date: Sat, 22 Aug 2026 00:57:37 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E7=BE=8E=E4=B8=80=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Project_detail_specification.md | 4 +- kernel/src/kernel/frame.cpp | 57 ++ kernel/src/kernel/frame.hpp | 69 +++ kernel/src/kernel/render_common.hpp | 1 + kernel/src/kernel/scene.hpp | 1 + kernel/src/kernel/scene.ipp | 9 + render_2D/render_2D/base/Frame_2D.cpp | 14 + render_2D/render_2D/base/Frame_2D.hpp | 22 + .../plottable/Selection_Rectangle_Overlay.ipp | 10 +- .../render_2D/plottable/Sweep_Spectrum.hpp | 11 +- .../render_2D/plottable/Sweep_Spectrum.ipp | 40 +- render_2D/render_2D/scene/Render_Scene_2D.cpp | 5 +- render_2D/render_2D/scene/Render_Scene_2D.hpp | 16 +- render_2D/render_2D/scene/Render_Scene_2D.ipp | 83 +-- render_2D/tests/Axis_Test.cpp | 26 +- render_2D/tests/Plottable_Migration_Test.cpp | 42 +- render_2D/tests/Spectrum_Test.cpp | 55 +- render_3D/DATOVIZ_MIGRATION_AUDIT.md | 4 +- render_3D/render_3D/base/Frame_3D.cpp | 17 + render_3D/render_3D/base/Frame_3D.hpp | 25 + render_3D/render_3D/base/Types.hpp | 6 - .../render_3D/detail/Async_Render_Backend.cpp | 111 +++- .../render_3D/detail/Async_Render_Backend.hpp | 7 +- .../detail/Datoviz_Visual_Backend.cpp | 6 +- .../detail/Datoviz_Visual_Backend.hpp | 5 +- render_3D/render_3D/scene/Render_Scene_3D.cpp | 2 +- render_3D/render_3D/scene/Render_Scene_3D.hpp | 12 +- render_3D/render_3D/scene/Render_Scene_3D.ipp | 79 ++- render_3D/render_3D/visual/Basic_Visual.ipp | 8 +- render_3D/tests/Visual_Tests.cpp | 25 +- web_server/src/Graph_WebSocket.cpp | 21 +- web_server/src/Plot.cpp | 360 ++++++++---- web_server/src/Plot.hpp | 22 +- web_server/src/Renderable_Adapter.ipp | 2 + webapp_gallery/src/app.tsx | 526 +++++++++++++++--- webapp_gallery/src/styles.css | 25 + 36 files changed, 1374 insertions(+), 354 deletions(-) create mode 100644 kernel/src/kernel/frame.cpp create mode 100644 kernel/src/kernel/frame.hpp create mode 100644 render_2D/render_2D/base/Frame_2D.cpp create mode 100644 render_2D/render_2D/base/Frame_2D.hpp create mode 100644 render_3D/render_3D/base/Frame_3D.cpp create mode 100644 render_3D/render_3D/base/Frame_3D.hpp diff --git a/Project_detail_specification.md b/Project_detail_specification.md index e427d95..5da0cb9 100644 --- a/Project_detail_specification.md +++ b/Project_detail_specification.md @@ -16,7 +16,9 @@ * 依赖可以选择 Prop/State 的单字段或整个 `Base_Tag` 层;字段写入必须同时发出字段级和所属层级变更,使用方按实际重建粒度选择一种依赖。 * 整体对象依赖只表达依赖图中的拓扑顺序,不传播 dirty;准备顺序、绘图顺序等业务含义由各自 Tag 解释。具体字段依赖才用于对应 Tag 的 dirty 传播,例如绘图缓存失效。 * Kernel `Scene` 只负责 2D/3D 共有的 Prepare 数据阶段;Paint、缓存失效、像素合成和异步后端提交由对应渲染模块自己的 Scene、Tag 与 Taskflow 负责。 -* `Scene::Private` 是输入事件流的唯一所有者:外部转移事件对象所有权,无锁提交到双缓冲队列,Prepare 入口交换并按 FIFO 消费,不复制事件快照。2D 按 Renderable 区域与 Paint 顺序形成接受链,区域默认整个 viewport;3D 无等待提交渲染域,满载时保留当前事件供下一次 Prepare 重试。 +* `Scene::Private` 是输入事件流的唯一所有者:外部转移事件对象所有权,无锁提交到双缓冲队列,事件不得独立触发帧,只在下一次正常渲染的 Prepare 入口交换并按 FIFO 消费。2D 按 Renderable 区域与 Paint 顺序形成接受链,区域默认整个 viewport;3D 无等待提交渲染域,满载时保留当前事件供下一次 Prepare 重试。 +* 帧由 Scene 外部创建和持有;每次 `render(frame*)` 只借用该帧并在完成回调返回同一地址。Scene、异步后端和 Web 层只向帧写入固定语义的单调时间点与原始耗时,不保存平均值、分位数或波动等衍生统计。 +* WebSocket 每帧先发送诊断 JSON,再发送纯 RGBA 二进制;浏览器按请求关联 ID 配对并计算滑动平均、P50/P95/P99、帧率、帧间隔抖动和序号缺口。帧策略只调节后继 `render` 请求频率,包含手动、固定频率、最低延迟和最高频率;输入事件不得触发帧。 * `Time_Axis` 是时间与 tick 的唯一权威来源;使用层先推进时间轴,再把同一 tick 分发给所有相关数据图元。时间窗口从第一条数据起始终锚定最新 tick,未产生数据的槽位保持背景。 * 图表选区保存两根轴上的数据范围,绘制时才映射为像素;选区作为独立 Renderable 在使用层与图元组合,禁止在各图元内复制选区状态。 * `Root` 只保存一个最终 `Private` 指针;`Builder::build()` 校验成功后创建并挂接完整 Private,`Root` 通过公共 Private 基类的虚析构统一释放。禁止直接公开该指针。 diff --git a/kernel/src/kernel/frame.cpp b/kernel/src/kernel/frame.cpp new file mode 100644 index 0000000..85baae5 --- /dev/null +++ b/kernel/src/kernel/frame.cpp @@ -0,0 +1,57 @@ +#include "frame.hpp" +#include +#include +#include +#include +#include +namespace aethera { +namespace { +constexpr std::size_t marker_count = static_cast(Frame_Trace_Marker::count); +constexpr std::size_t measurement_count = static_cast(Frame_Trace_Measurement::count); +std::uint64_t encode_present_value(std::uint64_t value) noexcept { return value == std::numeric_limits::max() ? value : value + 1; } +std::uint64_t decode_present_value(std::uint64_t value) noexcept { return value == std::numeric_limits::max() ? value : value - 1; } +} +struct Render_Frame::Private { + Frame_Identity identity{}; /* 外部帧管理器提供且终生不变的帧身份。 */ + std::chrono::steady_clock::time_point created_at{}; /* 所有 elapsed_ns 使用的单调时钟原点。 */ + std::uint64_t created_time_unix_ns{}; /* 用于跨进程展示的创建 Unix 时间,单位为纳秒。 */ + std::array markers{}; /* 每种时间点首次出现时的 elapsed_ns 加一编码。 */ + std::array measurements{}; /* 每种原始耗时首次记录值的加一编码。 */ +}; +Render_Frame::Render_Frame(Frame_Identity identity) : d(std::make_unique()) { + d->identity = identity; + d->created_at = std::chrono::steady_clock::now(); + const auto system_now = std::chrono::system_clock::now().time_since_epoch(); + d->created_time_unix_ns = static_cast(std::chrono::duration_cast(system_now).count()); + d->markers[static_cast(Frame_Trace_Marker::created)].store(encode_present_value(0), std::memory_order_relaxed); +} +Render_Frame::~Render_Frame() = default; +Frame_Identity Render_Frame::identity() const noexcept { return d->identity; } +std::uint64_t Render_Frame::created_time_unix_ns() const noexcept { return d->created_time_unix_ns; } +void Render_Frame::mark(Frame_Trace_Marker marker) noexcept { + const auto index = static_cast(marker); + if (index >= marker_count) return; + const auto elapsed = std::chrono::steady_clock::now() - d->created_at; + const auto elapsed_ns = static_cast(std::max(0, std::chrono::duration_cast(elapsed).count())); + std::uint64_t expected{}; + d->markers[index].compare_exchange_strong(expected, encode_present_value(elapsed_ns), std::memory_order_release, std::memory_order_relaxed); +} +void Render_Frame::record(Frame_Trace_Measurement measurement, std::uint64_t value_ns) noexcept { + const auto index = static_cast(measurement); + if (index >= measurement_count) return; + std::uint64_t expected{}; + d->measurements[index].compare_exchange_strong(expected, encode_present_value(value_ns), std::memory_order_release, std::memory_order_relaxed); +} +std::vector Render_Frame::trace_points() const { + std::vector result; + result.reserve(marker_count); + for (std::size_t index = 0; index < marker_count; ++index) if (const auto encoded = d->markers[index].load(std::memory_order_acquire)) result.push_back({static_cast(index), decode_present_value(encoded)}); + return result; +} +std::vector Render_Frame::trace_values() const { + std::vector result; + result.reserve(measurement_count); + for (std::size_t index = 0; index < measurement_count; ++index) if (const auto encoded = d->measurements[index].load(std::memory_order_acquire)) result.push_back({static_cast(index), decode_present_value(encoded)}); + return result; +} +} diff --git a/kernel/src/kernel/frame.hpp b/kernel/src/kernel/frame.hpp new file mode 100644 index 0000000..6c5b87a --- /dev/null +++ b/kernel/src/kernel/frame.hpp @@ -0,0 +1,69 @@ +#pragma once +#include "double_buffer/mechanism.hpp" +#include +#include +#include +namespace aethera { +enum class Frame_Trace_Marker : std::uint8_t { + created, + scene_render_requested, + scene_render_started, + event_dispatch_started, + event_dispatch_finished, + prepare_started, + prepare_finished, + paint_started, + paint_finished, + backend_queue_entered, + backend_queue_left, + gpu_submitted, + gpu_completed, + readback_started, + readback_finished, + scene_render_finished, + callback_started, + callback_finished, + websocket_publish_started, + websocket_publish_finished, + count +}; +enum class Frame_Trace_Measurement : std::uint8_t { + backend_apply_ns, + backend_plan_ns, + backend_execute_ns, + backend_submit_ns, + gpu_fence_wait_ns, + gpu_render_ns, + gpu_transition_ns, + gpu_copy_ns, + gpu_total_ns, + readback_ns, + count +}; +struct Frame_Identity { + std::uint64_t sequence{}; /* 外部帧管理器分配的单调帧序号。 */ + std::uint64_t correlation_id{}; /* 与调用方请求关联的标识;零值表示未关联。 */ +}; +struct Frame_Trace_Point { + Frame_Trace_Marker marker{}; /* 本时间点在端到端帧流水线中的固定语义。 */ + std::uint64_t elapsed_ns{}; /* 相对 Frame 创建时刻的单调时钟偏移,单位为纳秒。 */ +}; +struct Frame_Trace_Value { + Frame_Trace_Measurement measurement{}; /* 无法表达为公共时间点的原始后端测量类型。 */ + std::uint64_t value_ns{}; /* 后端直接记录的持续时间,单位为纳秒。 */ +}; +class Render_Frame : public double_buffer::Pinned { +public: + explicit Render_Frame(Frame_Identity identity); + virtual ~Render_Frame(); + [[nodiscard]] Frame_Identity identity() const noexcept; + [[nodiscard]] std::uint64_t created_time_unix_ns() const noexcept; + void mark(Frame_Trace_Marker marker) noexcept; + void record(Frame_Trace_Measurement measurement, std::uint64_t value_ns) noexcept; + [[nodiscard]] std::vector trace_points() const; + [[nodiscard]] std::vector trace_values() const; +private: + struct Private; + std::unique_ptr d; /* 帧身份、时钟原点与原子诊断槽位的唯一所有权。 */ +}; +} diff --git a/kernel/src/kernel/render_common.hpp b/kernel/src/kernel/render_common.hpp index 48d28d8..e352866 100644 --- a/kernel/src/kernel/render_common.hpp +++ b/kernel/src/kernel/render_common.hpp @@ -1,5 +1,6 @@ #pragma once #include "double_buffer/model.hpp" +#include "frame.hpp" #include #include #include diff --git a/kernel/src/kernel/scene.hpp b/kernel/src/kernel/scene.hpp index c5acd5e..9e0470c 100644 --- a/kernel/src/kernel/scene.hpp +++ b/kernel/src/kernel/scene.hpp @@ -1,4 +1,5 @@ #pragma once +#include "frame.hpp" #include "renderable.hpp" namespace aethera { /* Scene 状态标签,用于访问和订阅 Scene::State。 */ diff --git a/kernel/src/kernel/scene.ipp b/kernel/src/kernel/scene.ipp index 1186311..b3bd8ed 100644 --- a/kernel/src/kernel/scene.ipp +++ b/kernel/src/kernel/scene.ipp @@ -44,6 +44,9 @@ struct Scene::Private : Prev_Private { /* Impl CRTP 入口:同步执行当前总 Taskflow;派生 Private 只有在替换完整 Scene 处理语义时才应覆盖。 */ template void process(Object* object, Callback&& callback) requires std::invocable; + /* 派生渲染 Scene 使用:把调用方外部帧贯穿公共 Prepare 阶段并追加诊断标记。 */ + template + void process(Object* object, Render_Frame* frame, Callback&& callback) requires std::invocable; /* 派生 Scene 的 Private 还可覆盖 Def::Private 的四个 State 生命周期 hook,并通过 State_Access::get() 访问状态层。 */ }; struct Scene::Private::Runtime { @@ -68,10 +71,16 @@ void Scene::Private::consume_events(Callback&& callback) requires std::predicate } template void Scene::Private::process(Object* object, Callback&& callback) requires std::invocable { + process(object, static_cast(nullptr), std::forward(callback)); +} +template +void Scene::Private::process(Object* object, Render_Frame* frame, Callback&& callback) requires std::invocable { auto& private_data = static_cast(*this); auto& state = static_cast(*private_data.state.current); state.taskflow_execution_time_ns = 0; + 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); object->template notify_state(); Result result; std::invoke(std::forward(callback), std::as_const(result)); diff --git a/render_2D/render_2D/base/Frame_2D.cpp b/render_2D/render_2D/base/Frame_2D.cpp new file mode 100644 index 0000000..e122bbc --- /dev/null +++ b/render_2D/render_2D/base/Frame_2D.cpp @@ -0,0 +1,14 @@ +#include "Frame_2D.hpp" +#include +namespace aethera::render_2d { +struct Frame_2D::Private { + Blend2D_Cache color{}; /* Scene 直接合成且由外部 Frame 生命周期持有的最终颜色层。 */ +}; +Frame_2D::Frame_2D(Frame_Identity identity) : Render_Frame(identity), d(std::make_unique()) {} +Frame_2D::~Frame_2D() = default; +Image_View Frame_2D::image() const { return d->color.view(); } +Blend2D_Cache& detail::Frame_2D_Access::render_target(Frame_2D* frame) { + if (!frame) throw std::invalid_argument("Render_Scene_2D requires a non-null external frame"); + return frame->d->color; +} +} diff --git a/render_2D/render_2D/base/Frame_2D.hpp b/render_2D/render_2D/base/Frame_2D.hpp new file mode 100644 index 0000000..de6561c --- /dev/null +++ b/render_2D/render_2D/base/Frame_2D.hpp @@ -0,0 +1,22 @@ +#pragma once +#include "../render/Blend2D_Cache.hpp" +#include +#include +namespace aethera::render_2d { +class Frame_2D; +namespace detail { +struct Frame_2D_Access { + [[nodiscard]] static Blend2D_Cache& render_target(Frame_2D* frame); +}; +} +class Frame_2D final : public Render_Frame { +public: + explicit Frame_2D(Frame_Identity identity); + ~Frame_2D() override; + [[nodiscard]] Image_View image() const; +private: + struct Private; + std::unique_ptr d; /* 本帧最终二维颜色结果的唯一所有权。 */ + friend struct detail::Frame_2D_Access; +}; +} diff --git a/render_2D/render_2D/plottable/Selection_Rectangle_Overlay.ipp b/render_2D/render_2D/plottable/Selection_Rectangle_Overlay.ipp index 42d8c06..e8fbb08 100644 --- a/render_2D/render_2D/plottable/Selection_Rectangle_Overlay.ipp +++ b/render_2D/render_2D/plottable/Selection_Rectangle_Overlay.ipp @@ -20,12 +20,14 @@ struct Selection_Rectangle_Overlay::Private : Prev_Private { Point_F press_position{}; /* 仅用于区分点击与拖动的按下像素位置。 */ bool dragging{}; /* 是否正在构造尚未提交的选择矩形。 */ const Dispatch* dispatch{}; /* 最终类型公开薄壳分派表。 */ + static constexpr double minimum_drag_distance_pixels{3.0}; /* 允许提交和绘制选择框的最小像素距离。 */ /* CRTP 覆盖:绑定 Paint-only、事件能力和最终 Overlay 分派表。 */ template void bind_private_crtp(Object* object); void bind_sources(Abs_Axis* horizontal_axis_value, Abs_Axis* vertical_axis_value); template [[nodiscard]] static const Dispatch& dispatch_for(); [[nodiscard]] static Axis_Rectangle axis_rectangle(Axis_Point first, Axis_Point second); [[nodiscard]] static std::string range_label(const Abs_Axis* axis, Axis_Range range); + [[nodiscard]] bool has_visible_drag() const; /* CRTP 覆盖:无需 Prepare 子图,直接绘制选择矩形。 */ template void paint(Object* object); /* CRTP 覆盖:处理拖拽并更新权威选择区域状态。 */ @@ -81,7 +83,11 @@ void Selection_Rectangle_Overlay::Private::paint(Object* object) { painter.text({label_position.x, label_position.y + std::max(12.0, state.label_font.size * 1.35)}, "Y " + range_label(vertical_axis, region.vertical), state.label_font, state.label_pen); }; for (const auto& region : state.selected_regions) paint_region(region); - if (dragging) paint_region(axis_rectangle(drag_origin, drag_current)); + if (dragging && has_visible_drag()) paint_region(axis_rectangle(drag_origin, drag_current)); +} +inline bool Selection_Rectangle_Overlay::Private::has_visible_drag() const { + const Point_F current_position{horizontal_axis->coordinate_to_pixel(drag_current.horizontal), vertical_axis->coordinate_to_pixel(drag_current.vertical)}; + return std::hypot(current_position.x - press_position.x, current_position.y - press_position.y) >= minimum_drag_distance_pixels; } template void Selection_Rectangle_Overlay::Private::handle_event(Object* object, const Event& event) { @@ -98,7 +104,7 @@ void Selection_Rectangle_Overlay::Private::handle_event(Object* object, const Ev if (event.type == Event_Type::pointer_move && dragging) { drag_current = axis_point; object->template mark_dirty(); event.accept(); return; } if (event.type != Event_Type::pointer_release || !dragging) return; dragging = false; drag_current = axis_point; - if (std::hypot(point.x - press_position.x, point.y - press_position.y) < 3.0) { + if (std::hypot(point.x - press_position.x, point.y - press_position.y) < minimum_drag_distance_pixels) { object->template mark_dirty(); event.accept(); return; } const Axis_Rectangle selected = axis_rectangle(drag_origin, drag_current); diff --git a/render_2D/render_2D/plottable/Sweep_Spectrum.hpp b/render_2D/render_2D/plottable/Sweep_Spectrum.hpp index 9165d7c..4978d28 100644 --- a/render_2D/render_2D/plottable/Sweep_Spectrum.hpp +++ b/render_2D/render_2D/plottable/Sweep_Spectrum.hpp @@ -15,7 +15,8 @@ struct Sweep_Spectrum : Def; struct Prop : Prev_Prop { std::size_t bins_per_block{}; /* 每个扫频块期望的功率点数;零值接受首块尺寸。 */ - std::size_t block_count{1}; /* 最多保留的扫频块数;零值按 1 处理。 */ + std::size_t block_count{1}; /* 一个完整扫频周期包含的块数;零值按 1 处理。 */ + std::size_t next_block_index{}; /* 下一块覆盖的频率段下标;由 append_block() 推进。 */ Plot_Partition_Count partition_count{1}; /* fixed 模式使用的 Prepare 子图分块数。 */ bool visible_range_only{true}; /* 是否裁掉频率轴可见范围外的线段。 */ Axis_Range frequency_range{}; /* 全部扫描块覆盖的频率范围。 */ @@ -23,13 +24,13 @@ struct Sweep_Spectrum : Def> blocks{}; /* 已提交扫描块的唯一权威集合。 */ + std::vector> blocks{}; /* 各频率段最新数据的唯一权威槽位;下标即频率段。 */ bool operator==(const Prop&) const; }; struct State : Prev_State { - std::size_t stored_block_count{}; /* 当前发布的扫描块数。 */ - std::size_t stored_point_count{}; /* 当前发布的扫描点总数。 */ - std::size_t rendered_point_count{}; /* 最近一次 Prepare 生成的曲线点数。 */ + std::size_t stored_block_count{}; /* 当前已有最新数据的频率段数量。 */ + std::size_t stored_point_count{}; /* 当前单条组合折线保留的总点数。 */ + std::size_t rendered_point_count{}; /* 最近一次 Prepare 为单条折线生成的点数。 */ bool operator==(const State&) const; }; struct Private; diff --git a/render_2D/render_2D/plottable/Sweep_Spectrum.ipp b/render_2D/render_2D/plottable/Sweep_Spectrum.ipp index 79eaeed..9f71a9d 100644 --- a/render_2D/render_2D/plottable/Sweep_Spectrum.ipp +++ b/render_2D/render_2D/plottable/Sweep_Spectrum.ipp @@ -5,13 +5,13 @@ namespace aethera::render_2d { struct Sweep_Spectrum::Private : Prev_Private { struct Prepared { - std::vector partitions{}; /* Prepare 子图各分块的曲线输出。 */ - std::vector values{}; /* 已提交扫描块拼接后的连续功率值。 */ - Axis_Range domain{}; /* 当前已到达数据对应的频率区间。 */ - Point_F marker_first{}; /* 当前扫描位置线的首端点。 */ - Point_F marker_second{}; /* 当前扫描位置线的末端点。 */ + std::vector partitions{}; /* 单条组合折线各 Prepare 分块的曲线输出。 */ + std::vector values{}; /* 各频率段最新数据按槽位顺序拼接的功率值。 */ + Axis_Range domain{}; /* 当前已有频率段共同覆盖的业务坐标区间。 */ + Point_F marker_first{}; /* 最新写入段末端扫描位置线的首端点。 */ + Point_F marker_second{}; /* 最新写入段末端扫描位置线的末端点。 */ Size canvas{}; /* 当前 Scene viewport 的像素尺寸。 */ - bool valid{}; /* 两根轴是否正交且画布有效。 */ + bool valid{}; /* 两根轴正交、画布及组合折线均有效。 */ }; using Append_Run = void (*)(Root*, std::span); using Count_Run = std::size_t (*)(const Root*); struct Dispatch { @@ -58,16 +58,20 @@ template tf::Taskflow Sweep_Spectrum::Private::build_paint_graph(Object* object, const Prop&) { tf::Taskflow graph; graph.emplace([this, object] { paint_frame(object); }).name("sweep_spectrum.paint.frame"); return graph; } template void Sweep_Spectrum::Private::prepare_frame(Object* object) { - const auto& state = object->template read_prop(); const auto& frequency_layout = frequency_axis->template read_prop(); const auto& power_layout = power_axis->template read_prop(); const auto& power_state = power_axis->template read_prop(); prepared = {}; prepared.partitions.resize(graph_partition_count); prepared.canvas = scene->template read_prop().viewport; - if (prepared.canvas.empty() || frequency_layout.orientation == power_layout.orientation) return; for (const auto& block : state.blocks) prepared.values.insert(prepared.values.end(), block.begin(), block.end()); if (prepared.values.empty()) return; - const std::size_t bins_per_block = state.bins_per_block > 0 - ? state.bins_per_block : state.blocks.front().size(); - const std::size_t expected_points = std::max(1, bins_per_block * std::max(1, state.block_count)); - const double progress = expected_points > 1 - ? std::clamp(static_cast(prepared.values.size() - 1) / static_cast(expected_points - 1), 0.0, 1.0) - : 1.0; - const auto latest_frequency = state.frequency_range.origin + state.frequency_range.length() * progress; - prepared.domain = {state.frequency_range.origin, latest_frequency}; + const auto& state = object->template read_prop(); const auto& frequency_layout = frequency_axis->template read_prop(); const auto& power_layout = power_axis->template read_prop(); const auto& power_state = power_axis->template read_prop(); prepared = {}; prepared.canvas = scene->template read_prop().viewport; + if (prepared.canvas.empty() || frequency_layout.orientation == power_layout.orientation || state.blocks.empty()) return; + const std::size_t block_count = std::max(1, state.block_count); + const std::size_t stored_block_count = std::min(block_count, state.blocks.size()); + prepared.partitions.resize(graph_partition_count); + for (std::size_t index = 0; index < stored_block_count; ++index) prepared.values.insert(prepared.values.end(), state.blocks[index].begin(), state.blocks[index].end()); + if (prepared.values.empty()) return; + const bool complete = stored_block_count == block_count; + const std::size_t latest_block_index = complete ? (state.next_block_index + block_count - 1) % block_count : stored_block_count - 1; + const double domain_progress = static_cast(stored_block_count) / static_cast(block_count); + const double marker_progress = static_cast(latest_block_index + 1) / static_cast(block_count); + const auto domain_target = state.frequency_range.origin + state.frequency_range.length() * domain_progress; + const auto latest_frequency = state.frequency_range.origin + state.frequency_range.length() * marker_progress; + prepared.domain = {state.frequency_range.origin, domain_target}; prepared.marker_first = detail::map_plot_point(frequency_axis, latest_frequency, power_axis, power_state.coordinate_range.origin, frequency_layout.orientation); prepared.marker_second = detail::map_plot_point(frequency_axis, latest_frequency, power_axis, power_state.coordinate_range.target, frequency_layout.orientation); prepared.valid = true; } template @@ -78,13 +82,13 @@ void Sweep_Spectrum::Private::prepare_partition(Object* object, Plot_Partition_C template void Sweep_Spectrum::Private::paint_frame(Object* object) { const auto& state = object->template read_prop(); auto& cache = this->paint_surface(); if (!prepared.valid) return; detail::Painter painter(cache, prepared.canvas); for (const auto& curve : prepared.partitions) detail::paint_curve(painter, curve, state.pen); painter.line(prepared.marker_first, prepared.marker_second, state.current_frequency_pen); } template -void Sweep_Spectrum::Private::after_prop_set(Object* object, Member Owner::*, Prop_Access) { if constexpr (std::same_as) object->template mark_dirty(); } +void Sweep_Spectrum::Private::after_prop_set(Object* object, Member Owner::*, Prop_Access props) { if constexpr (std::same_as) { auto& state = props.template get(); const std::size_t block_count = std::max(1, state.block_count); if (state.blocks.size() > block_count) state.blocks.resize(block_count); state.next_block_index = state.blocks.size() < block_count ? state.blocks.size() : state.next_block_index % block_count; object->template mark_dirty(); } } template void Sweep_Spectrum::Private::before_advance(Object*, Prop_Type*, State_Access pending_states, const Prop_Type* current_prop, State_Access) { auto& state = pending_states.template get(); const auto& prop = static_cast(*current_prop); state.stored_block_count = prop.blocks.size(); state.stored_point_count = 0; for (const auto& block : prop.blocks) state.stored_point_count += block.size(); state.rendered_point_count = 0; for (const auto& partition : prepared.partitions) state.rendered_point_count += partition.points.size(); } template const Sweep_Spectrum::Private::Dispatch& Sweep_Spectrum::Private::dispatch_for() { static const Dispatch value{ - [](Root* root, std::span values) { auto* object = static_cast(root); object->template update_prop<&Prop::blocks>([values](Prop_Access props) { auto& state = props.template get(); state.blocks.emplace_back(values.begin(), values.end()); const std::size_t limit = std::max(1, state.block_count); while (state.blocks.size() > limit) state.blocks.erase(state.blocks.begin()); }); }, + [](Root* root, std::span values) { auto* object = static_cast(root); object->template update_prop<&Prop::blocks, &Prop::next_block_index>([values](Prop_Access props) { auto& state = props.template get(); const std::size_t block_count = std::max(1, state.block_count); if (state.blocks.size() > block_count) state.blocks.resize(block_count); if (state.blocks.size() < block_count) { state.next_block_index = state.blocks.size(); state.blocks.emplace_back(values.begin(), values.end()); } else { state.next_block_index %= block_count; state.blocks[state.next_block_index].assign(values.begin(), values.end()); } state.next_block_index = (state.next_block_index + 1) % block_count; }); }, [](const Root* root) { return static_cast(root)->template read_prop().blocks.size(); }, [](const Root* root) { const auto& blocks = static_cast(root)->template read_prop().blocks; std::size_t count{}; for (const auto& block : blocks) count += block.size(); return count; }, [](const Root* root) { const auto& data = static_cast(*static_cast(root)->d); std::size_t count{}; for (const auto& curve : data.prepared.partitions) count += curve.points.size(); return count; } diff --git a/render_2D/render_2D/scene/Render_Scene_2D.cpp b/render_2D/render_2D/scene/Render_Scene_2D.cpp index ecb5404..ee71b83 100644 --- a/render_2D/render_2D/scene/Render_Scene_2D.cpp +++ b/render_2D/render_2D/scene/Render_Scene_2D.cpp @@ -3,7 +3,7 @@ namespace aethera::render_2d { bool Render_Scene_2D::State::operator==(const State&) const = default; bool Render_Scene_2D::Prop::operator==(const Prop&) const = default; -void Render_Scene_2D::render() { static_cast(*d).dispatch->render(this); } +Render_Scene_2D::Render_Result Render_Scene_2D::render(Frame_2D* frame) { return static_cast(*d).dispatch->render(this, frame); } void Render_Scene_2D::set_frame_callback(Frame_Callback callback) { static_cast(*d).dispatch->set_frame_callback(this, std::move(callback)); } void Render_Scene_2D::dispatch_event(std::unique_ptr event) { static_cast(*d).dispatch->push_event(this, std::move(event)); @@ -14,7 +14,4 @@ void Render_Scene_2D::activate_view() { void Render_Scene_2D::deactivate_view() { static_cast(*d).dispatch->set_active(this, false); } -Image_View Render_Scene_2D::frame_view() const { - return static_cast(*d).dispatch->frame_view(this); -} } diff --git a/render_2D/render_2D/scene/Render_Scene_2D.hpp b/render_2D/render_2D/scene/Render_Scene_2D.hpp index 36a9ad0..6d5e27e 100644 --- a/render_2D/render_2D/scene/Render_Scene_2D.hpp +++ b/render_2D/render_2D/scene/Render_Scene_2D.hpp @@ -1,21 +1,20 @@ #pragma once +#include "../base/Frame_2D.hpp" #include "../base/Renderable_2D.hpp" #include "../render/Blend2D_Cache.hpp" #include #include #include namespace aethera::render_2d { -struct Scene_Color_Cache_Tag {}; /* 执行二维 Renderable 图、合成颜色层并发布最终像素帧。 */ struct Render_Scene_2D : Def, Dependency_Graph_Type, Dependency_Graph_Type > { struct Prop : Prev_Prop { Size viewport{}; /* 最终帧的像素尺寸;空尺寸不执行渲染。 */ Color background{Color::black()}; /* 每帧合成前写入的背景颜色。 */ - bool view_active{}; /* 视图是否接受 render() 产生新的完成帧。 */ + bool view_active{}; /* 视图是否接受 render(frame*) 产生新的完成帧。 */ bool operator==(const Prop&) const; }; struct State : Prev_State { @@ -34,10 +33,11 @@ struct Render_Scene_2D : Def(Object*)>> attachments{}; /* 仅在 build 期间绑定已构造 Renderable。 */ }; - using Frame_Callback = std::function; - /* 合成一帧;执行期间的重复调用合并为一次后继帧。 */ - void render(); - /* 安装完成帧回调;Image_View 仅在回调执行期间有效。 */ + enum class Render_Result { completed, view_inactive, empty_viewport }; + using Frame_Callback = std::function; + /* 向调用方拥有的帧合成一次;frame 必须存活到完成回调返回。 */ + [[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); @@ -45,8 +45,6 @@ struct Render_Scene_2D : Def, Dependency_Graph_Error> Render_Scene_2D:: return scene; } struct Render_Scene_2D::Private : Prev_Private { - using Render_Run = void (*)(Root*); + using Render_Run = Render_Result (*)(Root*, Frame_2D*); using Callback_Run = void (*)(Root*, Frame_Callback); using Event_Run = void (*)(Root*, std::unique_ptr); using Active_Run = void (*)(Root*, bool); - using Frame_View_Run = Image_View (*)(const Root*); struct Paint_Node { Root* object{}; /* Paint 拓扑位置对应的最终对象;Scene 不拥有。 */ Renderable_2D_Base::Private* private_data{}; /* 对象的二维能力层;对象存活期间有效。 */ @@ -54,17 +53,16 @@ struct Render_Scene_2D::Private : Prev_Private { Renderable_2D_Base::Private* private_data{}; /* 候选对象的二维能力层;仅在本次 Prepare 分发期间有效。 */ }; struct Dispatch { - Render_Run render; /* 执行最终 Scene 并合成颜色层。 */ + Render_Run render; /* 向外部帧执行最终 Scene 并合成颜色层。 */ Callback_Run set_frame_callback; /* 安装最终完成帧回调。 */ Event_Run push_event; /* 向最终 Scene 的无锁待处理批次转移事件所有权。 */ Active_Run set_active; /* 修改最终 Scene 的视图活动状态。 */ - Frame_View_Run frame_view; /* 访问最终 Scene 已合成的写侧帧。 */ }; const Dispatch* dispatch{}; /* Builder 绑定最终 Scene 类型后的静态分派表。 */ Frame_Callback frame_callback{}; /* 合成完成后的唯一像素发布出口。 */ std::unique_ptr paint_taskflow{}; /* 仅由二维 Paint 图构建的执行图。 */ - bool rendering{}; /* 是否正在同步合成当前帧。 */ - bool render_pending{}; /* 合成期间是否又收到 render();多个调用合并。 */ + Frame_2D* active_frame{}; /* 当前同步 process 借用的外部帧;render 返回前清空。 */ + Blend2D_Cache* frame_target{}; /* 当前 render(frame) 所属外部颜色层;调用返回后清空。 */ /* Impl CRTP 实现:在对象锁内执行 Kernel Scene,再按 Paint 图拓扑顺序合成颜色层。 */ std::vector paint_order{}; /* Paint 图当前拓扑序及 Scene 缓存分组结果。 */ std::vector cache_groups{}; /* 仅保存显式缓存根对应的执行分组。 */ @@ -77,7 +75,7 @@ struct Render_Scene_2D::Private : Prev_Private { template void prepare_paint_targets(Object* object, Blend2D_Cache& frame, Size viewport); /* Prepare 开始时交换 Scene 事件批次,按区域、Paint 逆序和最近目标规则逐事件完成接受链。 */ template void dispatch_events(Object* object, Size viewport); - template void render(Object* object); + template [[nodiscard]] Render_Result render(Object* object, Frame_2D* frame); template [[nodiscard]] static const Dispatch& dispatch_for(); /* CRTP 覆盖:Builder 挂接最终 Private 后安装二维 Scene 的无虚函数业务分派。 */ @@ -174,8 +172,10 @@ void Render_Scene_2D::Private::after_advance(Object* object, Prop*, State_Access const auto owner_node = std::find_if(paint_order.begin(), paint_order.end(), [cache_owner](const Paint_Node& value) { return value.object == cache_owner; }); if (group == cache_groups.end() || owner_node == paint_order.end()) return; if (group->rebuild) owner_node->private_data->valid_cache = owner_node->private_data->paint_target; - if (owner_node->private_data->valid_cache) - object->template pending_buffer().composite(*owner_node->private_data->valid_cache); + if (owner_node->private_data->valid_cache) { + if (!frame_target) throw std::logic_error("2D paint graph has no external frame target"); + frame_target->composite(*owner_node->private_data->valid_cache); + } }).name("render_2d.paint.cache.composite"); paint_done.precede(composite); previous = composite; @@ -228,14 +228,14 @@ void Render_Scene_2D::Private::prepare_paint_targets(Object*, Blend2D_Cache& fra template void Render_Scene_2D::Private::process(Object* object, Callback&& callback) requires std::invocable { + Frame_2D* frame_object = active_frame; + if (!frame_object) throw std::logic_error("2D Scene process has no external frame"); const auto& state = static_cast(*static_cast(*this).current); - if (!state.view_active) { - return; - } - if (state.viewport.empty()) { - return; - } + 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); + frame_object->mark(Frame_Trace_Marker::event_dispatch_finished); object->template current_dependency_graph().for_each([](const Dependency_Graph::Node& node) { if (!node.object->template take_dirty()) return; node.object->template mark_dirty(); @@ -248,9 +248,16 @@ void Render_Scene_2D::Private::process(Object* object, Callback&& callback) execute = execute || !data.prepare_graph_built || data.dispatch->prepare.rebuild_predicate(renderable); if (execute) prepare_executions.push_back(renderable); }); - Prev_Private::process(object, [&](const Scene::Private::Result&) { + Prev_Private::process(object, frame_object, [&](const Scene::Private::Result&) { + frame_object->mark(Frame_Trace_Marker::paint_started); for (auto* renderable : prepare_executions) renderable->template mark_dirty(); - auto& frame = object->template pending_buffer(); + auto& frame = detail::Frame_2D_Access::render_target(frame_object); + struct Frame_Target_Scope { + Blend2D_Cache*& target; /* Scene 当前帧目标槽位。 */ + Blend2D_Cache* previous{}; /* 嵌套调用前的目标;析构时恢复。 */ + ~Frame_Target_Scope() { target = previous; } + } frame_target_scope{frame_target, frame_target}; + frame_target = &frame; frame.ensure_size(state.viewport); frame.clear(); { @@ -261,18 +268,31 @@ void Render_Scene_2D::Private::process(Object* object, Callback&& callback) } prepare_paint_targets(object, frame, state.viewport); if (paint_taskflow && !paint_taskflow->empty()) aethera::detail::run_taskflow(*paint_taskflow); + frame_object->mark(Frame_Trace_Marker::paint_finished); }); std::invoke(std::forward(callback)); } template -void Render_Scene_2D::Private::render(Object* object) { - if (rendering) { render_pending = true; return; } - rendering = true; - do { - render_pending = false; - object->process([&] { if (frame_callback) frame_callback(object->template pending_buffer().view()); }); - } while (render_pending); - rendering = false; +Render_Scene_2D::Render_Result Render_Scene_2D::Private::render(Object* object, Frame_2D* frame) { + static_cast(detail::Frame_2D_Access::render_target(frame)); + frame->mark(Frame_Trace_Marker::scene_render_requested); + struct Active_Frame_Scope { + Frame_2D*& target; /* 最终 Private 的同步 process 帧槽位。 */ + Frame_2D* previous{}; /* 嵌套调用前的帧;析构时恢复。 */ + ~Active_Frame_Scope() { target = previous; } + } active_frame_scope{active_frame, active_frame}; + active_frame = frame; + bool completed{}; + object->process([&] { + completed = true; + frame->mark(Frame_Trace_Marker::scene_render_finished); + frame->mark(Frame_Trace_Marker::callback_started); + if (frame_callback) frame_callback(frame); + frame->mark(Frame_Trace_Marker::callback_finished); + }); + if (completed) return Render_Result::completed; + const auto& prop = object->template read_prop(); + return prop.view_active ? Render_Result::empty_viewport : Render_Result::view_inactive; } template void Render_Scene_2D::Private::dispatch_events(Object* object, Size viewport) { @@ -303,9 +323,9 @@ void Render_Scene_2D::Private::dispatch_events(Object* object, Size viewport) { template const Render_Scene_2D::Private::Dispatch& Render_Scene_2D::Private::dispatch_for() { static const Dispatch value{ - [](Root* root) { + [](Root* root, Frame_2D* frame) { auto* object = static_cast(root); - static_cast(*object->d).render(object); + return static_cast(*object->d).render(object, frame); }, [](Root* root, Frame_Callback callback) { auto* object = static_cast(root); @@ -315,14 +335,7 @@ const Render_Scene_2D::Private::Dispatch& Render_Scene_2D::Private::dispatch_for auto* object = static_cast(root); static_cast(*object->d).push_event(std::move(event)); }, - [](Root* root, bool active) { - static_cast(root)->template set<&Prop::view_active>(active); - }, - [](const Root* root) { - const auto* object = static_cast(root); - const auto& private_data = static_cast(*object->d); - return private_data.buffer_storage.template get().pending->view(); - } + [](Root* root, bool active) { static_cast(root)->template set<&Prop::view_active>(active); } }; return value; } diff --git a/render_2D/tests/Axis_Test.cpp b/render_2D/tests/Axis_Test.cpp index e212192..3275c32 100644 --- a/render_2D/tests/Axis_Test.cpp +++ b/render_2D/tests/Axis_Test.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -12,6 +13,15 @@ std::unique_ptr build_axis() { if (!result) std::terminate(); return std::move(result).value(); } +template +std::unique_ptr render_frame(Scene* scene) { + static std::uint64_t sequence{1}; + auto frame = std::make_unique(Frame_Identity{sequence++, 0}); + EXPECT_EQ(scene->render(frame.get()), Render_Scene_2D::Render_Result::completed); + return frame; +} +template +void render_once(Scene* scene) { static_cast(render_frame(scene)); } } TEST(event_routing_rule, keeps_layered_targets_before_nearest_scored_targets) { std::vector targets{1, 2, 3, 4}; @@ -82,8 +92,8 @@ TEST(axis_render, scene_prepares_and_paints_uncached_axis_into_frame) { prepare.add(axis.get()); paint.add(axis.get()); }).has_value())); - scene->render(); - const Image_View view = scene->frame_view(); + const auto frame = render_frame(scene.get()); + const Image_View view = frame->image(); ASSERT_FALSE(view.empty()); bool contains_color{}; for (int y = 0; y < view.height && !contains_color; ++y) { @@ -106,12 +116,12 @@ TEST(axis_event, numeric_axis_wheel_zoom_and_drag_update_authoritative_range) { prepare.add(axis.get()); paint.add(axis.get()); }).has_value())); scene->activate_view(); - scene->render(); + render_once(scene.get()); auto wheel = std::make_unique(); wheel->position = {50.0, 0.0}; wheel->angle_delta_y = 120.0; scene->dispatch_event(std::move(wheel)); - scene->render(); + render_once(scene.get()); EXPECT_EQ(axis->coordinate_range(), (Axis_Range{0.5, 9.5})); auto press = std::make_unique(Event_Type::pointer_press); press->position = {50.0, 0.0}; @@ -120,7 +130,7 @@ TEST(axis_event, numeric_axis_wheel_zoom_and_drag_update_authoritative_range) { auto move = std::make_unique(Event_Type::pointer_move); move->position = {60.0, 0.0}; scene->dispatch_event(std::move(move)); - scene->render(); + render_once(scene.get()); EXPECT_EQ(axis->coordinate_range(), (Axis_Range{-0.4, 8.6})); } TEST(axis_event, scene_routes_pointer_interaction_to_the_nearest_axis_segment) { @@ -143,13 +153,13 @@ TEST(axis_event, scene_routes_pointer_interaction_to_the_nearest_axis_segment) { paint.add(horizontal.get()); paint.add(vertical.get()); }).has_value())); scene->activate_view(); - scene->render(); + render_once(scene.get()); auto near_vertical = std::make_unique(); near_vertical->position = {22.0, 80.0}; near_vertical->angle_delta_y = 120.0; scene->dispatch_event(std::move(near_vertical)); - scene->render(); + render_once(scene.get()); EXPECT_DOUBLE_EQ(horizontal->coordinate_range().size(), 10.0); EXPECT_DOUBLE_EQ(vertical->coordinate_range().size(), 9.0); @@ -157,7 +167,7 @@ TEST(axis_event, scene_routes_pointer_interaction_to_the_nearest_axis_segment) { near_horizontal->position = {120.0, 178.0}; near_horizontal->angle_delta_y = 120.0; scene->dispatch_event(std::move(near_horizontal)); - scene->render(); + render_once(scene.get()); EXPECT_DOUBLE_EQ(horizontal->coordinate_range().size(), 9.0); EXPECT_DOUBLE_EQ(vertical->coordinate_range().size(), 9.0); } diff --git a/render_2D/tests/Plottable_Migration_Test.cpp b/render_2D/tests/Plottable_Migration_Test.cpp index 23b930b..3dddf32 100644 --- a/render_2D/tests/Plottable_Migration_Test.cpp +++ b/render_2D/tests/Plottable_Migration_Test.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -30,6 +31,12 @@ void configure_axis(Axis* axis, Axis_Orientation orientation, Point_F position, axis->template set<&Abs_Axis::Prop::position>(position); axis->template set<&Abs_Axis::Prop::pixel_length>(length); } +template +void render_once(Scene* scene) { + static std::uint64_t sequence{1}; + Frame_2D frame{Frame_Identity{sequence++, 0}}; + EXPECT_EQ(scene->render(&frame), Render_Scene_2D::Render_Result::completed); +} } TEST(plottable_migration, curve_plots_share_partitioned_rendering) { @@ -60,18 +67,31 @@ TEST(plottable_migration, curve_plots_share_partitioned_rendering) { trace->append_sample(time->append_time({2'000}), 50.0); trace->append_sample(time->append_time({3'000}), 90.0); sweep->set<&Sweep_Spectrum::Prop::frequency_range>(Axis_Range{0.0, 100.0}); + sweep->set<&Sweep_Spectrum::Prop::block_count>(2u); sweep->set<&Sweep_Spectrum::Prop::partition_mode>(Plot_Partition_Mode::fixed); sweep->set<&Sweep_Spectrum::Prop::partition_count>(2u); - const std::array block{-90.0, -60.0, -30.0, -10.0}; - sweep->append_block(block); + const std::array first_block{-90.0, -60.0, -30.0, -10.0}; + const std::array second_block{-80.0, -50.0, -20.0, -5.0}; + const std::array replacement_block{-70.0, -40.0, -15.0, -2.0}; + sweep->append_block(first_block); + sweep->append_block(second_block); auto scene = build_scene(trace.get(), sweep.get()); scene->set<&Render_Scene_2D::Prop::viewport>(canvas); scene->activate_view(); - scene->render(); + render_once(scene.get()); EXPECT_EQ(trace->sample_count(), 3u); EXPECT_GT(trace->rendered_point_count(), 0u); - EXPECT_EQ(sweep->stored_block_count(), 1u); + EXPECT_EQ(sweep->stored_block_count(), 2u); EXPECT_GT(sweep->rendered_point_count(), 0u); + const auto completed_sweep_points = sweep->rendered_point_count(); + sweep->append_block(replacement_block); + render_once(scene.get()); + EXPECT_EQ(sweep->stored_block_count(), 2u); + EXPECT_EQ(sweep->rendered_point_count(), completed_sweep_points); + const auto& sweep_prop = sweep->read_prop(); + EXPECT_EQ(sweep_prop.blocks[0], std::vector(replacement_block.begin(), replacement_block.end())); + EXPECT_EQ(sweep_prop.blocks[1], std::vector(second_block.begin(), second_block.end())); + EXPECT_EQ(sweep_prop.next_block_index, 1u); } TEST(plottable_migration, raster_plots_share_partitioned_color_blocks) { @@ -105,7 +125,7 @@ TEST(plottable_migration, raster_plots_share_partitioned_color_blocks) { auto scene = build_scene(glow.get(), waterfall.get()); scene->set<&Render_Scene_2D::Prop::viewport>(canvas); scene->activate_view(); - scene->render(); + render_once(scene.get()); EXPECT_EQ(glow->history_count(), 1u); EXPECT_GT(glow->rendered_cell_count(), 0u); EXPECT_EQ(waterfall->row_count(), 2u); @@ -133,7 +153,7 @@ TEST(plottable_migration, direct_overlay_and_constellation_build_and_render) { auto scene = build_scene(diagram.get(), overlay.get()); scene->set<&Render_Scene_2D::Prop::viewport>(canvas); scene->activate_view(); - scene->render(); + render_once(scene.get()); EXPECT_EQ(diagram->point_count(), 1u); EXPECT_EQ(overlay->read_state().prepare_task_count, 0u); EXPECT_EQ(overlay->read_state().paint_task_count, 1u); @@ -155,7 +175,7 @@ TEST(selection_overlay, control_extends_selection_and_plain_click_clears_it) { auto scene = build_scene(overlay.get()); scene->set<&Render_Scene_2D::Prop::viewport>(canvas); scene->activate_view(); - scene->render(); + render_once(scene.get()); const auto drag = [&](Point_F first, Point_F second, Keyboard_Modifier modifiers) { auto press = std::make_unique(Event_Type::pointer_press); @@ -172,7 +192,7 @@ TEST(selection_overlay, control_extends_selection_and_plain_click_clears_it) { release->button = Mouse_Button::left; release->modifiers = modifiers; scene->dispatch_event(std::move(release)); - scene->render(); + render_once(scene.get()); }; drag({30.0, 90.0}, {60.0, 60.0}, Keyboard_Modifier::none); EXPECT_EQ(overlay->selected_regions().size(), 1u); @@ -202,7 +222,7 @@ TEST(selection_overlay, time_axis_selection_keeps_axis_coordinates_while_window_ auto scene = build_scene(overlay.get()); scene->set<&Render_Scene_2D::Prop::viewport>(canvas); scene->activate_view(); - scene->render(); + render_once(scene.get()); auto press = std::make_unique(Event_Type::pointer_press); press->position = {92.0, 88.0}; press->button = Mouse_Button::left; @@ -214,12 +234,12 @@ TEST(selection_overlay, time_axis_selection_keeps_axis_coordinates_while_window_ release->position = {116.0, 56.0}; release->button = Mouse_Button::left; scene->dispatch_event(std::move(release)); - scene->render(); + render_once(scene.get()); ASSERT_EQ(overlay->selected_regions().size(), 1u); const Axis_Rectangle selected = overlay->selected_regions().front(); const auto pixel_before = time->coordinate_to_pixel(selected.horizontal.center()); time->append_time({4'000}); - scene->render(); + render_once(scene.get()); ASSERT_EQ(overlay->selected_regions().size(), 1u); EXPECT_EQ(overlay->selected_regions().front(), selected); EXPECT_NE(time->coordinate_to_pixel(selected.horizontal.center()), pixel_before); diff --git a/render_2D/tests/Spectrum_Test.cpp b/render_2D/tests/Spectrum_Test.cpp index f90c83b..97239b7 100644 --- a/render_2D/tests/Spectrum_Test.cpp +++ b/render_2D/tests/Spectrum_Test.cpp @@ -1,4 +1,5 @@ #include +#include #include #include namespace { @@ -11,6 +12,14 @@ std::unique_ptr build_object(Args&&... args) { if (!result) std::terminate(); return std::move(result).value(); } +template +std::unique_ptr build_scene(Renderables*... renderables) { + typename Scene::Builder builder; + (builder.add_renderable(renderables), ...); + auto result = builder.build(); + if (!result) std::terminate(); + return std::move(result).value(); +} bool contains_color(Image_View view) { for (int y = 0; y < view.height; ++y) { const auto* row = reinterpret_cast(view.data + static_cast(y) * view.stride); @@ -19,16 +28,22 @@ bool contains_color(Image_View view) { } return false; } +template +std::unique_ptr render_frame(Scene* scene) { + static std::uint64_t sequence{1}; + auto frame = std::make_unique(Frame_Identity{sequence++, 0}); + EXPECT_EQ(scene->render(frame.get()), Render_Scene_2D::Render_Result::completed); + return frame; +} } TEST(spectrum_data, publishes_samples_and_interpolates_power) { using Frequency = Impl; using Power = Impl; using Object = Impl; using Scene_Object = Impl; - auto scene = build_object(); auto frequency = build_object(); auto power = build_object(); - auto spectrum = build_object(scene.get(), frequency.get(), power.get()); + auto spectrum = build_object(frequency.get(), power.get()); const auto missing = spectrum->power_at(25.0); ASSERT_FALSE(missing.has_value()); EXPECT_EQ(missing.error(), Spectrum::Power_At_Result::no_samples); @@ -49,10 +64,9 @@ TEST(spectrum_markers, supports_selection_frequency_changes_and_known_results) { using Power = Impl; using Object = Impl; using Scene_Object = Impl; - auto scene = build_object(); auto frequency = build_object(); auto power = build_object(); - auto spectrum = build_object(scene.get(), frequency.get(), power.get()); + auto spectrum = build_object(frequency.get(), power.get()); spectrum->add_custom_marker(10.0); spectrum->add_custom_line_marker(20.0); spectrum->advance(); @@ -79,10 +93,10 @@ TEST(render_scene_2d, composites_axes_and_spectrum_into_final_frame) { using Spectrum_Object = Impl; using Scene_Object = Impl; initialize_runtime(2); - auto scene = build_object(); auto frequency = build_object(); auto power = build_object(); - auto spectrum = build_object(scene.get(), frequency.get(), power.get()); + auto spectrum = build_object(frequency.get(), power.get()); + auto scene = build_scene(spectrum.get()); const Size canvas{160, 120}; frequency->set<&Abs_Axis::Prop::position>(Point_F{20.0, 100.0}); frequency->set<&Abs_Axis::Prop::pixel_length>(120.0); @@ -112,48 +126,49 @@ TEST(render_scene_2d, composites_axes_and_spectrum_into_final_frame) { EXPECT_TRUE(cache_graph.depends_on(spectrum.get(), scene.get())); EXPECT_TRUE(cache_graph.depends_on(spectrum.get(), frequency.get())); EXPECT_TRUE(cache_graph.depends_on(spectrum.get(), power.get())); - scene->render(); + auto output_frame = render_frame(scene.get()); EXPECT_GT(spectrum->rendered_point_count(), 0u); const auto& render_state = spectrum->read_state(); EXPECT_EQ(render_state.prepare_task_count, 4u); EXPECT_EQ(render_state.paint_task_count, 1u); - const Image_View frame = scene->frame_view(); + const Image_View frame = output_frame->image(); ASSERT_FALSE(frame.empty()); EXPECT_TRUE(contains_color(frame)); EXPECT_FALSE(spectrum->dirty()); EXPECT_FALSE(spectrum->dirty()); EXPECT_FALSE(frequency->dirty()); EXPECT_FALSE(power->dirty()); - scene->render(); - EXPECT_TRUE(contains_color(scene->frame_view())); + output_frame = render_frame(scene.get()); + EXPECT_TRUE(contains_color(output_frame->image())); frequency->set<&Numeric_Axis::Prop::precision>(3); EXPECT_FALSE(spectrum->dirty()); - scene->render(); + output_frame = render_frame(scene.get()); EXPECT_TRUE(spectrum->read_state().paint_executed); EXPECT_TRUE(frequency->read_state().paint_executed); EXPECT_TRUE(power->read_state().paint_executed); - EXPECT_TRUE(contains_color(scene->frame_view())); + EXPECT_TRUE(contains_color(output_frame->image())); frequency->set<&Numeric_Axis::Prop::coordinate_range>(Axis_Range{0.0, 120.0}); EXPECT_TRUE(spectrum->dirty()); - scene->render(); + output_frame = render_frame(scene.get()); EXPECT_FALSE(spectrum->dirty()); EXPECT_TRUE(spectrum->read_state().prepare_executed); spectrum->set<&Spectrum::Prop::partition_count>(2u); spectrum->update_samples(samples); - scene->render(); + output_frame = render_frame(scene.get()); const auto& rebuilt_state = spectrum->read_state(); EXPECT_TRUE(rebuilt_state.prepare_graph_rebuilt); EXPECT_EQ(rebuilt_state.prepare_task_count, 3u); - EXPECT_TRUE(contains_color(scene->frame_view())); + EXPECT_TRUE(contains_color(output_frame->image())); const Size resized_canvas{200, 140}; scene->set<&Render_Scene_2D::Prop::viewport>(resized_canvas); - EXPECT_TRUE(frequency->dirty()); - EXPECT_TRUE(power->dirty()); - scene->render(); + EXPECT_TRUE(spectrum->dirty()); + EXPECT_FALSE(frequency->dirty()); + EXPECT_FALSE(power->dirty()); + output_frame = render_frame(scene.get()); EXPECT_EQ(scene->read_prop().viewport, resized_canvas); - EXPECT_TRUE(frequency->read_state().prepare_executed); + EXPECT_FALSE(frequency->read_state().prepare_executed); EXPECT_TRUE(frequency->read_state().paint_executed); - const Image_View resized_frame = scene->frame_view(); + const Image_View resized_frame = output_frame->image(); EXPECT_EQ(resized_frame.width, resized_canvas.width); EXPECT_EQ(resized_frame.height, resized_canvas.height); EXPECT_TRUE(contains_color(resized_frame)); diff --git a/render_3D/DATOVIZ_MIGRATION_AUDIT.md b/render_3D/DATOVIZ_MIGRATION_AUDIT.md index 7657e04..2023d63 100644 --- a/render_3D/DATOVIZ_MIGRATION_AUDIT.md +++ b/render_3D/DATOVIZ_MIGRATION_AUDIT.md @@ -19,7 +19,7 @@ Kernel Event -----------------+ +-- immutable Prepared_Point | Datoviz apply / emit / DRP2 / GPU | - immutable Pixel_Frame + externally owned Frame_3D ``` oneTBB worker 不调用 `dvz_*`。Datoviz FramePlan 继续负责 GPU upload/render/copy/readback 依赖,Renderive RenderPlan 只负责应用级 CPU prepare 依赖和末端单一 render node。 @@ -39,7 +39,7 @@ oneTBB worker 不调用 `dvz_*`。Datoviz FramePlan 继续负责 GPU upload/rend - positions、colors、sizes、sigma、angles、radii、normals 等派生数组只存在于 `Prepared_Point`,不保存为 Visual 成员。 - `Point_Scene` 复用 Kernel `Scene3D_Context`、frame-control、RenderPlan、oneTBB Flow Graph、capture 和 observer。 - Pointer、wheel、key 直接保留 Kernel Event 语义并投递到 `Render_Domain`,没有第二套输入命令或收集队列。 -- 后端只应用 prepared data,并负责 Datoviz/Vulkan 资源和不可变 `Pixel_Frame` 输出。 +- 后端只应用 prepared data,并负责 Datoviz/Vulkan 资源和写入调用方持有的 `Frame_3D`。 ## 唯一状态源检查 diff --git a/render_3D/render_3D/base/Frame_3D.cpp b/render_3D/render_3D/base/Frame_3D.cpp new file mode 100644 index 0000000..ca79437 --- /dev/null +++ b/render_3D/render_3D/base/Frame_3D.cpp @@ -0,0 +1,17 @@ +#include "Frame_3D.hpp" +#include +namespace aethera::render_3d { +struct Frame_3D::Private { + Extent extent{}; /* 本帧 GPU 读回图像尺寸。 */ + std::vector pixels{}; /* 本帧按 RGBA8 连续存储的 GPU 读回像素。 */ +}; +Frame_3D::Frame_3D(Frame_Identity identity) : Render_Frame(identity), d(std::make_unique()) {} +Frame_3D::~Frame_3D() = default; +Extent Frame_3D::extent() const noexcept { return d->extent; } +std::span Frame_3D::pixels() const noexcept { return d->pixels; } +void detail::Frame_3D_Access::assign_pixels(Frame_3D* frame, Extent extent, std::vector pixels) { + if (!frame) throw std::invalid_argument("Render_Scene_3D requires a non-null external frame"); + frame->d->extent = extent; + frame->d->pixels = std::move(pixels); +} +} diff --git a/render_3D/render_3D/base/Frame_3D.hpp b/render_3D/render_3D/base/Frame_3D.hpp new file mode 100644 index 0000000..ebc86d4 --- /dev/null +++ b/render_3D/render_3D/base/Frame_3D.hpp @@ -0,0 +1,25 @@ +#pragma once +#include "Types.hpp" +#include +#include +#include +#include +namespace aethera::render_3d { +class Frame_3D; +namespace detail { +struct Frame_3D_Access { + static void assign_pixels(Frame_3D* frame, Extent extent, std::vector pixels); +}; +} +class Frame_3D final : public Render_Frame { +public: + explicit Frame_3D(Frame_Identity identity); + ~Frame_3D() override; + [[nodiscard]] Extent extent() const noexcept; + [[nodiscard]] std::span pixels() const noexcept; +private: + struct Private; + std::unique_ptr d; /* 本帧最终三维读回结果的唯一所有权。 */ + friend struct detail::Frame_3D_Access; +}; +} diff --git a/render_3D/render_3D/base/Types.hpp b/render_3D/render_3D/base/Types.hpp index a34d92c..04165b1 100644 --- a/render_3D/render_3D/base/Types.hpp +++ b/render_3D/render_3D/base/Types.hpp @@ -67,12 +67,6 @@ struct Point_Style { Point_Aspect aspect{Point_Aspect::filled}; /* 点内部和边缘的组合方式。 */ bool operator==(const Point_Style&) const = default; }; -struct Pixel_Frame { - Extent extent{}; /* 像素帧尺寸。 */ - std::uint64_t sequence{}; /* Scene 分配的单调帧序号。 */ - std::vector rgba8{}; /* 按 RGBA8 连续存储的只读发布内容。 */ - bool operator==(const Pixel_Frame&) const = default; -}; namespace detail { [[nodiscard]] bool finite(Coordinate_3D value) noexcept; [[nodiscard]] bool finite(Vec2 value) noexcept; diff --git a/render_3D/render_3D/detail/Async_Render_Backend.cpp b/render_3D/render_3D/detail/Async_Render_Backend.cpp index cd06a08..837d413 100644 --- a/render_3D/render_3D/detail/Async_Render_Backend.cpp +++ b/render_3D/render_3D/detail/Async_Render_Backend.cpp @@ -4,40 +4,127 @@ #include "Render_Domain.hpp" #include #include +#include #include +#include namespace aethera::render_3d::detail { namespace { float wheel_step(double pixel, double angle) { return static_cast(pixel != 0.0 ? pixel : angle / 120.0); } +void record_datoviz_trace(Frame_3D* frame, const Datoviz_Frame_Trace& trace) { + if (trace.apply_ns) frame->record(Frame_Trace_Measurement::backend_apply_ns, trace.apply_ns); + if (trace.emit_ns) frame->record(Frame_Trace_Measurement::backend_plan_ns, trace.emit_ns); + if (trace.execute_ns) frame->record(Frame_Trace_Measurement::backend_execute_ns, trace.execute_ns); + if (trace.submit_ns) frame->record(Frame_Trace_Measurement::backend_submit_ns, trace.submit_ns); + if (trace.readback_ns) frame->record(Frame_Trace_Measurement::readback_ns, trace.readback_ns); + if (!trace.gpu) return; + frame->record(Frame_Trace_Measurement::gpu_render_ns, trace.gpu->render_ns); + frame->record(Frame_Trace_Measurement::gpu_transition_ns, trace.gpu->transition_ns); + frame->record(Frame_Trace_Measurement::gpu_copy_ns, trace.gpu->copy_ns); + frame->record(Frame_Trace_Measurement::gpu_total_ns, trace.gpu->total_ns); +} } struct Async_Render_Backend::Implementation : std::enable_shared_from_this { - struct Pending { std::optional frame{}; }; - struct Submission { Prepared_Visual visual{}; Scene_3D_Parameters parameters{}; }; + struct Pending { + std::optional backend_frame{}; /* 已提交并等待 GPU fence 的后端帧。 */ + Frame_3D* output{}; /* 调用方拥有且保证存活到完成回调的输出帧。 */ + }; + struct Submission { + Prepared_Visual visual{}; /* 本帧不可变的 CPU Prepared 数据。 */ + Scene_3D_Parameters parameters{}; /* 本帧 viewport 与清屏参数。 */ + Frame_3D* output{}; /* 调用方拥有的最终输出帧。 */ + }; std::shared_ptr render_domain; /* GPU 索引对应的单线程 Datoviz 域。 */ std::unique_ptr backend; /* 只允许 render_domain 线程访问。 */ mutable std::mutex render_mutex; /* 保护合并帧意图、在途标记和完成回调。 */ - std::optional pending_submission{}; /* 在途期间覆盖保存的最新绘制意图。 */ + std::deque pending_submissions{}; /* 唯一 Frame Target 忙碌时等待的外部帧 FIFO。 */ Frame_Callback frame_callback{}; /* 完成帧的唯一发布出口。 */ bool frame_in_flight{}; /* 是否已有帧占用唯一 Frame Target。 */ mutable std::mutex failure_mutex; /* 保护最后一次 Unknown Failure。 */ std::exception_ptr failure{}; /* 后端隔离边界捕获的最后一次 Unknown Failure。 */ - std::atomic_uint64_t next_sequence{1}; /* 下一完成帧使用的单调序号。 */ std::atomic_bool available{}; /* 后端是否可接受事件和绘制。 */ Implementation(std::uint32_t gpu_index, bool validation_enabled, Visual_Family visual_family, Scene_3D_Parameters initial) : render_domain(Render_Domain::acquire(gpu_index)) { const auto initialized = render_domain->invoke([this, gpu_index, validation_enabled, visual_family, initial] { backend = std::make_unique(gpu_index, validation_enabled, visual_family, initial); }); if (!initialized) throw std::runtime_error("render domain stopped during 3D backend initialization"); available.store(true, std::memory_order_release); } ~Implementation() { available.store(false, std::memory_order_release); if (!backend) return; const auto destroyed = render_domain->invoke([this] { backend.reset(); }); if (!destroyed) backend.release(); } void fail(std::exception_ptr value) noexcept; - void render(const Prepared_Visual& visual, Scene_3D_Parameters parameters); + void render(const Prepared_Visual& visual, Scene_3D_Parameters parameters, Frame_3D* frame); void submit(Submission submission); void finish(std::shared_ptr pending, Gpu_Completion_Service::Result completion) noexcept; - void complete(std::shared_ptr frame); + void complete(Frame_3D* frame); }; Async_Render_Backend::Async_Render_Backend(std::uint32_t gpu_index, bool validation_enabled, Visual_Family visual_family, Scene_3D_Parameters parameters) : implementation_(std::make_shared(gpu_index, validation_enabled, visual_family, parameters)) {} Async_Render_Backend::~Async_Render_Backend() = default; -void Async_Render_Backend::Implementation::fail(std::exception_ptr value) noexcept { { std::lock_guard lock(failure_mutex); failure = std::move(value); } available.store(false, std::memory_order_release); std::lock_guard lock(render_mutex); frame_in_flight = false; pending_submission.reset(); } -void Async_Render_Backend::Implementation::render(const Prepared_Visual& visual, Scene_3D_Parameters parameters) { if (!available.load(std::memory_order_acquire)) return; std::optional submission; { std::lock_guard lock(render_mutex); pending_submission = Submission{visual, parameters}; if (frame_in_flight) return; frame_in_flight = true; submission = std::move(pending_submission); pending_submission.reset(); } submit(std::move(*submission)); } -void Async_Render_Backend::Implementation::submit(Submission submission) { const auto sequence = next_sequence.fetch_add(1, std::memory_order_relaxed); auto self = shared_from_this(); auto prepared = std::make_shared(std::move(submission.visual)); auto pending = std::make_shared(); const auto parameters = submission.parameters; const auto queued = render_domain->try_post([self, parameters, sequence, prepared, pending] { auto reservation = Gpu_Completion_Service::instance().prepare([self, pending](Gpu_Completion_Service::Result result) { self->finish(pending, std::move(result)); }, [self](std::exception_ptr value) { self->fail(std::move(value)); }, false); if (!reservation) { self->complete({}); return; } pending->frame = self->backend->submit(parameters, *prepared, sequence, false); if (!pending->frame) { self->complete({}); return; } reservation.reservation.watch(pending->frame->device, pending->frame->fence); }, [self](std::exception_ptr value) { self->fail(std::move(value)); }); if (queued != Render_Domain::Try_Post_Result::queued) { std::lock_guard lock(render_mutex); pending_submission = Submission{*prepared, parameters}; frame_in_flight = false; } } -void Async_Render_Backend::Implementation::finish(std::shared_ptr pending, Gpu_Completion_Service::Result completion) noexcept { auto self = shared_from_this(); try { const auto result = render_domain->post([self, pending = std::move(pending), completion] { std::shared_ptr frame; if (pending->frame) { if (completion.error == Gpu_Completion_Service::Completion_Error::none) frame = self->backend->collect(std::move(*pending->frame)).frame; else self->backend->discard(std::move(*pending->frame)); } self->complete(std::move(frame)); }, [self](std::exception_ptr value) { self->fail(std::move(value)); }); if (result != Render_Domain::Admission_Result::none) fail(std::make_exception_ptr(std::runtime_error("render domain stopped before GPU completion collection"))); } catch (...) { fail(std::current_exception()); } } -void Async_Render_Backend::Implementation::complete(std::shared_ptr frame) { Frame_Callback callback; std::optional next; { std::lock_guard lock(render_mutex); callback = frame_callback; frame_in_flight = false; if (pending_submission && available.load(std::memory_order_acquire)) { frame_in_flight = true; next = std::move(pending_submission); pending_submission.reset(); } } if (frame && callback) callback(std::move(frame)); if (next) submit(std::move(*next)); } -void Async_Render_Backend::render(const Prepared_Visual& visual, Scene_3D_Parameters parameters) { implementation_->render(visual, parameters); } +void Async_Render_Backend::Implementation::fail(std::exception_ptr value) noexcept { { std::lock_guard lock(failure_mutex); failure = std::move(value); } available.store(false, std::memory_order_release); std::lock_guard lock(render_mutex); frame_in_flight = false; pending_submissions.clear(); } +void Async_Render_Backend::Implementation::render(const Prepared_Visual& visual, Scene_3D_Parameters parameters, Frame_3D* frame) { + if (!frame) throw std::invalid_argument("3D backend requires a non-null external frame"); + if (!available.load(std::memory_order_acquire)) return; + frame->mark(Frame_Trace_Marker::backend_queue_entered); + std::optional submission; + { + std::lock_guard lock(render_mutex); + if (frame_in_flight) { pending_submissions.push_back(Submission{visual, parameters, frame}); return; } + frame_in_flight = true; + submission = Submission{visual, parameters, frame}; + } + submit(std::move(*submission)); +} +void Async_Render_Backend::Implementation::submit(Submission submission) { + auto self = shared_from_this(); + auto prepared = std::make_shared(std::move(submission.visual)); + auto pending = std::make_shared(Pending{std::nullopt, submission.output}); + const auto parameters = submission.parameters; + const auto sequence = submission.output->identity().sequence; + const auto queued = render_domain->try_post([self, parameters, sequence, prepared, pending] { + pending->output->mark(Frame_Trace_Marker::backend_queue_left); + auto reservation = Gpu_Completion_Service::instance().prepare([self, pending](Gpu_Completion_Service::Result result) { self->finish(pending, std::move(result)); }, [self](std::exception_ptr value) { self->fail(std::move(value)); }, true); + if (!reservation) { self->complete(pending->output); return; } + pending->backend_frame = self->backend->submit(parameters, *prepared, sequence, true); + if (!pending->backend_frame) { self->complete(pending->output); return; } + record_datoviz_trace(pending->output, pending->backend_frame->trace); + pending->output->mark(Frame_Trace_Marker::gpu_submitted); + reservation.reservation.watch(pending->backend_frame->device, pending->backend_frame->fence); + }, [self](std::exception_ptr value) { self->fail(std::move(value)); }); + if (queued != Render_Domain::Try_Post_Result::queued) { + std::lock_guard lock(render_mutex); + pending_submissions.push_front(Submission{*prepared, parameters, submission.output}); + frame_in_flight = false; + } +} +void Async_Render_Backend::Implementation::finish(std::shared_ptr pending, Gpu_Completion_Service::Result completion) noexcept { + pending->output->mark(Frame_Trace_Marker::gpu_completed); + pending->output->record(Frame_Trace_Measurement::gpu_fence_wait_ns, completion.wait_duration_ns); + auto self = shared_from_this(); + try { + const auto result = render_domain->post([self, pending = std::move(pending), completion] { + if (pending->backend_frame) { + if (completion.error == Gpu_Completion_Service::Completion_Error::none) { + pending->output->mark(Frame_Trace_Marker::readback_started); + auto completed = self->backend->collect(std::move(*pending->backend_frame)); + pending->output->mark(Frame_Trace_Marker::readback_finished); + record_datoviz_trace(pending->output, completed.trace); + Frame_3D_Access::assign_pixels(pending->output, completed.extent, std::move(completed.pixels)); + } else self->backend->discard(std::move(*pending->backend_frame)); + } + self->complete(pending->output); + }, [self](std::exception_ptr value) { self->fail(std::move(value)); }); + if (result != Render_Domain::Admission_Result::none) fail(std::make_exception_ptr(std::runtime_error("render domain stopped before GPU completion collection"))); + } catch (...) { fail(std::current_exception()); } +} +void Async_Render_Backend::Implementation::complete(Frame_3D* frame) { + Frame_Callback callback; + std::optional next; + { + std::lock_guard lock(render_mutex); + callback = frame_callback; + frame_in_flight = false; + if (!pending_submissions.empty() && available.load(std::memory_order_acquire)) { + frame_in_flight = true; + next = std::move(pending_submissions.front()); + pending_submissions.pop_front(); + } + } + if (frame && callback) { frame->mark(Frame_Trace_Marker::callback_started); callback(frame); frame->mark(Frame_Trace_Marker::callback_finished); } + if (next) submit(std::move(*next)); +} +void Async_Render_Backend::render(const Prepared_Visual& visual, Scene_3D_Parameters parameters, Frame_3D* frame) { implementation_->render(visual, parameters, frame); } void Async_Render_Backend::set_frame_callback(Frame_Callback callback) { std::lock_guard lock(implementation_->render_mutex); implementation_->frame_callback = std::move(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 event, Extent viewport) { diff --git a/render_3D/render_3D/detail/Async_Render_Backend.hpp b/render_3D/render_3D/detail/Async_Render_Backend.hpp index f2ea392..15e8037 100644 --- a/render_3D/render_3D/detail/Async_Render_Backend.hpp +++ b/render_3D/render_3D/detail/Async_Render_Backend.hpp @@ -1,17 +1,18 @@ #pragma once +#include "../base/Frame_3D.hpp" #include "Backend_Types.hpp" #include #include namespace aethera::render_3d::detail { class Async_Render_Backend final { public: - using Frame_Callback = std::function)>; + using Frame_Callback = std::function; Async_Render_Backend(std::uint32_t gpu_index, bool validation_enabled, Visual_Family visual_family, Scene_3D_Parameters parameters); ~Async_Render_Backend(); Async_Render_Backend(const Async_Render_Backend&) = delete; Async_Render_Backend& operator=(const Async_Render_Backend&) = delete; - /* 记录最新绘制意图并无等待提交;在途期间的多次调用自动合并为一次后继帧。 */ - void render(const Prepared_Visual& visual, Scene_3D_Parameters parameters); + /* 将绘制意图无等待提交到调用方拥有的帧;frame 必须存活到完成回调返回。 */ + void render(const Prepared_Visual& visual, Scene_3D_Parameters parameters, Frame_3D* frame); /* 设置完成帧出口;回调在 Datoviz Render Domain 线程执行。 */ void set_frame_callback(Frame_Callback callback); [[nodiscard]] bool available() const noexcept; diff --git a/render_3D/render_3D/detail/Datoviz_Visual_Backend.cpp b/render_3D/render_3D/detail/Datoviz_Visual_Backend.cpp index 28c134a..c6f5ece 100644 --- a/render_3D/render_3D/detail/Datoviz_Visual_Backend.cpp +++ b/render_3D/render_3D/detail/Datoviz_Visual_Backend.cpp @@ -1039,11 +1039,7 @@ Datoviz_Visual_Backend::Completed_Frame Datoviz_Visual_Backend::collect( auto collection = target_->collect(); if (pending.trace.observed) pending.trace.readback_ns = trace_now_ns() - readback_started; pending.trace.gpu = std::move(collection.gpu_timing); - auto output = std::make_shared(); - output->extent = pending.extent; - output->sequence = pending.sequence; - output->rgba8 = std::move(collection.pixels); - return {std::move(output), std::move(pending.trace)}; + return {pending.extent, std::move(collection.pixels), std::move(pending.trace)}; } void Datoviz_Visual_Backend::discard(Pending_Frame pending) { require_domain(); diff --git a/render_3D/render_3D/detail/Datoviz_Visual_Backend.hpp b/render_3D/render_3D/detail/Datoviz_Visual_Backend.hpp index ec80735..8b80203 100644 --- a/render_3D/render_3D/detail/Datoviz_Visual_Backend.hpp +++ b/render_3D/render_3D/detail/Datoviz_Visual_Backend.hpp @@ -25,8 +25,9 @@ public: Datoviz_Frame_Trace trace; /* 本帧可选诊断数据。 */ }; struct Completed_Frame { - std::shared_ptr frame; /* 已完成并可跨线程发布的像素帧。 */ - Datoviz_Frame_Trace trace; /* 已补全 GPU 和读回阶段的诊断数据。 */ + Extent extent{}; /* 已完成 GPU 读回的像素尺寸。 */ + std::vector pixels{}; /* 已完成 GPU 读回的连续 RGBA8 像素。 */ + Datoviz_Frame_Trace trace; /* 已补全 GPU 和读回阶段的原始诊断数据。 */ }; Datoviz_Visual_Backend(std::uint32_t gpu_index, bool validation_enabled, Visual_Family visual_family, const Scene_3D_Parameters& initial_scene); diff --git a/render_3D/render_3D/scene/Render_Scene_3D.cpp b/render_3D/render_3D/scene/Render_Scene_3D.cpp index 3d4bb03..cfdd484 100644 --- a/render_3D/render_3D/scene/Render_Scene_3D.cpp +++ b/render_3D/render_3D/scene/Render_Scene_3D.cpp @@ -2,7 +2,7 @@ namespace aethera::render_3d { bool Render_Scene_3D::Prop::operator==(const Prop&) const = default; bool Render_Scene_3D::State::operator==(const State&) const = default; -void Render_Scene_3D::render() { static_cast(*d).dispatch->render(this); } +Render_Scene_3D::Render_Result Render_Scene_3D::render(Frame_3D* frame) { return static_cast(*d).dispatch->render(this, frame); } void Render_Scene_3D::set_frame_callback(Frame_Callback callback) { static_cast(*d).dispatch->set_frame_callback(this, std::move(callback)); } void Render_Scene_3D::dispatch_event(std::unique_ptr event) { static_cast(*d).dispatch->push_event(this, std::move(event)); } void Render_Scene_3D::activate_view() { static_cast(*d).dispatch->set_active(this, true); } diff --git a/render_3D/render_3D/scene/Render_Scene_3D.hpp b/render_3D/render_3D/scene/Render_Scene_3D.hpp index 283b682..21eef64 100644 --- a/render_3D/render_3D/scene/Render_Scene_3D.hpp +++ b/render_3D/render_3D/scene/Render_Scene_3D.hpp @@ -1,4 +1,5 @@ #pragma once +#include "../base/Frame_3D.hpp" #include "../detail/Backend_Types.hpp" #include "../visual/Visuals.hpp" #include @@ -13,7 +14,7 @@ struct Render_Scene_3D : Def)>; - /* 触发绘制;在途期间的多次调用合并为一个使用最新数据的后继帧。 */ - void render(); - /* 安装完成帧回调;回调由 Datoviz Render Domain 线程调用。 */ + enum class Render_Result { submitted, view_inactive, empty_viewport, backend_unavailable }; + using Frame_Callback = std::function; + /* 无等待提交到调用方拥有的帧;frame 必须存活到完成回调返回。 */ + [[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); diff --git a/render_3D/render_3D/scene/Render_Scene_3D.ipp b/render_3D/render_3D/scene/Render_Scene_3D.ipp index 8adba5a..abda014 100644 --- a/render_3D/render_3D/scene/Render_Scene_3D.ipp +++ b/render_3D/render_3D/scene/Render_Scene_3D.ipp @@ -7,10 +7,11 @@ template struct Scene_Paint_Context { std::shared_ptr backend{}; /* Scene 拥有、异步命令延长生命周期的后端。 */ Object* scene{}; /* 仅在 Scene 拥有本上下文期间读取当前 Prop。 */ + Frame_3D* frame{}; /* 当前 Submit 图对应的外部帧;process 返回后清空。 */ }; } struct Render_Scene_3D::Private : Prev_Private { - using Render_Run = void (*)(Root*); + using Render_Run = Render_Result (*)(Root*, Frame_3D*); using Callback_Run = void (*)(Root*, Frame_Callback); using Event_Run = void (*)(Root*, std::unique_ptr); using Active_Run = void (*)(Root*, bool); @@ -22,6 +23,7 @@ struct Render_Scene_3D::Private : Prev_Private { }; std::shared_ptr backend{}; /* Scene 拥有的异步后端;已入队命令自行延长实现寿命。 */ std::shared_ptr paint_context{}; /* Paint 节点读取 Scene 当前 Prop 的生命周期门闩。 */ + Frame_3D* active_frame{}; /* 当前同步 process 借用的外部帧;提交完成后清空。 */ const Dispatch* dispatch{}; /* 最终 Scene 类型对应的静态公开分派表。 */ /* Builder 内部初始化后端;必须在绑定 Visual Paint 目标之前调用一次。 */ template void initialize_backend(Object* object, std::uint32_t gpu_index, bool validation_enabled, Visual_Family visual_family); @@ -31,18 +33,85 @@ struct Render_Scene_3D::Private : Prev_Private { template void process(Object* object, Callback&& callback) requires std::invocable; /* Prepare 开始时交换公共 Scene 事件批次,并无等待提交到 Datoviz 渲染域。 */ void dispatch_events(Extent viewport); + template [[nodiscard]] Render_Result render(Object* object, Frame_3D* frame); template [[nodiscard]] static const Dispatch& dispatch_for(); }; template template Render_Scene_3D::Builder::Builder(Visual_Object* visual_value, std::uint32_t gpu_index_value, bool validation_enabled_value) : Base(), visual(visual_value), visual_family(Visual_Object::Attached_Object::Specification::family), gpu_index(gpu_index_value), validation_enabled(validation_enabled_value) { - bind_visual = [](Root* root, std::shared_ptr context) { auto* object = static_cast(root); Base::private_access(object).template get().bind_paint_target(std::move(context), [](void* raw_context, const detail::Prepared_Visual& prepared) { auto& submission = *static_cast*>(raw_context); const auto& prop = submission.scene->template read_prop(); submission.backend->render(prepared, detail::Scene_3D_Parameters{prop.viewport, prop.clear_color}); }); }; + bind_visual = [](Root* root, std::shared_ptr context) { auto* object = static_cast(root); Base::private_access(object).template get().bind_paint_target(std::move(context), [](void* raw_context, const detail::Prepared_Visual& prepared) { auto& submission = *static_cast*>(raw_context); const auto& prop = submission.scene->template read_prop(); submission.backend->render(prepared, detail::Scene_3D_Parameters{prop.viewport, prop.clear_color}, submission.frame); }); }; attach_visual = [](Object* scene, Root* root) { auto* object = static_cast(root); return scene->template edit_dependency_graph([&](auto& prepare, auto& submit) { prepare.add(object); submit.add(object); }); }; } template std::expected, Dependency_Graph_Error> Render_Scene_3D::Builder::build() { if (!visual || !bind_visual || !attach_visual) throw std::invalid_argument("Render_Scene_3D requires one Visual"); auto result = Base::build(); if (!result) return std::unexpected(result.error()); auto scene = std::move(result).value(); auto& private_data = Base::private_access(scene.get()).template get(); private_data.initialize_backend(scene.get(), gpu_index, validation_enabled, visual_family); bind_visual(visual, private_data.paint_context); auto graph_result = attach_visual(scene.get(), visual); if (!graph_result) return std::unexpected(graph_result.error()); return std::move(scene); } -template void Render_Scene_3D::Private::initialize_backend(Object* object, std::uint32_t gpu_index, bool validation_enabled, Visual_Family visual_family) { const auto& prop = object->template read_prop(); backend = std::make_shared(gpu_index, validation_enabled, visual_family, detail::Scene_3D_Parameters{prop.viewport, prop.clear_color}); paint_context = std::make_shared>(detail::Scene_Paint_Context{backend, object}); } +template void Render_Scene_3D::Private::initialize_backend(Object* object, std::uint32_t gpu_index, bool validation_enabled, Visual_Family visual_family) { const auto& prop = object->template read_prop(); backend = std::make_shared(gpu_index, validation_enabled, visual_family, detail::Scene_3D_Parameters{prop.viewport, prop.clear_color}); paint_context = std::make_shared>(detail::Scene_Paint_Context{backend, object, nullptr}); } inline void Render_Scene_3D::Private::dispatch_events(Extent viewport) { this->consume_events([&](const std::shared_ptr& event) { const auto result = backend->dispatch_event(event, viewport); return result != detail::Async_Render_Backend::Dispatch_Event_Result::queue_full && result != detail::Async_Render_Backend::Dispatch_Event_Result::backend_unavailable; }); } -template void Render_Scene_3D::Private::process(Object* object, Callback&& callback) requires std::invocable { const auto& prop = object->template read_prop(); if (!prop.view_active || prop.viewport.empty() || !backend || !backend->available()) return; dispatch_events(prop.viewport); Prev_Private::process(object, [&](const Scene::Private::Result&) { const auto prepare = object->template current_dependency_graph(); prepare.for_each_bound([](Renderable* renderable, Renderable::Private& data) { if (data.dispatch->state.get(renderable)->prepare_executed) renderable->template mark_dirty(); }); const auto submit = object->template current_dependency_graph(); const auto result = submit.for_each_topological_view([&](const auto& view, const Dependency_Graph::Node& node) { auto* data = view.private_data(node); if (!data) return; Root* root = node.object; auto* dispatch = data->dispatch; auto& state = *dispatch->state.get(root); state.paint_graph_rebuilt = false; state.paint_execution_time_ns = 0; const bool dirty = root->template dirty(); state.paint_dirty = dirty; state.paint_executed = dispatch->paint.predicate(root, dirty); if (!state.paint_executed) return; if (dispatch->paint.builder) { if (!data->paint_graph) data->paint_graph = std::make_unique(); const bool rebuild = dispatch->paint.rebuild_predicate(root); if (!data->paint_graph_built || rebuild) { *data->paint_graph = dispatch->paint.builder(root); data->paint_graph_built = true; state.paint_graph_rebuilt = true; } state.paint_task_count = data->paint_graph->num_tasks(); if (!data->paint_graph->empty()) aethera::detail::run_taskflow(*data->paint_graph); } else { state.paint_task_count = dispatch->paint.run ? 1 : 0; if (dispatch->paint.run) dispatch->paint.run(root); } root->template take_dirty(); dispatch->state.notify(root); }); if (!result) throw std::logic_error("render scene submit graph became invalid during submission"); }); std::invoke(std::forward(callback)); } -template const Render_Scene_3D::Private::Dispatch& Render_Scene_3D::Private::dispatch_for() { static const Dispatch value{[](Root* root) { auto* object = static_cast(root); object->process([] {}); }, [](Root* root, Frame_Callback callback) { auto* object = static_cast(root); auto& data = static_cast(*object->d); if (data.backend) data.backend->set_frame_callback(std::move(callback)); }, [](Root* root, std::unique_ptr event) { auto* object = static_cast(root); static_cast(*object->d).push_event(std::move(event)); }, [](Root* root, bool active) { static_cast(root)->template set<&Prop::view_active>(active); }}; return value; } +template +void Render_Scene_3D::Private::process(Object* object, Callback&& callback) requires std::invocable { + Frame_3D* frame = active_frame; + if (!frame) throw std::logic_error("3D Scene process has no external frame"); + const auto& prop = object->template read_prop(); + 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); + frame->mark(Frame_Trace_Marker::event_dispatch_finished); + auto context = std::static_pointer_cast>(paint_context); + struct Frame_Context_Scope { + Frame_3D*& target; /* Visual Submit 回调读取的当前外部帧槽位。 */ + Frame_3D* previous{}; /* 嵌套调用前的帧;析构时恢复。 */ + ~Frame_Context_Scope() { target = previous; } + } frame_context_scope{context->frame, context->frame}; + context->frame = frame; + Prev_Private::process(object, frame, [&](const Scene::Private::Result&) { + frame->mark(Frame_Trace_Marker::paint_started); + const auto prepare = object->template current_dependency_graph(); + prepare.for_each_bound([](Renderable* renderable, Renderable::Private& data) { if (data.dispatch->state.get(renderable)->prepare_executed) renderable->template mark_dirty(); }); + const auto submit = object->template current_dependency_graph(); + const auto result = submit.for_each_topological_view([&](const auto& view, const Dependency_Graph::Node& node) { + auto* data = view.private_data(node); + if (!data) return; + Root* root = node.object; + auto* dispatch = data->dispatch; + auto& state = *dispatch->state.get(root); + state.paint_graph_rebuilt = false; + state.paint_execution_time_ns = 0; + const bool dirty = root->template dirty(); + state.paint_dirty = dirty; + state.paint_executed = dispatch->paint.predicate(root, dirty); + if (!state.paint_executed) return; + if (dispatch->paint.builder) { + if (!data->paint_graph) data->paint_graph = std::make_unique(); + const bool rebuild = dispatch->paint.rebuild_predicate(root); + if (!data->paint_graph_built || rebuild) { *data->paint_graph = dispatch->paint.builder(root); data->paint_graph_built = true; state.paint_graph_rebuilt = true; } + state.paint_task_count = data->paint_graph->num_tasks(); + if (!data->paint_graph->empty()) aethera::detail::run_taskflow(*data->paint_graph); + } else { state.paint_task_count = dispatch->paint.run ? 1 : 0; if (dispatch->paint.run) dispatch->paint.run(root); } + root->template take_dirty(); + dispatch->state.notify(root); + }); + if (!result) throw std::logic_error("render scene submit graph became invalid during submission"); + frame->mark(Frame_Trace_Marker::paint_finished); + }); + std::invoke(std::forward(callback)); +} +template +Render_Scene_3D::Render_Result Render_Scene_3D::Private::render(Object* object, Frame_3D* frame) { + if (!frame) throw std::invalid_argument("Render_Scene_3D requires a non-null external frame"); + frame->mark(Frame_Trace_Marker::scene_render_requested); + struct Active_Frame_Scope { + Frame_3D*& target; /* 最终 Private 的同步 process 帧槽位。 */ + Frame_3D* previous{}; /* 嵌套调用前的帧;析构时恢复。 */ + ~Active_Frame_Scope() { target = previous; } + } active_frame_scope{active_frame, active_frame}; + active_frame = frame; + bool submitted{}; + object->process([&] { submitted = true; frame->mark(Frame_Trace_Marker::scene_render_finished); }); + if (submitted) return Render_Result::submitted; + const auto& prop = object->template read_prop(); + if (!prop.view_active) return Render_Result::view_inactive; + if (prop.viewport.empty()) return Render_Result::empty_viewport; + return Render_Result::backend_unavailable; +} +template const Render_Scene_3D::Private::Dispatch& Render_Scene_3D::Private::dispatch_for() { static const Dispatch value{[](Root* root, Frame_3D* frame) { auto* object = static_cast(root); return static_cast(*object->d).render(object, frame); }, [](Root* root, Frame_Callback callback) { auto* object = static_cast(root); auto& data = static_cast(*object->d); if (data.backend) data.backend->set_frame_callback(std::move(callback)); }, [](Root* root, std::unique_ptr event) { auto* object = static_cast(root); static_cast(*object->d).push_event(std::move(event)); }, [](Root* root, bool active) { static_cast(root)->template set<&Prop::view_active>(active); }}; return value; } template void Render_Scene_3D::Private::bind_private_crtp(Object* object) { Prev_Private::bind_private_crtp(object); dispatch = &dispatch_for(); } } diff --git a/render_3D/render_3D/visual/Basic_Visual.ipp b/render_3D/render_3D/visual/Basic_Visual.ipp index 91dcf76..1d0b8c1 100644 --- a/render_3D/render_3D/visual/Basic_Visual.ipp +++ b/render_3D/render_3D/visual/Basic_Visual.ipp @@ -26,9 +26,9 @@ struct Basic_Visual::Private : Basic_Visual::Prev_Private { void bind_paint_target(std::shared_ptr context, Enqueue_Paint enqueue); /* CRTP 数据能力:从当前 Prop 构建后端字段,不访问其他缓冲角色。 */ template void prepare_data(Object* object); - /* CRTP 子图能力:返回只负责提交异步后端工作的单节点图;节点不会等待 GPU。 */ + /* CRTP 子图能力:每帧提交一次后端渲染;比较双槽 revision 后直接使用最新 Prepared 数据。 */ template [[nodiscard]] tf::Taskflow build_paint_graph(Object* object, const Prop& prop); - /* CRTP 覆盖:Visual 未绑定 Scene 后端时不执行 Paint。 */ + /* CRTP 覆盖:绑定 Scene 后每帧执行 Paint;dirty 只表示 Prepared 数据是否更新,不控制帧生成。 */ template [[nodiscard]] bool should_paint(Object* object, const State& state, bool dirty); /* CRTP Prop hook:本层字段改变时标记 Prepare。 */ template void after_prop_set(Object* object, Member Owner::* member, Prop_Access props); @@ -42,8 +42,8 @@ template bool Basic_Visual::State::operator==(const State& template bool Basic_Visual::Private::valid_items(const std::vector& items) { return std::ranges::all_of(items, [](const Item& item) { return Spec::valid(item); }); } template void Basic_Visual::Private::bind_paint_target(std::shared_ptr context, Enqueue_Paint enqueue) { paint_target = {std::move(context), enqueue}; } template template void Basic_Visual::Private::prepare_data(Object* object) { using Visual_Tag = typename Basic_Visual::Base_Tag; const auto& prop = object->template read_prop(); if (!valid_items(prop.items) || !detail::finite(prop.transform)) throw std::invalid_argument("3D visual property contains invalid data"); auto& output = object->template pending_buffer(); output = {}; output.family = Spec::family; output.transform = prop.transform; output.visible = prop.visible; output.depth_test = prop.depth_test; output.revision = next_revision++; if constexpr (requires { prop.style; }) output.point_style = prop.style; Spec::prepare(prop.items, output); object->template update_state<&State::prepared_item_count, &State::prepared_revision>([&](State_Access states) { auto& state = states.template get(); state.prepared_item_count = output.positions.size(); state.prepared_revision = output.revision; }); } -template template tf::Taskflow Basic_Visual::Private::build_paint_graph(Object* object, const Prop&) { tf::Taskflow graph; graph.emplace([this, object] { if (auto context = paint_target.context.lock(); context && paint_target.enqueue) paint_target.enqueue(context.get(), object->template pending_buffer()); }).name("render_3d.paint.enqueue"); return graph; } -template template bool Basic_Visual::Private::should_paint(Object*, const State&, bool dirty) { return dirty && paint_target.enqueue != nullptr && !paint_target.context.expired(); } +template template tf::Taskflow Basic_Visual::Private::build_paint_graph(Object* object, const Prop&) { tf::Taskflow graph; graph.emplace([this, object] { if (auto context = paint_target.context.lock(); context && paint_target.enqueue) { const auto& pending = object->template pending_buffer(); const auto& current = object->template current_buffer(); paint_target.enqueue(context.get(), pending.revision >= current.revision ? pending : current); } }).name("render_3d.paint.enqueue"); return graph; } +template template bool Basic_Visual::Private::should_paint(Object*, const State&, bool) { return paint_target.enqueue != nullptr && !paint_target.context.expired(); } template template void Basic_Visual::Private::after_prop_set(Object* object, Member Owner::*, Prop_Access) { if constexpr (std::same_as) object->template mark_dirty(); } template template void Basic_Visual::Private::before_advance(Object*, Prop_Type*, State_Access pending_states, const Prop_Type* current_prop, State_Access) { using Visual_Tag = typename Basic_Visual::Base_Tag; pending_states.template get().item_count = static_cast(*current_prop).items.size(); } template template const typename Basic_Visual::Private::Dispatch& Basic_Visual::Private::dispatch_for() { using Visual_Tag = typename Basic_Visual::Base_Tag; static const Dispatch value{[](Root* root, std::vector items) { if (!Private::valid_items(items)) return Update_Items_Result::invalid_data; static_cast(root)->template set<&Prop::items>(std::move(items)); return Update_Items_Result::updated; }, [](Root* root, const std::function&)>& edit) { if (!edit) return Edit_Items_Result::empty_edit; auto* object = static_cast(root); auto items = object->template read_prop().items; edit(items); if (!Private::valid_items(items)) return Edit_Items_Result::invalid_data; object->template set<&Prop::items>(std::move(items)); return Edit_Items_Result::updated; }, [](const Root* root) { return static_cast(root)->template read_prop().items.size(); }}; return value; } diff --git a/render_3D/tests/Visual_Tests.cpp b/render_3D/tests/Visual_Tests.cpp index d9a30e9..af70ac0 100644 --- a/render_3D/tests/Visual_Tests.cpp +++ b/render_3D/tests/Visual_Tests.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include #include @@ -57,14 +58,30 @@ TEST(Render_3D_Scene, Paint_Submits_Without_Waiting_For_Gpu) { ASSERT_TRUE(scene_result.has_value()); auto scene = std::move(scene_result).value(); std::atomic_bool frame_ready{}; - std::shared_ptr frame; - scene->set_frame_callback([&](std::shared_ptr value) { frame = std::move(value); frame_ready.store(true, std::memory_order_release); }); + Frame_3D frame{Frame_Identity{1, 0}}; + Frame_3D unchanged_frame{Frame_Identity{2, 0}}; + Frame_3D exchanged_unchanged_frame{Frame_Identity{3, 0}}; + Frame_3D* completed_frame{}; + scene->set_frame_callback([&](Frame_3D* value) { completed_frame = value; frame_ready.store(true, std::memory_order_release); }); const auto started = std::chrono::steady_clock::now(); - scene->render(); + EXPECT_EQ(scene->render(&frame), Render_Scene_3D::Render_Result::submitted); const auto submit_duration = std::chrono::steady_clock::now() - started; EXPECT_LT(submit_duration, std::chrono::seconds(1)); for (std::size_t attempt = 0; attempt != 500 && !frame_ready.load(std::memory_order_acquire); ++attempt) std::this_thread::sleep_for(std::chrono::milliseconds(10)); - EXPECT_NE(frame, nullptr); + EXPECT_EQ(completed_frame, &frame); + EXPECT_FALSE(frame.pixels().empty()); + frame_ready.store(false, std::memory_order_release); + completed_frame = nullptr; + EXPECT_EQ(scene->render(&unchanged_frame), Render_Scene_3D::Render_Result::submitted); + for (std::size_t attempt = 0; attempt != 500 && !frame_ready.load(std::memory_order_acquire); ++attempt) std::this_thread::sleep_for(std::chrono::milliseconds(10)); + EXPECT_EQ(completed_frame, &unchanged_frame); + EXPECT_FALSE(unchanged_frame.pixels().empty()); + frame_ready.store(false, std::memory_order_release); + completed_frame = nullptr; + EXPECT_EQ(scene->render(&exchanged_unchanged_frame), Render_Scene_3D::Render_Result::submitted); + for (std::size_t attempt = 0; attempt != 500 && !frame_ready.load(std::memory_order_acquire); ++attempt) std::this_thread::sleep_for(std::chrono::milliseconds(10)); + EXPECT_EQ(completed_frame, &exchanged_unchanged_frame); + EXPECT_TRUE(std::ranges::equal(unchanged_frame.pixels(), exchanged_unchanged_frame.pixels())); } catch (const std::exception& error) { GTEST_SKIP() << "Vulkan/Datoviz unavailable: " << error.what(); diff --git a/web_server/src/Graph_WebSocket.cpp b/web_server/src/Graph_WebSocket.cpp index f12aed9..6cde83e 100644 --- a/web_server/src/Graph_WebSocket.cpp +++ b/web_server/src/Graph_WebSocket.cpp @@ -18,7 +18,7 @@ struct Graph_WebSocket::Private { }; Graph_WebSocket::Graph_WebSocket(drogon::WebSocketConnectionPtr connection, std::shared_ptr plot) : d(std::make_unique()) { d->connection = connection; d->plot = std::move(plot); d->owner = connection.get(); } Graph_WebSocket::~Graph_WebSocket() { close(); } -void Graph_WebSocket::start() { if (d->attached) return; const auto weak = weak_from_this(); d->plot->attach(d->owner, [weak](std::string pixels) { const auto socket = weak.lock(); if (!socket) return; const auto connection = socket->d->connection.lock(); if (connection && connection->connected()) connection->send(pixels.data(), pixels.size(), drogon::WebSocketMessageType::Binary); }); d->attached = true; } +void Graph_WebSocket::start() { if (d->attached) return; const auto weak = weak_from_this(); d->plot->attach(d->owner, [weak](std::shared_ptr frame) { const auto socket = weak.lock(); if (!socket) return; const auto connection = socket->d->connection.lock(); if (!connection || !connection->connected()) return; connection->send(frame->metadata, drogon::WebSocketMessageType::Text); connection->send(frame->pixels.data(), frame->pixels.size(), drogon::WebSocketMessageType::Binary); }); d->attached = true; } void Graph_WebSocket::receive(std::string_view message) { const auto json = nlohmann::json::parse(message, nullptr, false); if (json.is_discarded() || !json.is_object()) return; @@ -26,14 +26,16 @@ void Graph_WebSocket::receive(std::string_view message) { if (kind == json.end() || !kind->is_string() || (*kind != "frame" && *kind != "input")) return; try { - Plot_Event event; + Plot_Frame_Request request; + if (const auto value = json.find("request_id"); value != json.end() && value->is_number_unsigned()) + request.correlation_id = value->get(); if (const auto value = json.find("time"); value != json.end() && value->is_number()) - event.time_milliseconds = value->get(); + request.time_milliseconds = value->get(); if (const auto viewport = json.find("viewport"); viewport != json.end() && viewport->is_object()) { if (const auto value = viewport->find("width"); value != viewport->end() && value->is_number_unsigned()) - event.width = std::clamp(value->get(), 160U, 1920U); + request.width = std::clamp(value->get(), 160U, 1920U); if (const auto value = viewport->find("height"); value != viewport->end() && value->is_number_unsigned()) - event.height = std::clamp(value->get(), 120U, 1080U); + request.height = std::clamp(value->get(), 120U, 1080U); } if (*kind == "input") { const auto input = json.find("event"); @@ -47,8 +49,8 @@ void Graph_WebSocket::receive(std::string_view message) { if (value == input->end() || !value->is_object()) return; const auto x = value->value("x", 0.0); const auto y = value->value("y", 0.0); - point.x = viewport_relative ? std::clamp(x, 0.0, static_cast(event.width)) : x; - point.y = viewport_relative ? std::clamp(y, 0.0, static_cast(event.height)) : y; + point.x = viewport_relative ? std::clamp(x, 0.0, static_cast(request.width)) : x; + point.y = viewport_relative ? std::clamp(y, 0.0, static_cast(request.height)) : y; }; read_point("position", decoded.position, true); read_point("global_position", decoded.global_position, false); @@ -62,9 +64,10 @@ void Graph_WebSocket::receive(std::string_view message) { decoded.key = magic_enum::enum_cast(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); - event.input = decoded; + d->plot->submit_input(std::move(decoded)); + return; } - d->plot->submit(event); + d->plot->submit_frame(d->owner, std::move(request)); } catch (const nlohmann::json::exception&) { return; } diff --git a/web_server/src/Plot.cpp b/web_server/src/Plot.cpp index 2fee515..4dac740 100644 --- a/web_server/src/Plot.cpp +++ b/web_server/src/Plot.cpp @@ -3,16 +3,17 @@ #include #include #include +#include #include #include #include #include #include +#include #include #include -#include #include -#include +#include #include #include #include @@ -33,23 +34,104 @@ using Frequency_Axis_Object = Impl; using Numeric_Axis_Object = Impl; using Time_Axis_Object = Impl; using Selection_Object = Impl; +constexpr std::uint16_t frame_protocol_version{4}; -template -void append_binary(std::string& output, Integer value) { - const auto start = output.size(); - output.resize(start + sizeof(Integer)); - std::memcpy(output.data() + start, &value, sizeof(Integer)); +enum class Frame_Pacing_Mode : std::uint32_t { + manual, + fixed_rate, + minimum_latency, + maximum_rate +}; + +struct Frame_Pacing_Properties { + Frame_Pacing_Mode mode{Frame_Pacing_Mode::fixed_rate}; /* 控制后继 render 请求节奏的策略。 */ + double fixed_rate_fps{30.0}; /* 固定频率策略的目标帧率,单位为 FPS。 */ + double minimum_latency_headroom{1.25}; /* 最低延迟策略相对 P95 生成耗时的安全系数。 */ +}; + +class Frame_Policy final { +public: + [[nodiscard]] Frame_Pacing_Properties snapshot() const; + [[nodiscard]] nlohmann::json schema() const; + [[nodiscard]] nlohmann::json write_prop(std::string_view key, const nlohmann::json& value); +private: + mutable std::mutex mutex; /* 保护异步帧发布与 HTTP 属性编辑读取。 */ + Frame_Pacing_Properties pacing{}; /* 可编辑帧调度属性的唯一权威来源。 */ +}; + +std::string_view pacing_mode_name(Frame_Pacing_Mode mode) { + switch (mode) { + case Frame_Pacing_Mode::manual: return "manual"; + case Frame_Pacing_Mode::fixed_rate: return "fixed_rate"; + case Frame_Pacing_Mode::minimum_latency: return "minimum_latency"; + case Frame_Pacing_Mode::maximum_rate: return "maximum_rate"; + } + throw std::logic_error("unknown frame pacing mode"); } -std::string encode_frame(Image_View image, std::uint64_t sequence) { +std::optional parse_pacing_mode(std::string_view value) { + if (value == "manual") return Frame_Pacing_Mode::manual; + if (value == "fixed_rate") return Frame_Pacing_Mode::fixed_rate; + if (value == "minimum_latency") return Frame_Pacing_Mode::minimum_latency; + if (value == "maximum_rate") return Frame_Pacing_Mode::maximum_rate; + return std::nullopt; +} + +Frame_Pacing_Properties Frame_Policy::snapshot() const { std::lock_guard lock(mutex); return pacing; } +nlohmann::json Frame_Policy::schema() const { + std::lock_guard lock(mutex); + nlohmann::json fields = nlohmann::json::array(); + fields.push_back({{"key", "pacing_mode"}, {"label", "帧刷新策略"}, {"editor", "select"}, {"editable", true}, {"description", "选择浏览器如何安排下一次 render 调用。"}, {"technical_description", "Controls client-side render cadence using end-to-end samples computed by the browser."}, {"value", pacing_mode_name(pacing.mode)}, {"options", nlohmann::json::array({{{"value", "manual"}, {"label", "手动刷新"}}, {{"value", "fixed_rate"}, {"label", "固定频率"}}, {{"value", "minimum_latency"}, {"label", "最低延迟"}}, {{"value", "maximum_rate"}, {"label", "最高频率"}}})}}); + fields.push_back({{"key", "fixed_rate_fps"}, {"label", "固定目标帧率"}, {"editor", "number"}, {"editable", true}, {"description", "固定频率策略下每秒发起的 render 次数。"}, {"technical_description", "Target render request rate used by fixed_rate pacing, in frames per second."}, {"value", pacing.fixed_rate_fps}}); + fields.push_back({{"key", "minimum_latency_headroom"}, {"label", "最低延迟余量"}, {"editor", "number"}, {"editable", true}, {"description", "最低延迟策略使用的浏览器端 P95 端到端耗时安全系数。"}, {"technical_description", "Multiplier applied to browser-computed P95 request-to-pixel latency before scheduling the next render request."}, {"value", pacing.minimum_latency_headroom}}); + return {{"id", "frame-runtime"}, {"label", "帧策略与诊断"}, {"kind", "runtime"}, {"fields", std::move(fields)}, {"state", nlohmann::json::object()}}; +} + +nlohmann::json Frame_Policy::write_prop(std::string_view key, const nlohmann::json& value) { + std::lock_guard lock(mutex); + if (key == "pacing_mode") { + if (!value.is_string()) return {{"success", false}, {"error", "pacing_mode requires a string"}}; + const auto parsed = parse_pacing_mode(value.get_ref()); + if (!parsed) return {{"success", false}, {"error", "unknown frame pacing mode"}}; + pacing.mode = *parsed; + return {{"success", true}, {"component", "frame-runtime"}, {"key", key}, {"value", pacing_mode_name(pacing.mode)}}; + } + if (key == "fixed_rate_fps") { + if (!value.is_number()) return {{"success", false}, {"error", "fixed_rate_fps requires a number"}}; + const double next = value.get(); + if (!std::isfinite(next) || next < 0.1 || next > 240.0) return {{"success", false}, {"error", "fixed_rate_fps must be between 0.1 and 240"}}; + pacing.fixed_rate_fps = next; + return {{"success", true}, {"component", "frame-runtime"}, {"key", key}, {"value", pacing.fixed_rate_fps}}; + } + if (key == "minimum_latency_headroom") { + if (!value.is_number()) return {{"success", false}, {"error", "minimum_latency_headroom requires a number"}}; + const double next = value.get(); + if (!std::isfinite(next) || next < 1.0 || next > 4.0) return {{"success", false}, {"error", "minimum_latency_headroom must be between 1 and 4"}}; + pacing.minimum_latency_headroom = next; + return {{"success", true}, {"component", "frame-runtime"}, {"key", key}, {"value", pacing.minimum_latency_headroom}}; + } + return {{"success", false}, {"error", "unknown frame runtime property"}}; +} + +nlohmann::json frame_metadata(Render_Frame& frame, std::uint32_t width, std::uint32_t height, std::size_t byte_length, const Frame_Pacing_Properties& pacing) { + nlohmann::json markers = nlohmann::json::object(); + for (const auto& point : frame.trace_points()) markers[std::string(magic_enum::enum_name(point.marker))] = point.elapsed_ns; + nlohmann::json measurements = nlohmann::json::object(); + for (const auto& value : frame.trace_values()) measurements[std::string(magic_enum::enum_name(value.measurement))] = value.value_ns; + const auto identity = frame.identity(); + return {{"kind", "frame_metadata"}, {"protocol", "aethera.frame"}, {"version", frame_protocol_version}, + {"sequence", identity.sequence}, {"correlation_id", identity.correlation_id}, + {"created_time_unix_ms", static_cast(frame.created_time_unix_ns()) / 1'000'000.0}, + {"pixel", {{"width", width}, {"height", height}, {"format", "rgba8"}, {"byte_length", byte_length}}}, + {"pacing", {{"mode", pacing_mode_name(pacing.mode)}, {"fixed_rate_fps", pacing.fixed_rate_fps}, {"minimum_latency_headroom", pacing.minimum_latency_headroom}}}, + {"trace", {{"clock", "steady_elapsed_ns"}, {"markers", std::move(markers)}, {"measurements", std::move(measurements)}}}}; +} + +std::shared_ptr encode_frame(Frame_2D* frame, const Frame_Pacing_Properties& pacing) { + frame->mark(Frame_Trace_Marker::websocket_publish_started); + const Image_View image = frame->image(); std::string output; - output.reserve(24 + static_cast(image.width) * image.height * 4); - append_binary(output, std::uint32_t{0x41544852}); - append_binary(output, std::uint16_t{1}); - append_binary(output, std::uint16_t{}); - append_binary(output, static_cast(image.width)); - append_binary(output, static_cast(image.height)); - append_binary(output, sequence); + output.reserve(static_cast(image.width) * image.height * 4); for (int y = 0; y < image.height; ++y) { const auto* row = reinterpret_cast( image.data + static_cast(y) * image.stride); @@ -61,36 +143,42 @@ std::string encode_frame(Image_View image, std::uint64_t sequence) { output.push_back(static_cast(pixel[3])); } } - return output; + frame->mark(Frame_Trace_Marker::websocket_publish_finished); + auto message = std::make_shared(); + message->pixels = std::move(output); + message->metadata = frame_metadata(*frame, static_cast(image.width), static_cast(image.height), message->pixels.size(), pacing).dump(); + return message; } -std::string encode_frame(const Pixel_Frame& frame, std::uint64_t sequence) { - std::string output; - output.reserve(24 + frame.rgba8.size()); - append_binary(output, std::uint32_t{0x41544852}); - append_binary(output, std::uint16_t{1}); - append_binary(output, std::uint16_t{}); - append_binary(output, frame.extent.width); - append_binary(output, frame.extent.height); - append_binary(output, sequence); - output.append(reinterpret_cast(frame.rgba8.data()), frame.rgba8.size()); - return output; +std::shared_ptr encode_frame(Frame_3D* frame, const Frame_Pacing_Properties& pacing) { + frame->mark(Frame_Trace_Marker::websocket_publish_started); + const auto pixels = frame->pixels(); + auto message = std::make_shared(); + message->pixels.assign(reinterpret_cast(pixels.data()), pixels.size()); + frame->mark(Frame_Trace_Marker::websocket_publish_finished); + const auto extent = frame->extent(); + message->metadata = frame_metadata(*frame, extent.width, extent.height, message->pixels.size(), pacing).dump(); + return message; } struct Schema_Query { Plot::Json_Handler handler; }; +struct Frame_Submission { + const void* owner{}; /* 只向发起 render 的 WebSocket 连接返回完成帧。 */ + Plot_Frame_Request request{}; /* 浏览器帧请求及关联标识。 */ +}; struct Prop_Write { std::string component; std::string key; nlohmann::json value; Plot::Json_Handler handler; }; -using Plot_Input = std::variant; +using Plot_Input = std::variant; template class Scene_View_Model final : public Plot::Scene_View { public: Scene_View_Model(std::vector> value_descriptors, - std::function value_update, + std::function value_update, Owned_Objects... owned_objects) : descriptors(std::move(value_descriptors)), update_scene(std::move(value_update)), @@ -108,11 +196,11 @@ public: result["component"] = component; return result; } - void update(const Plot_Event& event) override { update_scene(event); } + void update(const Plot_Frame_Request& request) override { update_scene(request); } private: std::vector> descriptors; - std::function update_scene; + std::function update_scene; std::tuple objects; }; @@ -227,7 +315,7 @@ template std::unique_ptr make_scene_view( Object& object, Scene_2D& scene, - std::function update, + std::function update, Owned_Objects&&... owned_objects) { using Definition = typename Object::Attached_Object; using Tag = typename Definition::Base_Tag; @@ -390,9 +478,8 @@ std::shared_ptr make_axes_plot(asio::any_io_executor executor) { paint.add(frequency.get()); paint.add(numeric.get()); paint.add(time.get()); }); if (!topology) throw std::logic_error("axis gallery topology is invalid"); - auto update = [scene = scene.get(), frequency = frequency.get(), numeric = numeric.get(), time = time.get()](const Plot_Event& event) { + auto update = [scene = scene.get(), frequency = frequency.get(), numeric = numeric.get(), time = time.get()](const Plot_Frame_Request& event) { resize_axes(scene, {static_cast(event.width), static_cast(event.height)}, frequency, numeric, time); - if (event.input) return; constexpr double day_milliseconds = 86'400'000.0; time->append_time(Time_Of_Day{static_cast( std::fmod(std::max(0.0, event.time_milliseconds), day_milliseconds))}); @@ -425,9 +512,8 @@ std::shared_ptr make_spectrum_plot(asio::any_io_executor executor) { scene_builder.add_renderable(selection.get()); auto scene = *scene_builder.build(); place_selection_over(scene.get(), selection.get(), spectrum.get()); - auto update = [scene = scene.get(), raw = spectrum.get(), frequency = frequency.get(), vertical = vertical.get()](const Plot_Event& event) { + auto update = [scene = scene.get(), raw = spectrum.get(), frequency = frequency.get(), vertical = vertical.get()](const Plot_Frame_Request& event) { resize_axes(scene, {static_cast(event.width), static_cast(event.height)}, frequency, vertical); - if (event.input) return; std::array samples{}; for (std::size_t i = 0; i < samples.size(); ++i) { const double x = static_cast(i) / samples.size(); @@ -485,9 +571,8 @@ std::shared_ptr make_frequency_trace_plot(asio::any_io_executor executor) scene_builder.add_renderable(selection.get()); auto scene = *scene_builder.build(); place_selection_over(scene.get(), selection.get(), trace.get()); - auto update = [scene = scene.get(), raw = trace.get(), time = time.get(), vertical = vertical.get()](const Plot_Event& event) { + auto update = [scene = scene.get(), raw = trace.get(), time = time.get(), vertical = vertical.get()](const Plot_Frame_Request& event) { resize_axes(scene, {static_cast(event.width), static_cast(event.height)}, time, vertical); - if (event.input) return; constexpr double day_milliseconds = 86'400'000.0; const auto tick = time->append_time(Time_Of_Day{static_cast( std::fmod(std::max(0.0, event.time_milliseconds), day_milliseconds))}); @@ -525,23 +610,18 @@ std::shared_ptr make_sweep_spectrum_plot(asio::any_io_executor executor) { scene_builder.add_renderable(selection.get()); auto scene = *scene_builder.build(); place_selection_over(scene.get(), selection.get(), sweep.get()); - auto update = [scene = scene.get(), raw = sweep.get(), frequency = frequency.get(), vertical = vertical.get(), block_index = std::size_t{}](const Plot_Event& event) mutable { + auto update = [scene = scene.get(), raw = sweep.get(), frequency = frequency.get(), vertical = vertical.get()](const Plot_Frame_Request& event) { resize_axes(scene, {static_cast(event.width), static_cast(event.height)}, frequency, vertical); - if (event.input) return; const auto& state = raw->template read_prop(); const std::size_t block_count = std::max(1, state.block_count); const std::size_t bins_per_block = std::max(1, state.bins_per_block); - if (block_index >= block_count) { - raw->template set<&Sweep_Spectrum::Prop::blocks>(std::vector>{}); - block_index = 0; - } + const std::size_t block_index = state.blocks.size() < block_count ? state.blocks.size() : state.next_block_index % block_count; std::vector values(bins_per_block); for (std::size_t i = 0; i < values.size(); ++i) { const auto sweep_index = block_index * values.size() + i; values[i] = -90.0 + 35.0 * std::sin(sweep_index * 0.08 + event.time_milliseconds * 0.002); } raw->append_block(values); - ++block_index; }; auto view = make_scene_view< Prop_Field<&Sweep_Spectrum::Prop::bins_per_block, "bins_per_block", "Number of frequency bins stored in each incoming sweep block.">, @@ -553,10 +633,10 @@ std::shared_ptr make_sweep_spectrum_plot(asio::any_io_executor executor) { Prop_Field<&Sweep_Spectrum::Prop::pen, "pen", "Stroke style used for the completed sweep curve.">, Prop_Field<&Sweep_Spectrum::Prop::current_frequency_pen, "current_frequency_pen", "Stroke style used for the current sweep-frequency indicator.">, Prop_Field<&Sweep_Spectrum::Prop::interpolation_mode, "interpolation_mode", "Selects interpolation between adjacent sweep bins.">, - Prop_Field<&Sweep_Spectrum::Prop::blocks, "blocks", "Current collection of incremental sweep blocks.">, - State_Field, - State_Field, - State_Field>( + Prop_Field<&Sweep_Spectrum::Prop::blocks, "blocks", "Latest data stored in each fixed frequency-segment slot.">, + State_Field, + State_Field, + State_Field>( *sweep, *scene, std::move(update), std::move(frequency), std::move(vertical), std::move(sweep), std::move(selection)); return std::make_shared(std::move(executor), std::move(scene), std::move(view)); } @@ -580,9 +660,8 @@ std::shared_ptr make_afterglow_plot(asio::any_io_executor executor) { scene_builder.add_renderable(selection.get()); auto scene = *scene_builder.build(); place_selection_over(scene.get(), selection.get(), afterglow.get()); - auto update = [scene = scene.get(), raw = afterglow.get(), frequency = frequency.get(), vertical = vertical.get()](const Plot_Event& event) { + auto update = [scene = scene.get(), raw = afterglow.get(), frequency = frequency.get(), vertical = vertical.get()](const Plot_Frame_Request& event) { resize_axes(scene, {static_cast(event.width), static_cast(event.height)}, frequency, vertical); - if (event.input) return; std::array values{}; for (std::size_t i = 0; i < values.size(); ++i) values[i] = -95.0 + 62.0 * std::exp(-220.0 * std::pow( @@ -626,9 +705,8 @@ std::shared_ptr make_waterfall_plot(asio::any_io_executor executor) { scene_builder.add_renderable(selection.get()); auto scene = *scene_builder.build(); place_selection_over(scene.get(), selection.get(), waterfall.get()); - auto update = [scene = scene.get(), raw = waterfall.get(), frequency = frequency.get(), time = time.get()](const Plot_Event& event) { + auto update = [scene = scene.get(), raw = waterfall.get(), frequency = frequency.get(), time = time.get()](const Plot_Frame_Request& event) { resize_axes(scene, {static_cast(event.width), static_cast(event.height)}, frequency, time); - if (event.input) return; std::array values{}; for (std::size_t i = 0; i < values.size(); ++i) values[i] = -100.0 + 70.0 * std::exp(-240.0 * std::pow( @@ -679,9 +757,8 @@ std::shared_ptr make_constellation_plot(asio::any_io_executor executor) { scene_builder.add_renderable(selection.get()); auto scene = *scene_builder.build(); place_selection_over(scene.get(), selection.get(), constellation.get()); - auto update = [scene = scene.get(), raw = constellation.get(), horizontal = horizontal.get(), vertical = vertical.get()](const Plot_Event& event) { + auto update = [scene = scene.get(), raw = constellation.get(), horizontal = horizontal.get(), vertical = vertical.get()](const Plot_Frame_Request& event) { resize_axes(scene, {static_cast(event.width), static_cast(event.height)}, horizontal, vertical); - if (event.input) return; const auto& state = raw->template read_prop(); const int anchor_count = static_cast(state.type); const double radius = std::min(state.i_range.size(), state.q_range.size()) * 0.4; @@ -724,7 +801,7 @@ std::shared_ptr make_selection_overlay_plot(asio::any_io_executor executor .set(&Render_Scene_2D::Prop::view_active, true); scene_builder.add_renderable(selection.get()); auto scene = *scene_builder.build(); - auto update = [scene = scene.get(), horizontal = horizontal.get(), vertical = vertical.get()](const Plot_Event& event) { + auto update = [scene = scene.get(), horizontal = horizontal.get(), vertical = vertical.get()](const Plot_Frame_Request& event) { resize_axes(scene, {static_cast(event.width), static_cast(event.height)}, horizontal, vertical); }; auto view = make_scene_view< @@ -743,33 +820,119 @@ std::shared_ptr make_selection_overlay_plot(asio::any_io_executor executor struct Plot::Private { using Scene = std::variant, std::unique_ptr>; - asio::strand strand; - asio::experimental::concurrent_channel inputs; - Scene scene; - std::unique_ptr view; - std::once_flag start_once; - std::mutex handlers_mutex; - std::unordered_map handlers; - std::atomic_uint64_t frame_sequence{}; + using Frame = std::variant, std::unique_ptr>; + struct Managed_Frame { + const void* owner{}; /* 发起本帧的 WebSocket 连接身份。 */ + Plot_Frame_Request request{}; /* 创建本帧的浏览器请求及 viewport。 */ + Frame frame{}; /* Web 层唯一拥有并传给 Scene 的外部帧。 */ + }; + asio::strand strand; /* 串行执行帧请求、属性写入和 Schema 查询。 */ + asio::experimental::concurrent_channel inputs; /* 不承载输入事件的异步命令通道。 */ + std::unique_ptr view; /* 使用层数据推进及反射描述实现;拥有 Scene 引用的图元。 */ + Scene scene; /* 当前 Plot 唯一拥有的 2D 或 3D Scene;析构先于 view 所有图元。 */ + std::once_flag start_once; /* 保证回调与协程只安装一次。 */ + std::mutex handlers_mutex; /* 保护跨 Drogon 连接线程修改的订阅表。 */ + std::unordered_map handlers; /* 以连接身份索引的完成帧订阅。 */ + std::uint64_t next_frame_sequence{1}; /* 下一外部帧使用的单调序号;只在 strand 访问。 */ + Frame_Policy frame_policy{}; /* 仅保存可编辑刷新策略,不保存衍生统计。 */ + std::optional active_frame{}; /* 当前由 Scene/异步后端借用指针的外部帧。 */ + std::deque pending_order{}; /* 按首次等待顺序保存连接身份,避免连接间饥饿。 */ + std::unordered_map pending_frames{}; /* 每个连接只保留最新一个尚未提交 Scene 的外部帧。 */ + bool frame_in_flight{}; /* Scene 是否已有一次尚未完成回调的帧。 */ template Private(asio::any_io_executor executor, std::unique_ptr value_scene, std::unique_ptr value_view) : strand(asio::make_strand(std::move(executor))), inputs(strand, 32), - scene(std::move(value_scene)), view(std::move(value_view)) {} + view(std::move(value_view)), scene(std::move(value_scene)) {} - void publish(std::string pixels) { - std::vector outputs; + void publish(const void* owner, std::shared_ptr frame) { + Frame_Handler output; { std::lock_guard lock(handlers_mutex); - outputs.reserve(handlers.size()); - for (const auto& [owner, handler] : handlers) outputs.push_back(handler); + const auto found = handlers.find(owner); + if (found != handlers.end()) output = found->second; } - for (auto& output : outputs) output(pixels); + if (output) output(std::move(frame)); } + + [[nodiscard]] nlohmann::json schema() const; + [[nodiscard]] Managed_Frame make_frame(Frame_Submission submission); + void request_frame(Frame_Submission submission); + void render_frame(Managed_Frame frame); + void publish_completed_frame(Render_Frame* frame); + void frame_completed(); }; +nlohmann::json Plot::Private::schema() const { + auto result = view->schema(); + result["components"].push_back(frame_policy.schema()); + return result; +} + +Plot::Private::Managed_Frame Plot::Private::make_frame(Frame_Submission submission) { + const Frame_Identity identity{next_frame_sequence++, submission.request.correlation_id}; + if (std::holds_alternative>(scene)) return {submission.owner, std::move(submission.request), std::make_unique(identity)}; + return {submission.owner, std::move(submission.request), std::make_unique(identity)}; +} + +void Plot::Private::request_frame(Frame_Submission submission) { + auto frame = make_frame(std::move(submission)); + if (frame_in_flight) { + const auto found = pending_frames.find(frame.owner); + if (found != pending_frames.end()) found->second = std::move(frame); + else { + const auto owner = frame.owner; + pending_order.push_back(owner); + pending_frames.emplace(owner, std::move(frame)); + } + return; + } + render_frame(std::move(frame)); +} + +void Plot::Private::render_frame(Managed_Frame frame) { + frame_in_flight = true; + active_frame = std::move(frame); + auto& request = active_frame->request; + request.width = std::clamp(request.width, 160U, 1920U); + request.height = std::clamp(request.height, 120U, 1080U); + view->update(request); + if (auto* scene_2d = std::get_if>(&scene)) { + (*scene_2d)->set<&Render_Scene_2D::Prop::viewport>(Size{static_cast(request.width), static_cast(request.height)}); + const auto result = (*scene_2d)->render(std::get>(active_frame->frame).get()); + if (result != Render_Scene_2D::Render_Result::completed) asio::post(strand, [this] { frame_completed(); }); + return; + } + auto& scene_3d = std::get>(scene); + scene_3d->set<&Render_Scene_3D::Prop::viewport>(Extent{request.width, request.height}); + const auto result = scene_3d->render(std::get>(active_frame->frame).get()); + if (result != Render_Scene_3D::Render_Result::submitted) asio::post(strand, [this] { frame_completed(); }); +} + +void Plot::Private::publish_completed_frame(Render_Frame* frame) { + if (!active_frame) throw std::logic_error("frame callback has no externally owned active frame"); + const auto pacing = frame_policy.snapshot(); + if (auto* frame_2d = std::get_if>(&active_frame->frame); frame_2d && frame_2d->get() == frame) publish(active_frame->owner, encode_frame(frame_2d->get(), pacing)); + else if (auto* frame_3d = std::get_if>(&active_frame->frame); frame_3d && frame_3d->get() == frame) publish(active_frame->owner, encode_frame(frame_3d->get(), pacing)); + else throw std::logic_error("frame callback does not match the externally owned active frame"); + frame_completed(); +} + +void Plot::Private::frame_completed() { + active_frame.reset(); + frame_in_flight = false; + while (!pending_order.empty()) { + const auto owner = pending_order.front(); + pending_order.pop_front(); + auto next = pending_frames.extract(owner); + if (next.empty()) continue; + render_frame(std::move(next.mapped())); + return; + } +} + Plot::Plot(asio::any_io_executor executor, std::unique_ptr scene, std::unique_ptr view) @@ -786,18 +949,18 @@ void Plot::ensure_started() { std::call_once(d->start_once, [this] { auto self = shared_from_this(); if (auto* scene = std::get_if>(&d->scene)) { - (*scene)->set_frame_callback([weak = weak_from_this()](Image_View image) { + (*scene)->set_frame_callback([weak = weak_from_this()](Frame_2D* frame) { if (auto owner = weak.lock()) { - const auto sequence = owner->d->frame_sequence.fetch_add(1, std::memory_order_acq_rel) + 1; - owner->d->publish(encode_frame(image, sequence)); + frame->mark(Frame_Trace_Marker::callback_finished); + asio::post(owner->d->strand, [weak, frame] { if (auto next_owner = weak.lock()) next_owner->d->publish_completed_frame(frame); }); } }); } else { std::get>(d->scene)->set_frame_callback( - [weak = weak_from_this()](std::shared_ptr frame) { + [weak = weak_from_this()](Frame_3D* frame) { if (auto owner = weak.lock()) { - const auto sequence = owner->d->frame_sequence.fetch_add(1, std::memory_order_acq_rel) + 1; - owner->d->publish(encode_frame(*frame, sequence)); + frame->mark(Frame_Trace_Marker::callback_finished); + asio::post(owner->d->strand, [weak, frame] { if (auto next_owner = weak.lock()) next_owner->d->publish_completed_frame(frame); }); } }); } @@ -808,28 +971,17 @@ void Plot::ensure_started() { asio::redirect_error(asio::use_awaitable, error)); if (error) co_return; if (auto* query = std::get_if(&input)) { - query->handler(self->d->view->schema()); + query->handler(self->d->schema()); continue; } if (auto* write = std::get_if(&input)) { - write->handler(self->d->view->write_prop(write->component, write->key, write->value)); + write->handler(write->component == "frame-runtime" + ? self->d->frame_policy.write_prop(write->key, write->value) + : self->d->view->write_prop(write->component, write->key, write->value)); continue; } - auto event = std::get(input); - event.width = std::clamp(event.width, 160U, 1920U); - event.height = std::clamp(event.height, 120U, 1080U); - self->d->view->update(event); - if (auto* scene = std::get_if>(&self->d->scene)) { - (*scene)->set<&Render_Scene_2D::Prop::viewport>( - Size{static_cast(event.width), static_cast(event.height)}); - if (event.input) dispatch_plot_input(**scene, *event.input); - (*scene)->render(); - } else { - auto& scene_3d = std::get>(self->d->scene); - scene_3d->set<&Render_Scene_3D::Prop::viewport>(Extent{event.width, event.height}); - if (event.input) dispatch_plot_input(*scene_3d, *event.input); - scene_3d->render(); - } + auto submission = std::get(input); + self->d->request_frame(std::move(submission)); } }, [](std::exception_ptr exception) { if (exception) std::rethrow_exception(exception); @@ -850,9 +1002,15 @@ void Plot::detach(const void* owner) { d->handlers.erase(owner); } -void Plot::submit(Plot_Event event) { +void Plot::submit_frame(const void* owner, Plot_Frame_Request request) { ensure_started(); - static_cast(d->inputs.try_send(asio::error_code{}, Plot_Input{event})); + static_cast(d->inputs.try_send(asio::error_code{}, Plot_Input{Frame_Submission{owner, std::move(request)}})); +} + +void Plot::submit_input(Plot_Input_Event event) { + ensure_started(); + if (auto* scene = std::get_if>(&d->scene)) dispatch_plot_input(**scene, event); + else dispatch_plot_input(*std::get>(d->scene), event); } void Plot::async_schema(Json_Handler handler) { @@ -876,10 +1034,14 @@ std::shared_ptr make_datoviz_point_plot(asio::any_io_executor executor) { render_3d::Point{.position = {0.0F, 0.5F, 0.0F}, .color = Color::green_color(), .diameter_px = 30.0F}, render_3d::Point{.position = {0.55F, -0.1F, 0.0F}, .color = Color{42, 120, 255, 255}, .diameter_px = 26.0F}})); visual->advance(); - auto scene = *Scene_3D::Builder(visual.get()) + /* 保留派生 Builder 身份;链式 set() 返回公共 Builder,不能用其 build() 绕过 3D 后端挂接。 */ + Scene_3D::Builder scene_builder(visual.get()); + scene_builder .set(&Render_Scene_3D::Prop::viewport, Extent{720, 420}) - .set(&Render_Scene_3D::Prop::view_active, true) - .build(); + .set(&Render_Scene_3D::Prop::view_active, true); + auto scene_result = scene_builder.build(); + if (!scene_result) throw std::logic_error("Datoviz point plot dependency graph is invalid"); + auto scene = std::move(scene_result).value(); using Adapter = detail::Renderable_Adapter, detail::Prop_Field<&Point_Visual::Prop::visible, "visible", "Controls whether the point visual participates in scene rendering.">, @@ -893,7 +1055,7 @@ std::shared_ptr make_datoviz_point_plot(asio::any_io_executor executor) { components.push_back(detail::make_renderable_descriptor("plot", "主绘图组件", "visual", Adapter{*visual})); auto view = std::make_unique>( std::move(components), - [](const Plot_Event&) {}, std::move(visual)); + [](const Plot_Frame_Request&) {}, std::move(visual)); return std::make_shared(std::move(executor), std::move(scene), std::move(view)); } } diff --git a/web_server/src/Plot.hpp b/web_server/src/Plot.hpp index ccaafe0..a72b1bc 100644 --- a/web_server/src/Plot.hpp +++ b/web_server/src/Plot.hpp @@ -6,7 +6,6 @@ #include #include #include -#include #include #include namespace aethera::web { @@ -25,15 +24,19 @@ struct Plot_Input_Event { std::uint32_t native_key{}; bool auto_repeat{}; }; -struct Plot_Event { - double time_milliseconds{}; - std::uint32_t width{720}; - std::uint32_t height{420}; - std::optional input{}; +struct Plot_Frame_Request { + std::uint64_t correlation_id{}; /* 浏览器请求标识;完成帧元数据原样返回。 */ + double time_milliseconds{}; /* 使用层推进图表时间轴的本地日内毫秒。 */ + std::uint32_t width{720}; /* 请求帧宽度,单位为物理像素。 */ + std::uint32_t height{420}; /* 请求帧高度,单位为物理像素。 */ +}; +struct Plot_Frame_Message { + std::string metadata{}; /* 单帧 JSON 元数据 WebSocket 文本消息。 */ + std::string pixels{}; /* 紧随元数据发送的纯 RGBA8 WebSocket 二进制消息。 */ }; class Plot final : public std::enable_shared_from_this { public: - using Frame_Handler = std::function; + using Frame_Handler = std::function)>; using Json_Handler = std::function; class Scene_View { public: @@ -41,7 +44,7 @@ public: [[nodiscard]] virtual nlohmann::json schema() const = 0; [[nodiscard]] virtual nlohmann::json write_prop(std::string_view component, std::string_view key, const nlohmann::json& value) = 0; - virtual void update(const Plot_Event& event) = 0; + virtual void update(const Plot_Frame_Request& request) = 0; }; Plot(asio::any_io_executor executor, std::unique_ptr> scene, @@ -54,7 +57,8 @@ public: Plot& operator=(const Plot&) = delete; void attach(const void* owner, Frame_Handler handler); void detach(const void* owner); - void submit(Plot_Event event); + void submit_frame(const void* owner, Plot_Frame_Request request); + void submit_input(Plot_Input_Event event); void async_schema(Json_Handler handler); void async_write_prop(std::string component, std::string key, nlohmann::json value, Json_Handler handler); private: diff --git a/web_server/src/Renderable_Adapter.ipp b/web_server/src/Renderable_Adapter.ipp index e56b45c..a0f0b76 100644 --- a/web_server/src/Renderable_Adapter.ipp +++ b/web_server/src/Renderable_Adapter.ipp @@ -299,6 +299,8 @@ nlohmann::json adapter_schema(const Adapter& adapter, std::string_view id, std:: {"description", std::string(field_label) + "。协议字段:" + std::string(field.key()) + "。"}, {"technical_description", std::string(Field::accessor_type::description.view())}, {"value", encode_protocol_value(value)}}; + if constexpr (std::same_as) item["color_channel_scale"] = "normalized"; + else if constexpr (std::same_as) item["color_channel_scale"] = "byte"; if (editable) { if constexpr (std::is_enum_v) { item["options"] = nlohmann::json::array(); diff --git a/webapp_gallery/src/app.tsx b/webapp_gallery/src/app.tsx index 7e8c3a9..3f0c522 100644 --- a/webapp_gallery/src/app.tsx +++ b/webapp_gallery/src/app.tsx @@ -2,21 +2,40 @@ import {memo, useCallback, useEffect, useMemo, useRef, useState} from "react"; import {I18nLabel, Layout, Model, type IJsonModel, type TabNode} from "flexlayout-react"; import {Responsive, useContainerWidth, type LayoutItem, type ResponsiveLayouts} from "react-grid-layout"; import ReconnectingWebSocket from "reconnecting-websocket"; +import * as echarts from "echarts/core"; +import {LineChart} from "echarts/charts"; +import {GridComponent, LegendComponent, TooltipComponent} from "echarts/components"; +import {CanvasRenderer} from "echarts/renderers"; +import type {EChartsType} from "echarts/core"; import "flexlayout-react/style/alpha_dark.css"; import "react-grid-layout/css/styles.css"; import "react-resizable/css/styles.css"; +echarts.use([LineChart, GridComponent, LegendComponent, TooltipComponent, CanvasRenderer]); + type Plot = {id: string; title: string; category: string; description: string; dimension: "2D" | "3D"; websocket: string; schema: string}; type Option = {value: string; label: string}; type Editor = "boolean" | "integer" | "number" | "text" | "select" | "color" | "point2" | "size" | "rect" | "range" | "pen" | "brush" | "font" | "color-map" | "vector3" | "matrix4" | "json"; -type Field = {key: string; label: string; description: string; technical_description?: string; editor: Editor; editable: boolean; value: unknown; options?: Option[]}; +type Color_Channel_Scale = "normalized" | "byte"; +type Field = {key: string; label: string; description: string; technical_description?: string; editor: Editor; editable: boolean; value: unknown; options?: Option[]; color_channel_scale?: Color_Channel_Scale}; type Component = {id: string; label: string; kind: string; fields: Field[]; state: Record}; type Schema = {protocol: "aethera.plot.inspector"; version: 2; components: Component[]}; type State_Histories = Record; type Stream_Status = "CONNECTING" | "LIVE" | "OFFLINE"; - -const protocol_header_size = 24; -const frame_interval_ms = 1000 / 30; +type Frame_Pacing_Mode = "manual" | "fixed_rate" | "minimum_latency" | "maximum_rate"; +type Frame_Metadata = {kind: "frame_metadata"; protocol: "aethera.frame"; version: 4; sequence: number; correlation_id: number; + created_time_unix_ms: number; pixel: {width: number; height: number; format: "rgba8"; byte_length: number}; + pacing: {mode: Frame_Pacing_Mode; fixed_rate_fps: number; minimum_latency_headroom: number}; + trace: {clock: "steady_elapsed_ns"; markers: Record; measurements: Record}}; +type Frame_Stage_Values = Record; +type Frame_Sample = {sequence: number; correlation_id: number; received_at_ms: number; values: Frame_Stage_Values}; +type Frame_Diagnostics = {metadata: Frame_Metadata; samples: Frame_Sample[]; frame_rate_fps: number; interval_jitter_p95_ms: number; + request_to_pixels_average_ms: number; request_to_pixels_p50_ms: number; request_to_pixels_p95_ms: number; request_to_pixels_p99_ms: number; + frame_interval_average_ms: number; frame_interval_p95_ms: number; dropped_sequence_count: number; latest: Frame_Stage_Values}; +type Frame_Metrics = {sequence: number; generated_time_unix_ms: number; request_to_pixels_ms: number; average_request_to_pixels_ms: number; + p95_request_to_pixels_ms: number; p99_request_to_pixels_ms: number; frame_rate_fps: number; p95_frame_interval_jitter_ms: number; + pacing_mode: Frame_Pacing_Mode; fixed_rate_fps: number; minimum_latency_headroom: number}; +type Frame_Policy_Event = {plot_id: string; key: "pacing_mode" | "fixed_rate_fps" | "minimum_latency_headroom"; value: unknown}; function socket_url(path: string) { return `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}${path}`; } @@ -25,21 +44,106 @@ function local_time_milliseconds() { return ((now.getHours() * 60 + now.getMinutes()) * 60 + now.getSeconds()) * 1000 + now.getMilliseconds(); } -function draw_pixels(canvas: HTMLCanvasElement, bytes: ArrayBuffer) { - if (bytes.byteLength < protocol_header_size) return; - const view = new DataView(bytes); - if (view.getUint32(0, true) !== 0x41544852 || view.getUint16(4, true) !== 1) return; - const width = view.getUint32(8, true); - const height = view.getUint32(12, true); - const pixels = new Uint8ClampedArray(bytes, protocol_header_size); - if (pixels.byteLength !== width * height * 4) return; +function valid_frame_metadata(value: unknown): value is Frame_Metadata { + if (!value || typeof value !== "object") return false; + const frame = value as Partial; + return frame.kind === "frame_metadata" && frame.protocol === "aethera.frame" && frame.version === 4 + && typeof frame.sequence === "number" && typeof frame.correlation_id === "number" + && Boolean(frame.pixel) && frame.pixel?.format === "rgba8" && Boolean(frame.trace); +} + +function draw_pixels(canvas: HTMLCanvasElement, bytes: ArrayBuffer, metadata: Frame_Metadata) { + const {width, height, byte_length} = metadata.pixel; + if (bytes.byteLength !== byte_length || byte_length !== width * height * 4) return null; + const started = performance.now(); + const pixels = new Uint8ClampedArray(bytes); if (canvas.width !== width || canvas.height !== height) { canvas.width = width; canvas.height = height; } - canvas.getContext("2d", {alpha: false})?.putImageData(new ImageData(pixels, width, height), 0, 0); + const context = canvas.getContext("2d", {alpha: false}); + if (!context) return null; + context.putImageData(new ImageData(pixels, width, height), 0, 0); + return performance.now() - started; +} + +function percentile(values: number[], ratio: number) { + if (values.length === 0) return 0; + const sorted = [...values].sort((left, right) => left - right); + return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * ratio) - 1)]; +} + +function average(values: number[]) { return values.length === 0 ? 0 : values.reduce((sum, value) => sum + value, 0) / values.length; } + +function marker_ms(metadata: Frame_Metadata, name: string) { + const value = metadata.trace.markers[name]; + return Number.isFinite(value) ? value / 1_000_000 : null; +} + +function marker_interval_ms(metadata: Frame_Metadata, start: string, finish: string) { + const start_ms = marker_ms(metadata, start); + const finish_ms = marker_ms(metadata, finish); + return start_ms === null || finish_ms === null ? null : Math.max(0, finish_ms - start_ms); +} + +function frame_stage_values(metadata: Frame_Metadata, request_started_at: number, metadata_received_at: number, + pixels_received_at: number, canvas_upload_ms: number): Frame_Stage_Values { + const values: Frame_Stage_Values = { + request_to_metadata_ms: Math.max(0, metadata_received_at - request_started_at), + request_to_pixels_ms: Math.max(0, pixels_received_at - request_started_at), + metadata_to_pixels_ms: Math.max(0, pixels_received_at - metadata_received_at), + canvas_upload_ms, + payload_megabytes: metadata.pixel.byte_length / (1024 * 1024) + }; + const marker_intervals: Array<[string, string, string]> = [ + ["scene_render_ms", "scene_render_started", "scene_render_finished"], + ["event_dispatch_ms", "event_dispatch_started", "event_dispatch_finished"], + ["prepare_ms", "prepare_started", "prepare_finished"], + ["paint_ms", "paint_started", "paint_finished"], + ["backend_queue_ms", "backend_queue_entered", "backend_queue_left"], + ["gpu_submission_ms", "gpu_submitted", "gpu_completed"], + ["readback_stage_ms", "readback_started", "readback_finished"], + ["callback_ms", "callback_started", "callback_finished"], + ["websocket_encode_ms", "websocket_publish_started", "websocket_publish_finished"] + ]; + for (const [key, start, finish] of marker_intervals) { + const value = marker_interval_ms(metadata, start, finish); + if (value !== null) values[key] = value; + } + const server_completion = marker_ms(metadata, "websocket_publish_finished"); + if (server_completion !== null) values.server_to_websocket_ready_ms = server_completion; + for (const [key, value] of Object.entries(metadata.trace.measurements)) if (Number.isFinite(value)) + values[key.endsWith("_ns") ? `${key.slice(0, -3)}_ms` : `${key}_ms`] = value / 1_000_000; + return values; +} + +function build_frame_diagnostics(metadata: Frame_Metadata, samples: Frame_Sample[]): Frame_Diagnostics { + const request_latencies = samples.map(sample => sample.values.request_to_pixels_ms).filter(Number.isFinite); + const intervals = samples.slice(1).map((sample, index) => sample.received_at_ms - samples[index].received_at_ms).filter(value => value >= 0); + const median_interval = percentile(intervals, .5); + const interval_jitter = intervals.map(value => Math.abs(value - median_interval)); + let dropped_sequence_count = 0; + for (let index = 1; index < samples.length; ++index) + dropped_sequence_count += Math.max(0, samples[index].correlation_id - samples[index - 1].correlation_id - 1); + const elapsed = samples.length > 1 ? samples.at(-1)!.received_at_ms - samples[0].received_at_ms : 0; + return { + metadata, + samples, + frame_rate_fps: elapsed > 0 ? (samples.length - 1) * 1000 / elapsed : 0, + interval_jitter_p95_ms: percentile(interval_jitter, .95), + request_to_pixels_average_ms: average(request_latencies), + request_to_pixels_p50_ms: percentile(request_latencies, .5), + request_to_pixels_p95_ms: percentile(request_latencies, .95), + request_to_pixels_p99_ms: percentile(request_latencies, .99), + frame_interval_average_ms: average(intervals), + frame_interval_p95_ms: percentile(intervals, .95), + dropped_sequence_count, + latest: samples.at(-1)?.values ?? {} + }; } function use_plot_stream(plot: Plot, canvas_ref: React.RefObject) { const [status, set_status] = useState("CONNECTING"); + const [metrics, set_metrics] = useState(null); const socket_ref = useRef(null); + const pending_pointer_move = useRef | null>(null); const envelope = useCallback((kind: "frame" | "input", event?: Record) => { const canvas = canvas_ref.current; @@ -58,8 +162,21 @@ function use_plot_stream(plot: Plot, canvas_ref: React.RefObject { let stopped = false; - let animation = 0; - let previous_frame_time = 0; + let timer = 0; + let diagnostics_timer = 0; + let frame_pending = false; + let manual_frame_pending = false; + let next_request_id = 1; + let previous_request_time = 0; + let previous_diagnostics_publish_time = 0; + let latest_metrics: Frame_Metrics | null = null; + let latest_metadata: Frame_Metadata | null = null; + let pending_metadata: {value: Frame_Metadata; received_at: number} | null = null; + let samples: Frame_Sample[] = []; + const request_started_at = new Map(); + const presentation_callbacks = new Set(); + const pacing = {mode: "fixed_rate" as Frame_Pacing_Mode, fixed_rate_fps: 30, minimum_latency_headroom: 1.25}; + const local_policy_overrides = new Set(); const socket = new ReconnectingWebSocket(socket_url(plot.websocket), [], { minReconnectionDelay: 300, maxReconnectionDelay: 5000, @@ -69,27 +186,169 @@ function use_plot_stream(plot: Plot, canvas_ref: React.RefObject set_status("LIVE"); - socket.onclose = () => { if (!stopped) set_status("CONNECTING"); }; - socket.onmessage = event => { - if (event.data instanceof ArrayBuffer && canvas_ref.current) draw_pixels(canvas_ref.current, event.data); + const clear_timer = () => { if (timer) window.clearTimeout(timer); timer = 0; }; + const clear_diagnostics_timer = () => { if (diagnostics_timer) window.clearTimeout(diagnostics_timer); diagnostics_timer = 0; }; + const target_interval = () => { + if (pacing.mode === "maximum_rate") return 0; + if (pacing.mode === "fixed_rate") return 1000 / Math.max(0.1, pacing.fixed_rate_fps); + if (pacing.mode === "minimum_latency") return latest_metrics + ? Math.max(1, latest_metrics.p95_request_to_pixels_ms * pacing.minimum_latency_headroom) + : 1000 / 30; + return Number.POSITIVE_INFINITY; }; - const tick = (time: number) => { + const schedule_next = (immediate = false) => { + clear_timer(); + if (stopped || pacing.mode === "manual") return; + const due = immediate || previous_request_time === 0 ? performance.now() : previous_request_time + target_interval(); + timer = window.setTimeout(() => request_frame(false), Math.max(0, due - performance.now())); + }; + const request_frame = (manual: boolean) => { + clear_timer(); + if (stopped) return; + if (frame_pending) { if (manual) manual_frame_pending = true; return; } + const retry_when_visible = () => { if (manual) manual_frame_pending = true; timer = window.setTimeout(() => request_frame(manual), 250); }; + if (socket.readyState !== WebSocket.OPEN) { retry_when_visible(); return; } const canvas = canvas_ref.current; - if (!stopped && socket.readyState === WebSocket.OPEN && canvas && time - previous_frame_time >= frame_interval_ms) { - const bounds = canvas.getBoundingClientRect(); - const visible = bounds.bottom > 0 && bounds.top < innerHeight && bounds.right > 0 && bounds.left < innerWidth; - if (visible && bounds.width > 0 && bounds.height > 0) { - const message = envelope("frame"); - if (message) socket.send(JSON.stringify(message)); - previous_frame_time = time; - } + if (!canvas) { retry_when_visible(); return; } + const bounds = canvas.getBoundingClientRect(); + const visible = bounds.bottom > 0 && bounds.top < innerHeight && bounds.right > 0 && bounds.left < innerWidth; + if (!visible || bounds.width <= 0 || bounds.height <= 0) { retry_when_visible(); return; } + if (pending_pointer_move.current) { + const input_message = envelope("input", pending_pointer_move.current); + if (input_message) socket.send(JSON.stringify(input_message)); + pending_pointer_move.current = null; } - if (!stopped) animation = requestAnimationFrame(tick); + const message = envelope("frame"); + if (!message) return; + const request_id = next_request_id++; + const started_at = performance.now(); + socket.send(JSON.stringify({...message, request_id})); + request_started_at.set(request_id, started_at); + frame_pending = true; + manual_frame_pending = false; + previous_request_time = started_at; }; - animation = requestAnimationFrame(tick); - return () => { stopped = true; cancelAnimationFrame(animation); socket_ref.current = null; socket.close(); }; - }, [plot.websocket, canvas_ref, envelope]); + const synchronize_pacing = (metadata: Frame_Metadata) => { + const next = metadata.pacing; + if (local_policy_overrides.has("pacing_mode")) { if (pacing.mode === next.mode) local_policy_overrides.delete("pacing_mode"); } + else pacing.mode = next.mode; + if (local_policy_overrides.has("fixed_rate_fps")) { if (pacing.fixed_rate_fps === next.fixed_rate_fps) local_policy_overrides.delete("fixed_rate_fps"); } + else pacing.fixed_rate_fps = next.fixed_rate_fps; + if (local_policy_overrides.has("minimum_latency_headroom")) { if (pacing.minimum_latency_headroom === next.minimum_latency_headroom) local_policy_overrides.delete("minimum_latency_headroom"); } + else pacing.minimum_latency_headroom = next.minimum_latency_headroom; + }; + const publish_diagnostics = (force: boolean) => { + if (!latest_metadata || samples.length === 0) return; + const now = performance.now(); + const remaining = 250 - (now - previous_diagnostics_publish_time); + if (!force && remaining > 0) { + if (!diagnostics_timer) diagnostics_timer = window.setTimeout(() => { diagnostics_timer = 0; publish_diagnostics(true); }, remaining); + return; + } + clear_diagnostics_timer(); + previous_diagnostics_publish_time = now; + const diagnostics = build_frame_diagnostics(latest_metadata, samples); + const latest = diagnostics.latest; + latest_metrics = { + sequence: latest_metadata.sequence, + generated_time_unix_ms: latest_metadata.created_time_unix_ms, + request_to_pixels_ms: latest.request_to_pixels_ms ?? 0, + average_request_to_pixels_ms: diagnostics.request_to_pixels_average_ms, + p95_request_to_pixels_ms: diagnostics.request_to_pixels_p95_ms, + p99_request_to_pixels_ms: diagnostics.request_to_pixels_p99_ms, + frame_rate_fps: diagnostics.frame_rate_fps, + p95_frame_interval_jitter_ms: diagnostics.interval_jitter_p95_ms, + pacing_mode: latest_metadata.pacing.mode, + fixed_rate_fps: latest_metadata.pacing.fixed_rate_fps, + minimum_latency_headroom: latest_metadata.pacing.minimum_latency_headroom + }; + set_metrics(latest_metrics); + window.dispatchEvent(new CustomEvent("aethera-frame-diagnostics", {detail: {plot_id: plot.id, diagnostics}})); + }; + const mark_presentation_opportunity = (sequence: number, pixels_received_at: number) => { + const first = requestAnimationFrame(() => { + presentation_callbacks.delete(first); + const second = requestAnimationFrame(timestamp => { + presentation_callbacks.delete(second); + if (stopped) return; + const sample = samples.find(value => value.sequence === sequence); + if (!sample) return; + const browser_tail = Math.max(0, timestamp - pixels_received_at); + samples = samples.map(value => value.sequence !== sequence ? value : {...value, values: {...value.values, + pixels_to_presentation_opportunity_ms: browser_tail, + request_to_presentation_opportunity_ms: (value.values.request_to_pixels_ms ?? 0) + browser_tail}}); + publish_diagnostics(false); + }); + presentation_callbacks.add(second); + }); + presentation_callbacks.add(first); + }; + const receive_pixels = (bytes: ArrayBuffer) => { + const pair = pending_metadata; + pending_metadata = null; + const canvas = canvas_ref.current; + if (!pair || !canvas) return; + const started_at = request_started_at.get(pair.value.correlation_id); + if (started_at === undefined) return; + frame_pending = false; + const pixels_received_at = performance.now(); + const canvas_upload_ms = draw_pixels(canvas, bytes, pair.value); + request_started_at.delete(pair.value.correlation_id); + if (canvas_upload_ms !== null) { + latest_metadata = pair.value; + synchronize_pacing(pair.value); + samples = [...samples, { + sequence: pair.value.sequence, + correlation_id: pair.value.correlation_id, + received_at_ms: pixels_received_at, + values: frame_stage_values(pair.value, started_at, pair.received_at, pixels_received_at, canvas_upload_ms) + }].slice(-180); + publish_diagnostics(samples.length === 1); + mark_presentation_opportunity(pair.value.sequence, pixels_received_at); + } + if (manual_frame_pending) request_frame(true); else schedule_next(); + }; + const on_policy_change = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (!detail || detail.plot_id !== plot.id) return; + if (detail.key === "pacing_mode" && ["manual", "fixed_rate", "minimum_latency", "maximum_rate"].includes(String(detail.value))) pacing.mode = detail.value as Frame_Pacing_Mode; + else if (detail.key === "fixed_rate_fps") pacing.fixed_rate_fps = Number(detail.value); + else if (detail.key === "minimum_latency_headroom") pacing.minimum_latency_headroom = Number(detail.value); + else return; + local_policy_overrides.add(detail.key); + schedule_next(); + }; + const on_manual_frame = (event: Event) => { + const detail = (event as CustomEvent<{plot_id: string}>).detail; + if (detail?.plot_id === plot.id) request_frame(true); + }; + window.addEventListener("aethera-frame-policy", on_policy_change); + window.addEventListener("aethera-manual-frame", on_manual_frame); + socket.onopen = () => { frame_pending = false; pending_metadata = null; request_started_at.clear(); set_status("LIVE"); request_frame(true); }; + socket.onclose = () => { clear_timer(); frame_pending = false; pending_metadata = null; request_started_at.clear(); if (!stopped) set_status("CONNECTING"); }; + socket.onmessage = event => { + if (typeof event.data === "string") { + try { + const decoded: unknown = JSON.parse(event.data); + if (valid_frame_metadata(decoded)) pending_metadata = {value: decoded, received_at: performance.now()}; + } catch { return; } + return; + } + if (event.data instanceof ArrayBuffer) receive_pixels(event.data); + else if (event.data instanceof Blob) void event.data.arrayBuffer().then(receive_pixels); + }; + return () => { + stopped = true; + clear_timer(); + clear_diagnostics_timer(); + for (const callback of presentation_callbacks) cancelAnimationFrame(callback); + presentation_callbacks.clear(); + window.removeEventListener("aethera-frame-policy", on_policy_change); + window.removeEventListener("aethera-manual-frame", on_manual_frame); + socket_ref.current = null; + socket.close(); + }; + }, [plot.id, plot.websocket, canvas_ref, envelope]); useEffect(() => { const canvas = canvas_ref.current; @@ -104,29 +363,23 @@ function use_plot_stream(plot: Plot, canvas_ref: React.RefObject value === 0 ? "left" : value === 1 ? "middle" : value === 2 ? "right" : "none"; const pointer_payload = (type: string, event: PointerEvent) => ({type, position: point(event), global_position: global_point(event), button: button(event.button), buttons: event.buttons, modifiers: modifiers(event)}); - let pending_pointer_move: Record | null = null; - let pointer_animation = 0; const on_pointer_move = (event: PointerEvent) => { event.stopPropagation(); - pending_pointer_move = pointer_payload("pointer_move", event); - if (pointer_animation) return; - pointer_animation = requestAnimationFrame(() => { - pointer_animation = 0; - if (pending_pointer_move) transmit("input", pending_pointer_move); - pending_pointer_move = null; - }); + pending_pointer_move.current = pointer_payload("pointer_move", event); }; const on_pointer_down = (event: PointerEvent) => { event.preventDefault(); event.stopPropagation(); canvas.focus({preventScroll: true}); canvas.setPointerCapture(event.pointerId); + pending_pointer_move.current = null; transmit("input", pointer_payload("pointer_press", event)); }; const on_pointer_up = (event: PointerEvent) => { event.preventDefault(); event.stopPropagation(); if (canvas.hasPointerCapture(event.pointerId)) canvas.releasePointerCapture(event.pointerId); + pending_pointer_move.current = null; transmit("input", pointer_payload("pointer_release", event)); }; - const on_pointer_cancel = (event: PointerEvent) => transmit("input", pointer_payload("pointer_release", event)); - const on_pointer_leave = (event: PointerEvent) => { event.stopPropagation(); transmit("input", {type: "leave"}); }; + const on_pointer_cancel = (event: PointerEvent) => { pending_pointer_move.current = null; transmit("input", pointer_payload("pointer_release", event)); }; + const on_pointer_leave = (event: PointerEvent) => { event.stopPropagation(); pending_pointer_move.current = null; transmit("input", {type: "leave"}); }; const on_wheel = (event: WheelEvent) => { event.preventDefault(); event.stopPropagation(); const scale = event.deltaMode === WheelEvent.DOM_DELTA_LINE ? 16 : event.deltaMode === WheelEvent.DOM_DELTA_PAGE ? canvas.clientHeight : 1; @@ -157,7 +410,7 @@ function use_plot_stream(plot: Plot, canvas_ref: React.RefObject { - if (pointer_animation) cancelAnimationFrame(pointer_animation); + pending_pointer_move.current = null; canvas.removeEventListener("pointermove", on_pointer_move); canvas.removeEventListener("pointerdown", on_pointer_down); canvas.removeEventListener("pointerup", on_pointer_up); canvas.removeEventListener("pointercancel", on_pointer_cancel); canvas.removeEventListener("pointerleave", on_pointer_leave); canvas.removeEventListener("wheel", on_wheel); @@ -165,14 +418,15 @@ function use_plot_stream(plot: Plot, canvas_ref: React.RefObject = {x: "横坐标", y: "纵坐标", z: "深度", width: "宽度", height: "高度", origin: "起点", target: "终点", red: "红", green: "绿", blue: "蓝", alpha: "透明度", color: "颜色", style: "样式", cap: "端点", join: "连接", size: "大小", weight: "字重", italic: "斜体"}; const enum_labels: Record = {automatic: "自动", fixed: "固定", horizontal: "水平", vertical: "垂直", none: "无", solid: "实线", dash: "虚线", dot: "点线", butt: "平直", square: "方形", round: "圆形", miter: "尖角", bevel: "斜角", nearest: "最近邻", bilinear: "双线性", bicubic: "双三次", - nearest_sample: "最近样本", linear_value: "线性数值", linear_power_domain: "线性功率域", step_left: "左阶梯", step_right: "右阶梯", cubic_value: "三次插值"}; + nearest_sample: "最近样本", linear_value: "线性数值", linear_power_domain: "线性功率域", step_left: "左阶梯", step_right: "右阶梯", cubic_value: "三次插值", + manual: "手动刷新", fixed_rate: "固定频率", minimum_latency: "最低延迟", maximum_rate: "最高频率"}; const plot_labels: Record = {axes: "坐标轴", spectrum: "频谱图", frequency_trace: "频率轨迹", sweep_spectrum: "扫频图", afterglow: "余辉图", waterfall: "瀑布图", constellation: "星座图", selection_overlay: "矩形选区", datoviz_point: "三维点图"}; const field_label = (field: Field) => field.label || `字段(${field.key})`; @@ -192,23 +446,37 @@ function Json_Control({field, on_change}: {field: Field; on_change: (value: unkn {invalid ? "JSON 格式不正确" : "编辑完成后点击确定提交"}; } -function rgba_hex(value: Record) { - const channel = (name: string) => Math.max(0, Math.min(255, Number(value[name] ?? 0))).toString(16).padStart(2, "0"); +function infer_color_channel_scale(value: Record, declared?: Color_Channel_Scale): Color_Channel_Scale { + if (declared) return declared; + const channels = ["red", "green", "blue", "alpha"].map(name => Number(value[name] ?? 0)); + return channels.every(channel => Number.isFinite(channel) && channel >= 0 && channel <= 1) ? "normalized" : "byte"; +} + +function rgba_hex(value: Record, declared?: Color_Channel_Scale) { + const scale = infer_color_channel_scale(value, declared) === "normalized" ? 255 : 1; + const channel = (name: string) => Math.round(Math.max(0, Math.min(255, Number(value[name] ?? 0) * scale))).toString(16).padStart(2, "0"); return `#${channel("red")}${channel("green")}${channel("blue")}`; } +function update_rgb(value: Record, hex: string, declared?: Color_Channel_Scale) { + const scale = infer_color_channel_scale(value, declared) === "normalized" ? 1 / 255 : 1; + return {...value, red: parseInt(hex.slice(1, 3), 16) * scale, green: parseInt(hex.slice(3, 5), 16) * scale, blue: parseInt(hex.slice(5, 7), 16) * scale}; +} + function Structured_Control({field, on_change}: {field: Field; on_change: (value: unknown) => void}) { const value = (field.value && typeof field.value === "object" ? field.value : {}) as Record; const update = (key: string, next: unknown) => on_change({...value, [key]: next}); - if (field.editor === "color") return ; + if (field.editor === "color") { + const channel_scale = infer_color_channel_scale(value, field.color_channel_scale); + const alpha_max = channel_scale === "normalized" ? 1 : 255; + return ; + } return
{field_label(field)} {field.key}
{Object.entries(value).map(([key, item]) => item && typeof item === "object" && "red" in item ? : ["style", "cap", "join"].includes(key) && typeof item === "string" @@ -280,14 +548,11 @@ function use_active_component(schema: Schema | null) { return {components, selected, set_active}; } -function percentile(values: number[], ratio: number) { - if (values.length === 0) return 0; - const sorted = [...values].sort((left, right) => left - right); - return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * ratio) - 1)]; -} - function format_metric(value: number, field: Field) { + if (field.key === "generated_time_unix_ms") return new Date(value).toLocaleTimeString("zh-CN", {hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, fractionalSecondDigits: 3}); if (field.key.endsWith("_time_ns")) return `${(value / 1_000_000).toFixed(3)} ms`; + if (field.key.endsWith("_ms")) return `${value.toFixed(3)} ms`; + if (field.key.endsWith("_fps")) return `${value.toFixed(1)} FPS`; return Number.isInteger(value) ? value.toLocaleString("zh-CN") : value.toLocaleString("zh-CN", {maximumFractionDigits: 3}); } @@ -307,8 +572,104 @@ function State_Field_View({component, field, histories}: {component: Component; ; } -function State_Component({component, histories}: {component: Component; histories: State_Histories}) { +const diagnostic_stage_labels: Record = { + request_to_metadata_ms: "请求 → 元数据", + request_to_pixels_ms: "请求 → 像素到达", + request_to_presentation_opportunity_ms: "请求 → 浏览器呈现机会", + metadata_to_pixels_ms: "元数据 → 像素", + server_to_websocket_ready_ms: "帧创建 → WebSocket 就绪", + scene_render_ms: "Scene 渲染", + event_dispatch_ms: "事件分发", + prepare_ms: "准备数据", + paint_ms: "绘制", + backend_queue_ms: "后台排队", + gpu_submission_ms: "GPU 提交 → 完成", + gpu_fence_wait_ms: "GPU 栅栏等待", + gpu_render_ms: "GPU 渲染", + gpu_transition_ms: "GPU 资源转换", + gpu_copy_ms: "GPU 回读复制", + gpu_total_ms: "GPU 总耗时", + readback_ms: "后端回读", + readback_stage_ms: "回读阶段", + callback_ms: "Render 回调", + websocket_encode_ms: "像素编码", + canvas_upload_ms: "Canvas 上传", + pixels_to_presentation_opportunity_ms: "像素到达 → 呈现机会", + payload_megabytes: "像素载荷" +}; + +function diagnostic_value(value: number, key: string) { + if (key === "payload_megabytes") return `${value.toFixed(2)} MiB`; + return `${value.toFixed(3)} ms`; +} + +function Frame_Timeline_Chart({diagnostics}: {diagnostics: Frame_Diagnostics}) { + const host_ref = useRef(null); + const chart_ref = useRef(null); + useEffect(() => { + if (!host_ref.current) return; + const chart = echarts.init(host_ref.current, "dark", {renderer: "canvas"}); + chart_ref.current = chart; + const observer = new ResizeObserver(() => chart.resize()); + observer.observe(host_ref.current); + return () => { observer.disconnect(); chart.dispose(); chart_ref.current = null; }; + }, []); + useEffect(() => { + const chart = chart_ref.current; + if (!chart) return; + const visible_samples = diagnostics.samples.slice(-120); + const series_keys: Array<[string, string, string]> = [ + ["request_to_pixels_ms", "端到端", "#5ce4c2"], + ["server_to_websocket_ready_ms", "服务端", "#62a8ff"], + ["prepare_ms", "准备", "#f4bd63"], + ["paint_ms", "绘制", "#ff7d9c"], + ["gpu_total_ms", "GPU", "#b998ff"] + ]; + chart.setOption({ + backgroundColor: "transparent", + animation: false, + color: series_keys.map(([, , color]) => color), + tooltip: {trigger: "axis", valueFormatter: (value: unknown) => `${Number(value).toFixed(3)} ms`}, + legend: {top: 0, textStyle: {color: "#91a5c0", fontSize: 10}}, + grid: {left: 45, right: 12, top: 34, bottom: 28}, + xAxis: {type: "category", name: "帧", data: visible_samples.map(sample => sample.sequence), axisLabel: {color: "#71839e", hideOverlap: true}}, + yAxis: {type: "value", name: "ms", min: 0, axisLabel: {color: "#71839e"}, splitLine: {lineStyle: {color: "#1d304a"}}}, + series: series_keys.map(([key, name]) => ({name, type: "line", showSymbol: false, connectNulls: false, + data: visible_samples.map(sample => Number.isFinite(sample.values[key]) ? sample.values[key] : null), lineStyle: {width: 1.5}})) + }, true); + }, [diagnostics]); + return
; +} + +function Frame_Diagnostics_View({diagnostics}: {diagnostics: Frame_Diagnostics | null}) { const [copied, set_copied] = useState(false); + if (!diagnostics) return
等待帧诊断数据浏览器收到第一组 JSON 元数据与 RGBA 像素后开始统计。
; + const copy = async () => { await navigator.clipboard.writeText(JSON.stringify(diagnostics.metadata, null, 2)); set_copied(true); window.setTimeout(() => set_copied(false), 1200); }; + const latest_stages = Object.entries(diagnostics.latest).filter(([key, value]) => diagnostic_stage_labels[key] && Number.isFinite(value)); + const summaries = [ + ["帧率", `${diagnostics.frame_rate_fps.toFixed(1)} FPS`], + ["端到端平均", `${diagnostics.request_to_pixels_average_ms.toFixed(2)} ms`], + ["端到端 P50", `${diagnostics.request_to_pixels_p50_ms.toFixed(2)} ms`], + ["端到端 P95", `${diagnostics.request_to_pixels_p95_ms.toFixed(2)} ms`], + ["端到端 P99", `${diagnostics.request_to_pixels_p99_ms.toFixed(2)} ms`], + ["帧间隔平均", `${diagnostics.frame_interval_average_ms.toFixed(2)} ms`], + ["帧间隔抖动 P95", `${diagnostics.interval_jitter_p95_ms.toFixed(2)} ms`], + ["请求 ID 缺口", diagnostics.dropped_sequence_count.toLocaleString("zh-CN")] + ]; + return
+
统计由浏览器基于最近 {diagnostics.samples.length} 帧原始样本计算;“呈现机会”采用双 requestAnimationFrame,只代表浏览器经过一次绘制机会,不等同于物理屏幕扫描时刻。
+
{summaries.map(([label, value]) =>
{label}
{value}
)}
+ +
最新帧阶段#{diagnostics.metadata.sequence} / 请求 {diagnostics.metadata.correlation_id}
+
{latest_stages.map(([key, value]) =>
{diagnostic_stage_labels[key]}
{diagnostic_value(value, key)}
)}
+
最新帧原始诊断 JSON
协议 v{diagnostics.metadata.version}
+
{JSON.stringify(diagnostics.metadata, null, 2)}
+
; +} + +function State_Component({component, histories, diagnostics}: {component: Component; histories: State_Histories; diagnostics: Frame_Diagnostics | null}) { + const [copied, set_copied] = useState(false); + if (component.id === "frame-runtime") return ; const copy = async () => { await navigator.clipboard.writeText(JSON.stringify(component.state, null, 2)); set_copied(true); window.setTimeout(() => set_copied(false), 1200); }; const fields = component.fields.filter(field => !field.editable); return
{fields.length} 个只读字段 @@ -317,7 +678,7 @@ function State_Component({component, histories}: {component: Component; historie
原始状态 JSON
{JSON.stringify(component.state, null, 2)}
; } -function Property_Pane({plot, schema, busy, on_refresh, on_update}: {plot: Plot; schema: Schema | null; busy: boolean; on_refresh: () => void; on_update: (component: Component, field: Field, value: unknown) => Promise}) { +function Property_Pane({plot, schema, busy, on_refresh, on_update, on_manual_frame}: {plot: Plot; schema: Schema | null; busy: boolean; on_refresh: () => void; on_update: (component: Component, field: Field, value: unknown) => Promise; on_manual_frame: () => void}) { const {components, selected, set_active} = use_active_component(schema); const component = components.find(item => item.id === selected); const fields = component?.fields.filter(field => field.editable) ?? []; @@ -325,24 +686,26 @@ function Property_Pane({plot, schema, busy, on_refresh, on_update}: {plot: Plot; return
{busy && !schema ?

正在读取组件信息…

: component ?
- {component.label}{fields.length} 个可编辑属性
+ {component.label}{fields.length} 个可编辑属性
+ {component.id === "frame-runtime" ?
只提交一次 render;输入事件仍随这次正常渲染处理。
: null}
{fields.map(field => on_update(component, field, value)}/>)}
: null}
; } -function State_Pane({plot, schema, busy, on_refresh, histories}: {plot: Plot; schema: Schema | null; busy: boolean; on_refresh: () => void; histories: State_Histories}) { +function State_Pane({plot, schema, busy, on_refresh, histories, diagnostics}: {plot: Plot; schema: Schema | null; busy: boolean; on_refresh: () => void; histories: State_Histories; diagnostics: Frame_Diagnostics | null}) { const {components, selected, set_active} = use_active_component(schema); const component = components.find(item => item.id === selected); - const count = components.reduce((sum, item) => sum + item.fields.filter(field => !field.editable).length, 0); + const count = components.reduce((sum, item) => sum + item.fields.filter(field => !field.editable).length, 0) + (diagnostics ? Object.keys(diagnostics.latest).length : 0); return
-
{component ? : null}
; +
{component ? : null}
; } const Plot_Card = memo(function Plot_Card({plot, selected, on_select}: {plot: Plot; selected: boolean; on_select: (plot: Plot) => void}) { const canvas_ref = useRef(null); - const status = use_plot_stream(plot, canvas_ref); + const {status, metrics} = use_plot_stream(plot, canvas_ref); + const generated_time = metrics ? new Date(metrics.generated_time_unix_ms).toLocaleTimeString("zh-CN", {hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, fractionalSecondDigits: 3}) : "尚未生成帧"; return
on_select(plot)} onFocusCapture={() => on_select(plot)}>
绘图组件 · {plot.dimension}

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

{{CONNECTING: "重连中", LIVE: "实时", OFFLINE: "已离线"}[status]}
+ onPointerDownCapture={() => on_select(plot)} onFocusCapture={() => on_select(plot)}>
绘图组件 · {plot.dimension}

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

{{CONNECTING: "重连中", LIVE: "实时", OFFLINE: "已离线"}[status]}{metrics ? enum_label(metrics.pacing_mode) : "等待策略"}
{metrics ? metrics.frame_rate_fps.toFixed(1) : "--.-"} FPSE2E {metrics ? metrics.request_to_pixels_ms.toFixed(1) : "--.-"} msP95 {metrics ? metrics.p95_request_to_pixels_ms.toFixed(1) : "--.-"} msP99 {metrics ? metrics.p99_request_to_pixels_ms.toFixed(1) : "--.-"} ms
{plot.description ?

{plot.description}

: null}
; }); @@ -457,16 +820,27 @@ export function App() { const [plots, set_plots] = useState([]); const [category, set_category] = useState("全部"); const [selected, set_selected] = useState(null); const [schema, set_schema] = useState(null); const [state_histories, set_state_histories] = useState({}); + const [frame_diagnostics, set_frame_diagnostics] = useState(null); const [schema_busy, set_schema_busy] = useState(false); const [layout_model, set_layout_model] = useState(load_workspace_model); const [gallery_layout_revision, set_gallery_layout_revision] = useState(0); const schema_request = useRef(0); + const schema_busy_request = useRef(0); useEffect(() => { void fetch("/plot").then(response => response.json()).then(set_plots); }, []); useEffect(() => { if (!selected && plots.length > 0) set_selected(plots[0]); }, [plots, selected]); - const load_schema = useCallback(async () => { + useEffect(() => { + set_frame_diagnostics(null); + const receive = (event: Event) => { + const detail = (event as CustomEvent<{plot_id: string; diagnostics: Frame_Diagnostics}>).detail; + if (detail?.plot_id === selected?.id) set_frame_diagnostics(detail.diagnostics); + }; + window.addEventListener("aethera-frame-diagnostics", receive); + return () => window.removeEventListener("aethera-frame-diagnostics", receive); + }, [selected?.id]); + const load_schema = useCallback(async (show_busy: boolean) => { if (!selected) return; const request = ++schema_request.current; - set_schema_busy(true); + if (show_busy) { schema_busy_request.current = request; set_schema_busy(true); } try { const response = await fetch(selected.schema); const result = await response.json() as Schema; if (request === schema_request.current) { set_schema(result); set_state_histories(current => { @@ -478,16 +852,18 @@ export function App() { return next; }); } } - finally { if (request === schema_request.current) set_schema_busy(false); } + finally { if (show_busy && request === schema_busy_request.current) set_schema_busy(false); } }, [selected]); - useEffect(() => { ++schema_request.current; set_schema(null); set_state_histories({}); if (selected) void load_schema(); }, [selected, load_schema]); - useEffect(() => { if (!selected) return; const timer = window.setInterval(() => void load_schema(), 1000); return () => window.clearInterval(timer); }, [selected, load_schema]); + useEffect(() => { ++schema_request.current; ++schema_busy_request.current; set_schema_busy(false); set_schema(null); set_state_histories({}); if (selected) void load_schema(true); }, [selected, load_schema]); + useEffect(() => { if (!selected) return; const timer = window.setInterval(() => void load_schema(false), 1000); return () => window.clearInterval(timer); }, [selected, load_schema]); const update = async (component: Component, field: Field, value: unknown) => { if (!selected) return; const response = await fetch(`/plot/${encodeURIComponent(selected.id)}/component/${encodeURIComponent(component.id)}/prop/${encodeURIComponent(field.key)}`, { method: "PUT", headers: {"Content-Type": "application/json"}, body: JSON.stringify(value)}); const result = await response.json(); if (!result.success) throw new Error(result.error ?? "属性提交失败"); + if (component.id === "frame-runtime" && ["pacing_mode", "fixed_rate_fps", "minimum_latency_headroom"].includes(field.key)) + window.dispatchEvent(new CustomEvent("aethera-frame-policy", {detail: {plot_id: selected.id, key: field.key as Frame_Policy_Event["key"], value: result.value}})); set_schema(current => current ? {...current, components: current.components.map(item => item.id !== component.id ? item : {...item, fields: item.fields.map(current_field => current_field.key === field.key ? {...current_field, value: result.value} : current_field)})} : current); }; @@ -502,8 +878,8 @@ export function App() { const factory = (node: TabNode) => { if (node.getComponent() === "gallery") return gallery; if (!selected) return
请选择一个图形组件。
; - if (node.getComponent() === "properties") return ; - if (node.getComponent() === "state") return ; + if (node.getComponent() === "properties") return ; + if (node.getComponent() === "state") return ; return
未知工作区面板。
; }; return
layout_labels[label]} diff --git a/webapp_gallery/src/styles.css b/webapp_gallery/src/styles.css index c5e030d..5b85aff 100644 --- a/webapp_gallery/src/styles.css +++ b/webapp_gallery/src/styles.css @@ -66,6 +66,11 @@ nav { display: flex; flex-wrap: wrap; gap: 8px; padding: 18px 0; } .cardDragHandle:active { cursor: grabbing; } .card > p { min-height: 42px; margin: 0; padding: 0 20px 14px; color: #8fa2bd; line-height: 1.5; } .status { align-self: flex-start; padding: 5px 8px; color: #5ce4c2; border: 1px solid #27594f; border-radius: 7px; font: 700 10px/1 ui-monospace, monospace; letter-spacing: .08em; } +.cardRuntime { display: grid; flex: 0 0 auto; justify-items: end; gap: 7px; } +.cardRuntime > div:first-child { display: flex; align-items: center; justify-content: flex-end; gap: 6px; } +.framePolicy { color: #8296b2; font-size: 9px; white-space: nowrap; } +.frameMetrics { display: grid; grid-template-columns: auto auto; gap: 4px 9px; color: #8296b2; font: 9px/1.15 ui-monospace, monospace; font-variant-numeric: tabular-nums; white-space: nowrap; } +.frameMetrics span:nth-child(2n) { text-align: right; } .plotViewport { flex: 1; width: 100%; min-height: 160px; overflow: hidden; background: #070d18; } canvas { display: block; width: 100%; height: 100%; background: #070d18; overscroll-behavior: contain; touch-action: none; } canvas:focus { outline: 1px solid #5ce4c2; outline-offset: -1px; } @@ -105,6 +110,26 @@ canvas:focus { outline: 1px solid #5ce4c2; outline-offset: -1px; } .propertyActions button:first-child:not(:disabled) { color: #062019; border-color: #5ce4c2; background: #5ce4c2; } .propertyActions button:disabled { opacity: .4; cursor: default; } .savingHint { display: block; margin-top: 5px; color: #5ce4c2; text-align: right; } +.framePolicyActions { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; padding: 11px; border: 1px solid #27594f; border-radius: 9px; background: #0b1b1a; } +.framePolicyActions button { flex: 0 0 auto; padding: 8px 12px; color: #062019; border: 1px solid #5ce4c2; border-radius: 7px; background: #5ce4c2; cursor: pointer; } +.framePolicyActions small { color: #7fa99f; line-height: 1.4; } + +.frameDiagnosticPanel { display: grid; gap: 14px; min-width: 0; } +.diagnosticNotice { padding: 10px 12px; color: #8eabca; border: 1px solid #29435e; border-radius: 9px; background: #0c1a2a; font-size: 11px; line-height: 1.55; } +.frameDiagnosticSummary { display: grid; grid-template-columns: repeat(auto-fit, minmax(122px, 1fr)); gap: 8px; margin: 0; } +.frameDiagnosticSummary > div { min-width: 0; padding: 10px; border: 1px solid #213653; border-radius: 9px; background: #0a1422; } +.frameDiagnosticSummary dt { color: #71839e; font-size: 10px; } +.frameDiagnosticSummary dd { margin: 5px 0 0; color: #5ce4c2; font: 700 14px/1.2 ui-monospace, monospace; font-variant-numeric: tabular-nums; } +.frameTimelineChart { width: 100%; height: 260px; min-height: 220px; border: 1px solid #213653; border-radius: 10px; background: #08111e; } +.frameStagePanel { overflow: hidden; border: 1px solid #213653; border-radius: 10px; background: #0a1422; } +.frameStagePanel > header { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 11px 13px; border-bottom: 1px solid #1d304a; background: #101d2f; } +.frameStagePanel > header code { color: #71839e; font: 10px/1 ui-monospace, monospace; } +.frameStagePanel dl { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); margin: 0; } +.frameStagePanel dl > div { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 9px 12px; border-right: 1px solid #172942; border-bottom: 1px solid #172942; } +.frameStagePanel dt { color: #91a5c0; font-size: 11px; } +.frameStagePanel dd { margin: 0; color: #dce8f8; font: 11px/1 ui-monospace, monospace; font-variant-numeric: tabular-nums; white-space: nowrap; } +.diagnosticEmpty { display: grid; min-height: 180px; place-content: center; gap: 8px; padding: 24px; color: #71839e; border: 1px dashed #29435e; border-radius: 10px; text-align: center; } +.diagnosticEmpty strong { color: #cbd8ea; } .componentCard { margin-bottom: 13px; overflow: hidden; border: 1px solid #213653; border-radius: 11px; background: #0a1422; } .componentCard > summary { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 11px 13px; color: #dce8f8; background: #101d2f; cursor: pointer; }