From 4f21169ade95e75cb00450f1f445d414b9dfcd27 Mon Sep 17 00:00:00 2001 From: wyc <1104749580@qq.com> Date: Mon, 24 Aug 2026 09:37:04 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- render_2D/render_2D/axis/Abs_Axis.hpp | 2 +- render_2D/render_2D/base/Renderable_2D.hpp | 31 +- render_2D/render_2D/base/Renderable_2D.ipp | 35 +- render_2D/render_2D/plottable/Afterglow.hpp | 3 +- .../plottable/Constellation_Diagram.hpp | 3 +- .../render_2D/plottable/Frequency_Trace.hpp | 3 +- .../plottable/Selection_Rectangle_Overlay.cpp | 2 - .../plottable/Selection_Rectangle_Overlay.hpp | 4 +- .../plottable/Selection_Rectangle_Overlay.ipp | 20 - render_2D/render_2D/plottable/Spectrum.cpp | 12 - render_2D/render_2D/plottable/Spectrum.hpp | 73 ++- render_2D/render_2D/plottable/Spectrum.ipp | 43 +- .../render_2D/plottable/Sweep_Spectrum.hpp | 3 +- render_2D/render_2D/plottable/Waterfall.hpp | 3 +- render_2D/render_2D/scene/Render_Scene_2D.ipp | 9 +- render_2D/tests/Plottable_Migration_Test.cpp | 14 +- render_2D/tests/Spectrum_Test.cpp | 16 +- render_3D/render_3D/detail/Backend_Types.hpp | 32 +- .../detail/Datoviz_Visual_Backend.cpp | 171 ++++--- .../detail/Datoviz_Visual_Backend.hpp | 4 +- render_3D/render_3D/scene/Render_Scene_3D.ipp | 11 +- render_3D/render_3D/visual/Basic_Visual.hpp | 16 +- render_3D/render_3D/visual/Basic_Visual.ipp | 116 +++-- .../render_3D/visual/Prepared_Visual.hpp | 31 +- render_3D/render_3D/visual/Visuals.hpp | 284 ++++++++---- render_3D/render_3D/visual/Visuals.ipp | 70 +-- render_3D/tests/Visual_Tests.cpp | 9 +- web_server/src/Gallery_Plots_2D.cpp | 8 +- web_server/src/Gallery_Plots_3D.cpp | 12 +- web_server/src/Gallery_Video_Stream.cpp | 84 ++-- web_server/src/Gallery_Video_Stream.hpp | 4 +- web_server/src/Gallery_WebSocket.cpp | 4 +- web_server/src/Graph_WebSocket.cpp | 20 +- web_server/src/Graph_WebSocket.hpp | 2 +- web_server/src/Plot.cpp | 338 +++++++++++--- web_server/src/Plot.hpp | 4 +- web_server/src/Renderable_Adapter.ipp | 3 +- web_server/src/Sliding_Statistics.cpp | 143 ++++++ web_server/src/Sliding_Statistics.hpp | 63 +++ web_server/src/Web_Server.cpp | 33 +- web_server/tests/Sliding_Statistics_Tests.cpp | 38 ++ webapp_gallery/src/app.tsx | 428 ++++++------------ 42 files changed, 1371 insertions(+), 833 deletions(-) create mode 100644 web_server/src/Sliding_Statistics.cpp create mode 100644 web_server/src/Sliding_Statistics.hpp create mode 100644 web_server/tests/Sliding_Statistics_Tests.cpp diff --git a/render_2D/render_2D/axis/Abs_Axis.hpp b/render_2D/render_2D/axis/Abs_Axis.hpp index 5c409fe..eafe766 100644 --- a/render_2D/render_2D/axis/Abs_Axis.hpp +++ b/render_2D/render_2D/axis/Abs_Axis.hpp @@ -19,7 +19,7 @@ concept Axis_Object = Renderable_Object && std::derived_from && { private_data.sub_tick_count(object, tick) } -> std::same_as; }; /* 所有二维坐标轴共享的定义层;最终通过 Impl 创建运行时对象。 */ -struct Abs_Axis : Def> { +struct Abs_Axis : Def { struct Prop : Prev_Prop { Point_F position{}; /* 坐标轴起点在画布中的二维像素位置。 */ Axis_Pixel_Length pixel_length{}; /* 从坐标起点到终点的轴向像素跨度;为 0 时反算返回坐标起点。 */ diff --git a/render_2D/render_2D/base/Renderable_2D.hpp b/render_2D/render_2D/base/Renderable_2D.hpp index 31e031d..038d2a7 100644 --- a/render_2D/render_2D/base/Renderable_2D.hpp +++ b/render_2D/render_2D/base/Renderable_2D.hpp @@ -4,36 +4,21 @@ #include #include namespace aethera::render_2d { -/* 二维 Renderable 是否拥有可跨帧复用的完整颜色缓存。 */ -enum class Renderable_2D_Cache { - disabled, - enabled -}; /* - * 二维绘制能力层。业务类型在该模板位置选择是否拥有颜色缓存;默认不分配完整缓存。 + * 二维绘制能力层。每个对象都具备惰性颜色缓存,是否使用由当前 Prop 决定。 * Paint 实现只向 Scene 本轮指定的绘制目标输出,不直接选择或清理双缓冲角色。 */ -template -struct Renderable_2D; -template <> -struct Renderable_2D : Def, Renderable> { - struct Prop : Prev_Prop {}; +struct Renderable_2D + : Def> { + struct Prop : Prev_Prop { + bool cache_enabled{}; /* 是否跨帧复用本对象及其缓存组的完整颜色结果。 */ + bool operator==(const Prop&) const; + }; struct State : Prev_State { bool operator==(const State&) const; }; struct Private; }; -template <> -struct Renderable_2D - : Def, - Renderable_2D, - Tagged_Buffer> { - struct Prop : Prev_Prop {}; - struct State : Prev_State { - bool operator==(const State&) const; - }; - struct Private; -}; -using Renderable_2D_Base = Renderable_2D; +using Renderable_2D_Base = Renderable_2D; } #include "Renderable_2D.ipp" diff --git a/render_2D/render_2D/base/Renderable_2D.ipp b/render_2D/render_2D/base/Renderable_2D.ipp index 899e21f..f75db0a 100644 --- a/render_2D/render_2D/base/Renderable_2D.ipp +++ b/render_2D/render_2D/base/Renderable_2D.ipp @@ -1,11 +1,13 @@ #pragma once #include namespace aethera::render_2d { -struct Renderable_2D::Private : Prev_Private { +struct Renderable_2D::Private : Prev_Private { using Scene_Attach = std::function(Root*)>; using Cache_Access = Blend2D_Cache* (*)(Root*); + using Cache_Enabled = bool (*)(const Root*); using Event_Region_Run = Rect_F (*)(const Root*, Size); Cache_Access pending_cache{}; /* 非空时返回最终对象本轮可写的缓存物理对象。 */ + Cache_Enabled cache_enabled{}; /* 读取最终对象当前发布的运行时缓存策略。 */ Event_Region_Run event_region_run{}; /* 计算最终对象当前事件区域的无虚函数入口。 */ Blend2D_Cache* paint_target{}; /* 仅在 Scene Paint 阶段有效的非拥有绘制目标。 */ Blend2D_Cache* valid_cache{}; /* 缓存根最近一次完整重绘产生的权威物理缓存。 */ @@ -14,24 +16,27 @@ struct Renderable_2D::Private : Prev_Private { [[nodiscard]] Blend2D_Cache& paint_surface(); /* CRTP 可覆盖:返回最终对象可接收指针与滚轮事件的画布区域;默认覆盖整个 viewport。 */ [[nodiscard]] Rect_F event_region(const Attached auto* object, Size viewport) const; - /* CRTP 覆盖:识别最终类型是否选择了二维颜色缓存。 */ + template + void after_prop_set(Object* object, Member Owner::* member, + Prop_Access pending_props); template void bind_private_crtp(Object* object); }; -struct Renderable_2D::Private : Prev_Private { - /* CRTP 覆盖:继续绑定二维绘制能力;缓存机制由本定义层静态加入。 */ - template void bind_private_crtp(Object* object); -}; -inline bool Renderable_2D::State::operator==(const State&) const = default; -inline bool Renderable_2D::State::operator==(const State&) const = default; -inline Blend2D_Cache& Renderable_2D::Private::paint_surface() { +inline bool Renderable_2D::Prop::operator==(const Prop&) const = default; +inline bool Renderable_2D::State::operator==(const State&) const = default; +inline Blend2D_Cache& Renderable_2D::Private::paint_surface() { if (!paint_target) throw std::logic_error("2D renderable painted without a scene paint target"); return *paint_target; } -inline Rect_F Renderable_2D::Private::event_region(const Attached auto*, Size viewport) const { +inline Rect_F Renderable_2D::Private::event_region(const Attached auto*, Size viewport) const { return {0.0, 0.0, static_cast(viewport.width), static_cast(viewport.height)}; } +template +void Renderable_2D::Private::after_prop_set(Object* object, Member Owner::*, + Prop_Access) { + if constexpr (std::same_as) object->template mark_dirty(); +} template -void Renderable_2D::Private::bind_private_crtp(Object* object) { +void Renderable_2D::Private::bind_private_crtp(Object* object) { Prev_Private::bind_private_crtp(object); object->template mark_dirty(); this->event_region_run = [](const Root* root, Size viewport) { @@ -39,12 +44,12 @@ void Renderable_2D::Private::bind_private_crtp(Ob const auto& private_data = static_cast(*value->d); return private_data.event_region(value, viewport); }; -} -template -void Renderable_2D::Private::bind_private_crtp(Object* object) { - Prev_Private::bind_private_crtp(object); this->pending_cache = [](Root* root) { return &static_cast(root)->template pending_buffer(); }; + this->cache_enabled = [](const Root* root) { + return static_cast(root) + ->template read_prop().cache_enabled; + }; } } diff --git a/render_2D/render_2D/plottable/Afterglow.hpp b/render_2D/render_2D/plottable/Afterglow.hpp index 0e25e9b..ddfebb0 100644 --- a/render_2D/render_2D/plottable/Afterglow.hpp +++ b/render_2D/render_2D/plottable/Afterglow.hpp @@ -10,11 +10,12 @@ #include namespace aethera::render_2d { struct Afterglow_Stream_Tag {}; -struct Afterglow : Def, +struct Afterglow : Def>>> { using Scene_Object = Impl; using Frequency_Object = Impl; using Power_Object = Impl; struct Prop : Prev_Prop { + Prop() { cache_enabled = true; } std::size_t frequency_point_size{}; /* 栅格频率列数;零值使用最新频谱尺寸。 */ std::size_t power_point_size{}; /* 栅格功率行数;零值使用默认尺寸。 */ Plot_Partition_Count partition_count{1}; /* fixed 模式使用的 Prepare 子图分块数。 */ diff --git a/render_2D/render_2D/plottable/Constellation_Diagram.hpp b/render_2D/render_2D/plottable/Constellation_Diagram.hpp index 852d674..090be58 100644 --- a/render_2D/render_2D/plottable/Constellation_Diagram.hpp +++ b/render_2D/render_2D/plottable/Constellation_Diagram.hpp @@ -15,11 +15,12 @@ struct Constellation_Point { }; struct Constellation_Stream_Tag {}; struct Constellation_Diagram : Def, + Renderable_2D, Mpmc_Triple_Buffer> { using Scene_Object = Impl; using Axis_Object = Impl; struct Prop : Prev_Prop { + Prop() { cache_enabled = true; } Plot_Duration_Milliseconds point_lifetime_ms{1000}; /* 接收点保留时间,单位为毫秒。 */ Constellation_Diagram_Type type{Constellation_Diagram_Type::psk8}; /* 理想 PSK 锚点数量。 */ Plot_Ratio phase_offset_radians{}; /* 理想锚点相位偏移,单位为弧度。 */ diff --git a/render_2D/render_2D/plottable/Frequency_Trace.hpp b/render_2D/render_2D/plottable/Frequency_Trace.hpp index a7021c1..e3049a3 100644 --- a/render_2D/render_2D/plottable/Frequency_Trace.hpp +++ b/render_2D/render_2D/plottable/Frequency_Trace.hpp @@ -13,12 +13,13 @@ struct Frequency_Trace_Sample { bool operator==(const Frequency_Trace_Sample&) const; }; struct Frequency_Trace_Stream_Tag {}; -struct Frequency_Trace : Def, +struct Frequency_Trace : Def> { using Scene_Object = Impl; using Time_Object = Impl; using Value_Object = Impl; struct Prop : Prev_Prop { + Prop() { cache_enabled = true; } Plot_Partition_Count partition_count{1}; /* fixed 模式使用的 Prepare 子图分块数。 */ Pen pen{Color::yellow()}; /* 频率轨迹折线样式。 */ Plot_Partition_Mode partition_mode{Plot_Partition_Mode::automatic}; /* Prepare 子图分块策略。 */ diff --git a/render_2D/render_2D/plottable/Selection_Rectangle_Overlay.cpp b/render_2D/render_2D/plottable/Selection_Rectangle_Overlay.cpp index db5da49..8f41fe7 100644 --- a/render_2D/render_2D/plottable/Selection_Rectangle_Overlay.cpp +++ b/render_2D/render_2D/plottable/Selection_Rectangle_Overlay.cpp @@ -20,6 +20,4 @@ std::string Selection_Rectangle_Overlay::Private::range_label(const Abs_Axis* ax }; return coordinate_label(range.origin) + " .. " + coordinate_label(range.target); } -std::vector Selection_Rectangle_Overlay::selected_regions() const { return static_cast(*d).dispatch->selected_regions(this); } -void Selection_Rectangle_Overlay::clear_selected_regions() { static_cast(*d).dispatch->clear_selected_regions(this); } } diff --git a/render_2D/render_2D/plottable/Selection_Rectangle_Overlay.hpp b/render_2D/render_2D/plottable/Selection_Rectangle_Overlay.hpp index 06d0d7f..e8bbb50 100644 --- a/render_2D/render_2D/plottable/Selection_Rectangle_Overlay.hpp +++ b/render_2D/render_2D/plottable/Selection_Rectangle_Overlay.hpp @@ -8,7 +8,7 @@ #include #include namespace aethera::render_2d { -struct Selection_Rectangle_Overlay : Def> { +struct Selection_Rectangle_Overlay : Def { using Scene_Object = Impl; struct Prop : Prev_Prop { Font label_font{}; /* 选择范围标签使用的字体。 */ @@ -32,8 +32,6 @@ struct Selection_Rectangle_Overlay : Def initialize{}; /* build 成功后绑定两根最终轴及其 Scene 拓扑。 */ }; - [[nodiscard]] std::vector selected_regions() const; - void clear_selected_regions(); }; } #include "Selection_Rectangle_Overlay.ipp" diff --git a/render_2D/render_2D/plottable/Selection_Rectangle_Overlay.ipp b/render_2D/render_2D/plottable/Selection_Rectangle_Overlay.ipp index e8fbb08..5f5a257 100644 --- a/render_2D/render_2D/plottable/Selection_Rectangle_Overlay.ipp +++ b/render_2D/render_2D/plottable/Selection_Rectangle_Overlay.ipp @@ -6,12 +6,6 @@ namespace aethera::render_2d { struct Selection_Rectangle_Overlay::Private : Prev_Private { using No_Prepare = void; - using Regions_Get = std::vector (*)(const Root*); - using Clear_Run = void (*)(Root*); - struct Dispatch { - Regions_Get selected_regions; /* 查询最终对象已发布选择区域。 */ - Clear_Run clear_selected_regions; /* 清空最终对象选择区域。 */ - }; Scene_Object* scene{}; /* 不拥有的所属 Scene。 */ Abs_Axis* horizontal_axis{}; /* 不拥有的水平坐标轴。 */ Abs_Axis* vertical_axis{}; /* 不拥有的垂直坐标轴。 */ @@ -19,12 +13,8 @@ struct Selection_Rectangle_Overlay::Private : Prev_Private { Axis_Point drag_current{}; /* 当前拖动终点的两轴数据坐标。 */ 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; @@ -119,15 +109,5 @@ template void Selection_Rectangle_Overlay::Private::after_prop_set(Object* object, Member Owner::*, Prop_Access) { if constexpr (std::same_as) object->template mark_dirty(); } template void Selection_Rectangle_Overlay::Private::before_advance(Object*, Prop_Type*, State_Access pending_states, const Prop_Type* current_prop, State_Access) { pending_states.template get().selected_region_count = static_cast(*current_prop).selected_regions.size(); } -template -const Selection_Rectangle_Overlay::Private::Dispatch& Selection_Rectangle_Overlay::Private::dispatch_for() { - static const Dispatch value{ - [](const Root* root) { return static_cast(root)->template read_prop().selected_regions; }, - [](Root* root) { static_cast(root)->template set<&Prop::selected_regions>(std::vector{}); } - }; - return value; -} -template -void Selection_Rectangle_Overlay::Private::bind_private_crtp(Object* object) { Prev_Private::bind_private_crtp(object); dispatch = &dispatch_for(); } inline void Selection_Rectangle_Overlay::Private::bind_sources(Abs_Axis* horizontal_axis_value, Abs_Axis* vertical_axis_value) { horizontal_axis = horizontal_axis_value; vertical_axis = vertical_axis_value; } } diff --git a/render_2D/render_2D/plottable/Spectrum.cpp b/render_2D/render_2D/plottable/Spectrum.cpp index 2bb7961..4bd62cd 100644 --- a/render_2D/render_2D/plottable/Spectrum.cpp +++ b/render_2D/render_2D/plottable/Spectrum.cpp @@ -3,18 +3,6 @@ namespace aethera::render_2d { bool Spectrum_Frame::operator==(const Spectrum_Frame&) const = default; bool Spectrum::Prop::operator==(const Prop&) const = default; bool Spectrum::State::operator==(const State&) const = default; -void Spectrum::update_samples(std::span values) { - static_cast(*d).dispatch->update_samples(this, values); -} -void Spectrum::update_samples(std::pmr::vector&& values) { - update_samples(std::span(values.data(), values.size())); -} -std::size_t Spectrum::sample_count() const { - return static_cast(*d).dispatch->sample_count(this); -} -std::size_t Spectrum::rendered_point_count() const { - return static_cast(*d).dispatch->rendered_point_count(this); -} std::expected Spectrum::power_at(Spectrum_Frequency frequency) const { return static_cast(*d).dispatch->power_at(this, frequency); } diff --git a/render_2D/render_2D/plottable/Spectrum.hpp b/render_2D/render_2D/plottable/Spectrum.hpp index 501b0a9..d4a662c 100644 --- a/render_2D/render_2D/plottable/Spectrum.hpp +++ b/render_2D/render_2D/plottable/Spectrum.hpp @@ -5,8 +5,6 @@ #include "Plot_Types.hpp" #include #include -#include -#include #include #include namespace aethera::render_2d { @@ -17,47 +15,47 @@ using Spectrum_Marker_Index = Plot_Index; using Spectrum_Partition_Mode = Plot_Partition_Mode; struct Spectrum_Frame_Tag {}; struct Spectrum_Frame { - std::vector samples{}; /* 最近提交的当前频谱功率样本。 */ - std::vector maxima{}; /* 与 samples 同尺寸的逐点历史最大值。 */ - std::vector minima{}; /* 与 samples 同尺寸的逐点历史最小值。 */ + std::vector samples{}; /* 最近提交的当前频谱功率样本。 */ bool operator==(const Spectrum_Frame&) const; }; /* 使用频率轴和功率轴分块准备、绘制当前值、保持曲线及频率标记。 */ -struct Spectrum : Def, Tagged_Buffer> { +struct Spectrum : Def> { using Scene_Object = Impl; using Frequency_Object = Impl; using Power_Object = Impl; struct Prop : Prev_Prop { - Spectrum_Frequency center_frequency{50.0}; /* 中心频率标记位置,单位为 Hz。 */ - std::size_t partition_count{1}; /* fixed 模式使用的 Prepare 子图分块数。 */ - bool max_hold_visible{}; /* 是否绘制逐点历史最大值曲线。 */ - bool min_hold_visible{}; /* 是否绘制逐点历史最小值曲线。 */ - bool max_marker_visible{}; /* 是否标记当前样本的最大值位置。 */ - bool min_marker_visible{}; /* 是否标记当前样本的最小值位置。 */ - bool sweep_region_visible{}; /* 是否绘制扫频范围背景。 */ - bool visible_range_only{true}; /* 是否裁掉频率轴当前范围外的线段。 */ - Axis_Range frequency_range{}; /* 输入样本首尾对应的有向频率范围,单位为 Hz。 */ - Axis_Range sweep_frequency_range{40.0, 60.0}; /* 扫频背景覆盖的频率范围,单位为 Hz。 */ + Prop() { cache_enabled = true; } + Spectrum_Frequency center_frequency{50.0}; /* 中心频率标记位置,单位为 Hz。 */ + std::size_t partition_count{1}; /* fixed 模式使用的 Prepare 子图分块数。 */ + bool max_hold_visible{}; /* 是否绘制逐点历史最大值曲线。 */ + bool min_hold_visible{}; /* 是否绘制逐点历史最小值曲线。 */ + bool max_marker_visible{}; /* 是否标记当前样本的最大值位置。 */ + bool min_marker_visible{}; /* 是否标记当前样本的最小值位置。 */ + bool sweep_region_visible{}; /* 是否绘制扫频范围背景。 */ + bool visible_range_only{true}; /* 是否裁掉频率轴当前范围外的线段。 */ + Axis_Range frequency_range{}; /* 输入样本首尾对应的有向频率范围,单位为 Hz。 */ + Axis_Range sweep_frequency_range{40.0, 60.0}; /* 扫频背景覆盖的频率范围,单位为 Hz。 */ Spectrum_Partition_Mode partition_mode{Spectrum_Partition_Mode::automatic}; /* Prepare 子图的分块策略。 */ Line_Interpolation_Mode interpolation_mode{Line_Interpolation_Mode::linear_value}; /* 相邻样本间的插值规则。 */ - Brush max_brush{}; /* 最大保持曲线下方的填充样式。 */ - Brush current_brush{}; /* 当前频谱曲线下方的填充样式。 */ - Brush min_brush{}; /* 最小保持曲线下方的填充样式。 */ - Pen max_pen{Color::red_color()}; /* 最大保持曲线及最大值标记样式。 */ - Pen current_pen{Color::green_color()}; /* 当前频谱曲线样式。 */ - Pen min_pen{Color::white()}; /* 最小保持曲线及最小值标记样式。 */ - Pen selected_marker_pen{Color{0, 0, 139, 255}, 2.0}; /* 当前选中自定义标记的线条样式。 */ - Pen marker_pen{Color::red_color()}; /* 未选中自定义标记的线条样式。 */ - Pen middle_frequency_pen{Color::red_color()}; /* 中心频率垂线样式。 */ + Brush max_brush{}; /* 最大保持曲线下方的填充样式。 */ + Brush current_brush{}; /* 当前频谱曲线下方的填充样式。 */ + Brush min_brush{}; /* 最小保持曲线下方的填充样式。 */ + Pen max_pen{Color::red_color()}; /* 最大保持曲线及最大值标记样式。 */ + Pen current_pen{Color::green_color()}; /* 当前频谱曲线样式。 */ + Pen min_pen{Color::white()}; /* 最小保持曲线及最小值标记样式。 */ + Pen selected_marker_pen{Color{0, 0, 139, 255}, 2.0}; /* 当前选中自定义标记的线条样式。 */ + Pen marker_pen{Color::red_color()}; /* 未选中自定义标记的线条样式。 */ + Pen middle_frequency_pen{Color::red_color()}; /* 中心频率垂线样式。 */ Brush sweep_region_brush{Color{255, 255, 0, 100}, Brush_Style::solid}; /* 扫频区域背景样式。 */ - std::vector custom_markers{}; /* 自定义频率标记的唯一权威集合,单位为 Hz。 */ - Spectrum_Marker_Index selected_marker{-1}; /* 当前选中标记下标;-1 表示未选择。 */ + std::vector custom_markers{}; /* 自定义频率标记的唯一权威集合,单位为 Hz。 */ + Spectrum_Marker_Index selected_marker{-1}; /* 当前选中标记下标;-1 表示未选择。 */ bool operator==(const Prop&) const; }; struct State : Prev_State { - std::size_t sample_count{}; /* 最近一次发布帧的样本数。 */ - std::size_t rendered_point_count{}; /* 最近一次 Prepare 生成的曲线点数。 */ - std::size_t selectable_marker_count{}; /* 当前可选择的自定义标记数。 */ + std::size_t sample_count{}; /* 最近一次发布帧的样本数。 */ + std::size_t rendered_point_count{}; /* 最近一次 Prepare 生成的曲线点数。 */ + std::size_t selectable_marker_count{}; /* 当前可选择的自定义标记数。 */ bool operator==(const State&) const; }; /* 完整声明、分块子图、内部状态操作和 CRTP 分派见 Spectrum.ipp。 */ @@ -68,20 +66,9 @@ struct Spectrum : Def, Tag Builder(Frequency_Object* frequency_axis, Power_Object* power_axis); [[nodiscard]] std::expected, Dependency_Graph_Error> build(); private: - Frequency_Object* frequency_axis{}; /* 不拥有的频率轴;生命周期必须覆盖 Spectrum。 */ - Power_Object* power_axis{}; /* 不拥有的功率轴;生命周期必须覆盖 Spectrum。 */ + Frequency_Object* frequency_axis{}; /* 不拥有的频率轴;生命周期必须覆盖 Spectrum。 */ + Power_Object* power_axis{}; /* 不拥有的功率轴;生命周期必须覆盖 Spectrum。 */ }; - /* 提交新一帧样本并更新逐点最大、最小保持。 */ - void update_samples(std::span values); - /* 接收 PMR 连续样本容器;提交完成后调用方仍拥有容器。 */ - void update_samples(std::pmr::vector&& values); - /* 接收具有 data() 和 size() 的连续样本容器。 */ - template - void update_samples(const Values& values); - /* 返回最近一次双缓冲交换后发布的样本数。 */ - [[nodiscard]] std::size_t sample_count() const; - /* 返回最近一次 Prepare 子图得到的当前曲线点数。 */ - [[nodiscard]] std::size_t rendered_point_count() const; enum class Power_At_Result { no_samples, invalid_frequency_range, diff --git a/render_2D/render_2D/plottable/Spectrum.ipp b/render_2D/render_2D/plottable/Spectrum.ipp index f1b70ad..ff208e0 100644 --- a/render_2D/render_2D/plottable/Spectrum.ipp +++ b/render_2D/render_2D/plottable/Spectrum.ipp @@ -30,9 +30,8 @@ struct Spectrum::Private : Prev_Private { Size canvas_size{}; /* 所属 Scene viewport 决定的颜色层尺寸。 */ bool valid{}; /* 两根轴与画布是否足以生成绘制数据。 */ }; - using Update_Run = void (*)(Root*, std::span); - using Count_Run = std::size_t (*)(const Root*); using Power_Run = std::expected (*)(const Root*, Spectrum_Frequency); + using Count_Run = std::size_t (*)(const Root*); using Frequency_Run = void (*)(Root*, Spectrum_Frequency); using Void_Run = void (*)(Root*); using Index_Get_Run = Spectrum_Marker_Index (*)(const Root*); @@ -41,9 +40,6 @@ struct Spectrum::Private : Prev_Private { using Set_Marker_Frequency_Run = Set_Marker_Frequency_Result (*)(Root*, Spectrum_Marker_Index, Spectrum_Frequency); using Set_Current_Marker_Frequency_Run = Set_Current_Marker_Frequency_Result (*)(Root*, Spectrum_Frequency); struct Dispatch { - Update_Run update_samples; /* 向最终对象写入 Spectrum_Frame_Tag。 */ - Count_Run sample_count; /* 查询最终对象已发布样本数量。 */ - Count_Run rendered_point_count; /* 查询 Prepare 子图生成的曲线点数。 */ Power_Run power_at; /* 查询最终对象已发布帧的插值功率。 */ Frequency_Run add_marker; /* 添加自定义标记。 */ Frequency_Run remove_marker; /* 删除最接近指定频率的标记。 */ @@ -62,6 +58,8 @@ struct Spectrum::Private : Prev_Private { Frequency_Object* frequency_axis{}; /* 不拥有的频率轴;Prepare 直接读取其当前状态。 */ Power_Object* power_axis{}; /* 不拥有的功率轴;Prepare 直接读取其当前状态。 */ Prepared prepared{}; /* 当前权威 State、Frame 和轴状态推导出的 Paint 输入。 */ + std::vector maxima{}; /* 当前样本历史逐点最大值;只由 Prepare 更新。 */ + std::vector minima{}; /* 当前样本历史逐点最小值;只由 Prepare 更新。 */ std::size_t prepare_graph_partition_count{}; /* 当前 Prepare 子图实际固化的任务分块数。 */ const Dispatch* dispatch{}; /* Builder 绑定最终 Spectrum 类型后的静态分派表。 */ /* CRTP 覆盖:绑定 Renderable 机制和 Spectrum 公开薄壳分派;派生 Private 必须先调用此实现。 */ @@ -69,7 +67,6 @@ struct Spectrum::Private : Prev_Private { /* Builder 内部绑定 Scene 与两根轴;三个来源都必须比 Spectrum 生命周期更长。 */ void bind_render_sources(Frequency_Object* frequency_axis_value, Power_Object* power_axis_value); template [[nodiscard]] static const Dispatch& dispatch_for(); - template void update_samples(Object* object, std::span values); template void add_marker(Object* object, Spectrum_Frequency frequency); template void remove_marker(Object* object, Spectrum_Frequency frequency); template void remove_selected_marker(Object* object); @@ -128,10 +125,6 @@ std::expected, Dependency_Graph_Error> Spectrum::Builder }; return spectrum; } -template -void Spectrum::update_samples(const Values& values) { - update_samples(std::span(std::data(values), std::size(values))); -} inline std::expected Spectrum::Private::power_at(const Prop& state, const Spectrum_Frame& frame, Spectrum_Frequency frequency) { if (frame.samples.empty()) return std::unexpected(Power_At_Result::no_samples); if (state.frequency_range.length() == 0.0) return std::unexpected(Power_At_Result::invalid_frequency_range); @@ -174,6 +167,15 @@ void Spectrum::Private::prepare_frame(Object* object, std::size_t partition_coun auto& private_data = static_cast(*this); const auto& state = static_cast(*private_data.current); const auto& frame = object->template current_buffer(); + if (maxima.size() != frame.samples.size()) { + maxima = frame.samples; + minima = frame.samples; + } else { + for (std::size_t index = 0; index < frame.samples.size(); ++index) { + maxima[index] = std::max(maxima[index], frame.samples[index]); + minima[index] = std::min(minima[index], frame.samples[index]); + } + } const auto& scene_state = scene->template read_prop(); const auto& frequency_layout = frequency_axis->template read_prop(); const auto& power_layout = power_axis->template read_prop(); @@ -218,8 +220,8 @@ void Spectrum::Private::prepare_partition(Object* object, std::size_t partition_ auto& partition = prepared.partitions[partition_index]; partition.clip = detail::map_plot_rect(frequency_axis, range.domain, power_axis, power_state.coordinate_range, frequency_layout.orientation); partition.current = detail::prepare_curve(std::span(frame.samples).subspan(range.first_sample, range.sample_count), range.domain, state.interpolation_mode, state.visible_range_only, frequency_state.coordinate_range, power_state.coordinate_range, frequency_axis, power_axis, frequency_layout.orientation, power_layout.orientation); - if (state.max_hold_visible && frame.maxima.size() == frame.samples.size()) partition.maximum = detail::prepare_curve(std::span(frame.maxima).subspan(range.first_sample, range.sample_count), range.domain, state.interpolation_mode, state.visible_range_only, frequency_state.coordinate_range, power_state.coordinate_range, frequency_axis, power_axis, frequency_layout.orientation, power_layout.orientation); - if (state.min_hold_visible && frame.minima.size() == frame.samples.size()) partition.minimum = detail::prepare_curve(std::span(frame.minima).subspan(range.first_sample, range.sample_count), range.domain, state.interpolation_mode, state.visible_range_only, frequency_state.coordinate_range, power_state.coordinate_range, frequency_axis, power_axis, frequency_layout.orientation, power_layout.orientation); + if (state.max_hold_visible && maxima.size() == frame.samples.size()) partition.maximum = detail::prepare_curve(std::span(maxima).subspan(range.first_sample, range.sample_count), range.domain, state.interpolation_mode, state.visible_range_only, frequency_state.coordinate_range, power_state.coordinate_range, frequency_axis, power_axis, frequency_layout.orientation, power_layout.orientation); + if (state.min_hold_visible && minima.size() == frame.samples.size()) partition.minimum = detail::prepare_curve(std::span(minima).subspan(range.first_sample, range.sample_count), range.domain, state.interpolation_mode, state.visible_range_only, frequency_state.coordinate_range, power_state.coordinate_range, frequency_axis, power_axis, frequency_layout.orientation, power_layout.orientation); } template void Spectrum::Private::paint_frame(Object* object) { @@ -246,20 +248,6 @@ void Spectrum::Private::paint_frame(Object* object) { } } template -void Spectrum::Private::update_samples(Object* object, std::span values) { - const Spectrum_Frame& previous = object->template current_buffer(); - Spectrum_Frame next; - next.samples.assign(values.begin(), values.end()); - next.maxima.resize(values.size()); - next.minima.resize(values.size()); - for (std::size_t index = 0; index < values.size(); ++index) { - next.maxima[index] = previous.maxima.size() == values.size() ? std::max(previous.maxima[index], values[index]) : values[index]; - next.minima[index] = previous.minima.size() == values.size() ? std::min(previous.minima[index], values[index]) : values[index]; - } - object->template pending_buffer() = std::move(next); - object->template mark_dirty(); -} -template void Spectrum::Private::add_marker(Object* object, Spectrum_Frequency frequency) { object->template update_prop<&Prop::custom_markers>([frequency](Prop_Access props) { props.template get().custom_markers.push_back(frequency); }); object->template mark_dirty(); @@ -336,9 +324,6 @@ void Spectrum::Private::before_advance(Object*, Prop_Type*, State_Access const Spectrum::Private::Dispatch& Spectrum::Private::dispatch_for() { static const Dispatch value{ - [](Root* root, std::span values) { auto* object = static_cast(root); static_cast(*object->d).update_samples(object, values); }, - [](const Root* root) { return static_cast(root)->template current_buffer().samples.size(); }, - [](const Root* root) { const auto& data = static_cast(*static_cast(root)->d); std::size_t count{}; for (const auto& partition : data.prepared.partitions) count += partition.current.points.size(); return count; }, [](const Root* root, Spectrum_Frequency frequency) { const auto* object = static_cast(root); const auto& data = static_cast(*object->d); return Private::power_at(static_cast(*data.current), object->template current_buffer(), frequency); }, [](Root* root, Spectrum_Frequency frequency) { auto* object = static_cast(root); static_cast(*object->d).add_marker(object, frequency); }, [](Root* root, Spectrum_Frequency frequency) { auto* object = static_cast(root); static_cast(*object->d).remove_marker(object, frequency); }, diff --git a/render_2D/render_2D/plottable/Sweep_Spectrum.hpp b/render_2D/render_2D/plottable/Sweep_Spectrum.hpp index 844f29b..b61fcae 100644 --- a/render_2D/render_2D/plottable/Sweep_Spectrum.hpp +++ b/render_2D/render_2D/plottable/Sweep_Spectrum.hpp @@ -10,13 +10,14 @@ #include namespace aethera::render_2d { struct Sweep_Spectrum_Stream_Tag {}; -struct Sweep_Spectrum : Def, +struct Sweep_Spectrum : Def>>> { using Scene_Object = Impl; using Frequency_Object = Impl; using Power_Object = Impl; struct Prop : Prev_Prop { + Prop() { cache_enabled = true; } std::size_t bins_per_block{}; /* 每个扫频块期望的功率点数;零值接受首块尺寸。 */ std::size_t block_count{1}; /* 一个完整扫频周期包含的块数;零值按 1 处理。 */ Plot_Partition_Count partition_count{1}; /* fixed 模式使用的 Prepare 子图分块数。 */ diff --git a/render_2D/render_2D/plottable/Waterfall.hpp b/render_2D/render_2D/plottable/Waterfall.hpp index 4a4f8ef..c39a524 100644 --- a/render_2D/render_2D/plottable/Waterfall.hpp +++ b/render_2D/render_2D/plottable/Waterfall.hpp @@ -16,12 +16,13 @@ struct Waterfall_Row { bool operator==(const Waterfall_Row&) const; }; struct Waterfall_Stream_Tag {}; -struct Waterfall : Def, +struct Waterfall : Def>> { using Scene_Object = Impl; using Frequency_Object = Impl; using Time_Object = Impl; struct Prop : Prev_Prop { + Prop() { cache_enabled = true; } bool tooltip_enabled{true}; /* 是否响应指针位置显示提示。 */ Font tooltip_font{}; /* 提示文字字体。 */ Pen tooltip_text_pen{Color::white()}; /* 提示文字样式。 */ diff --git a/render_2D/render_2D/scene/Render_Scene_2D.ipp b/render_2D/render_2D/scene/Render_Scene_2D.ipp index f501dcd..a31587c 100644 --- a/render_2D/render_2D/scene/Render_Scene_2D.ipp +++ b/render_2D/render_2D/scene/Render_Scene_2D.ipp @@ -86,6 +86,13 @@ template void Render_Scene_2D::Private::after_advance(Object* object, Prop*, State_Access, const Prop*, State_Access) { bool rebuild = !paint_taskflow; object->template access_pending_dependency_graph([&](auto& paint_state) { rebuild = rebuild || paint_state.dirty(); }); + for (auto& node : paint_order) { + const bool enabled = node.private_data->cache_enabled && + node.private_data->cache_enabled(node.object); + if (enabled == (node.cache_owner == node.object)) continue; + node.private_data->valid_cache = nullptr; + rebuild = true; + } if (!rebuild) return; if (!paint_taskflow) paint_taskflow = std::make_unique(); auto& taskflow = *paint_taskflow; @@ -100,7 +107,7 @@ void Render_Scene_2D::Private::after_advance(Object* object, Prop*, State_Access auto* data = view.private_data(node); if (!data) return; Root* cache_owner{}; - if (data->pending_cache) cache_owner = node.object; + if (data->cache_enabled && data->cache_enabled(node.object)) cache_owner = node.object; else { bool conflict{}; for (const auto* dependency : node.dependencies) { diff --git a/render_2D/tests/Plottable_Migration_Test.cpp b/render_2D/tests/Plottable_Migration_Test.cpp index c3c632a..a8c80b1 100644 --- a/render_2D/tests/Plottable_Migration_Test.cpp +++ b/render_2D/tests/Plottable_Migration_Test.cpp @@ -207,11 +207,11 @@ TEST(selection_overlay, control_extends_selection_and_plain_click_clears_it) { render_once(scene.get()); }; drag({30.0, 90.0}, {60.0, 60.0}, Keyboard_Modifier::none); - EXPECT_EQ(overlay->selected_regions().size(), 1u); + EXPECT_EQ(overlay->read_prop().selected_regions.size(), 1u); drag({70.0, 90.0}, {100.0, 60.0}, Keyboard_Modifier::control); - EXPECT_EQ(overlay->selected_regions().size(), 2u); + EXPECT_EQ(overlay->read_prop().selected_regions.size(), 2u); drag({120.0, 40.0}, {120.0, 40.0}, Keyboard_Modifier::none); - EXPECT_TRUE(overlay->selected_regions().empty()); + EXPECT_TRUE(overlay->read_prop().selected_regions.empty()); } TEST(selection_overlay, time_axis_selection_keeps_axis_coordinates_while_window_moves) { @@ -247,12 +247,12 @@ TEST(selection_overlay, time_axis_selection_keeps_axis_coordinates_while_window_ release->button = Mouse_Button::left; scene->submit_stream(release); render_once(scene.get()); - ASSERT_EQ(overlay->selected_regions().size(), 1u); - const Axis_Rectangle selected = overlay->selected_regions().front(); + ASSERT_EQ(overlay->read_prop().selected_regions.size(), 1u); + const Axis_Rectangle selected = overlay->read_prop().selected_regions.front(); const auto pixel_before = time->coordinate_to_pixel(selected.horizontal.center()); time->append_time({4'000}); render_once(scene.get()); - ASSERT_EQ(overlay->selected_regions().size(), 1u); - EXPECT_EQ(overlay->selected_regions().front(), selected); + ASSERT_EQ(overlay->read_prop().selected_regions.size(), 1u); + EXPECT_EQ(overlay->read_prop().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 97239b7..ec286ac 100644 --- a/render_2D/tests/Spectrum_Test.cpp +++ b/render_2D/tests/Spectrum_Test.cpp @@ -49,9 +49,11 @@ TEST(spectrum_data, publishes_samples_and_interpolates_power) { EXPECT_EQ(missing.error(), Spectrum::Power_At_Result::no_samples); spectrum->set<&Spectrum::Prop::frequency_range>(Axis_Range{0.0, 100.0}); const double samples[]{-100.0, -50.0, 0.0}; - spectrum->update_samples(samples); + spectrum->pending_buffer() = + Spectrum_Frame{std::vector(std::begin(samples), std::end(samples))}; + spectrum->mark_dirty(); spectrum->advance(); - EXPECT_EQ(spectrum->sample_count(), 3u); + EXPECT_EQ(spectrum->current_buffer().samples.size(), 3u); const auto value = spectrum->power_at(25.0); ASSERT_TRUE(value.has_value()); EXPECT_DOUBLE_EQ(*value, -75.0); @@ -111,7 +113,9 @@ TEST(render_scene_2d, composites_axes_and_spectrum_into_final_frame) { spectrum->set<&Spectrum::Prop::sweep_region_visible>(true); spectrum->set<&Spectrum::Prop::max_hold_visible>(true); const double samples[]{-90.0, -65.0, -20.0, -45.0, -75.0}; - spectrum->update_samples(samples); + spectrum->pending_buffer() = + Spectrum_Frame{std::vector(std::begin(samples), std::end(samples))}; + spectrum->mark_dirty(); scene->set<&Render_Scene_2D::Prop::viewport>(canvas); scene->set<&Render_Scene_2D::Prop::background>(Color::transparent()); scene->activate_view(); @@ -127,7 +131,7 @@ TEST(render_scene_2d, composites_axes_and_spectrum_into_final_frame) { EXPECT_TRUE(cache_graph.depends_on(spectrum.get(), frequency.get())); EXPECT_TRUE(cache_graph.depends_on(spectrum.get(), power.get())); auto output_frame = render_frame(scene.get()); - EXPECT_GT(spectrum->rendered_point_count(), 0u); + EXPECT_GT(spectrum->read_state().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); @@ -153,7 +157,9 @@ TEST(render_scene_2d, composites_axes_and_spectrum_into_final_frame) { EXPECT_FALSE(spectrum->dirty()); EXPECT_TRUE(spectrum->read_state().prepare_executed); spectrum->set<&Spectrum::Prop::partition_count>(2u); - spectrum->update_samples(samples); + spectrum->pending_buffer() = + Spectrum_Frame{std::vector(std::begin(samples), std::end(samples))}; + spectrum->mark_dirty(); output_frame = render_frame(scene.get()); const auto& rebuilt_state = spectrum->read_state(); EXPECT_TRUE(rebuilt_state.prepare_graph_rebuilt); diff --git a/render_3D/render_3D/detail/Backend_Types.hpp b/render_3D/render_3D/detail/Backend_Types.hpp index daa460f..2e525cf 100644 --- a/render_3D/render_3D/detail/Backend_Types.hpp +++ b/render_3D/render_3D/detail/Backend_Types.hpp @@ -1,12 +1,42 @@ #pragma once -#include "../visual/Prepared_Visual.hpp" +#include "../visual/Visuals.hpp" #include #include #include "../camera/Camera.hpp" #include #include +#include #include namespace aethera::render_3d::detail { +using Prepared_Visual_Payload = std::variant< + std::shared_ptr, + std::shared_ptr, + std::shared_ptr, + std::shared_ptr, + std::shared_ptr, + std::shared_ptr, + std::shared_ptr, + std::shared_ptr, + std::shared_ptr, + std::shared_ptr, + std::shared_ptr, + std::shared_ptr, + std::shared_ptr, + std::shared_ptr, + std::shared_ptr>; +/* Scene 到 Backend 的唯一异构边界;Visual 对象内部不保存此信封。 */ +struct Prepared_Visual { + Matrix4 transform{}; + bool visible{true}; + bool depth_test{true}; + std::uint64_t revision{}; + Prepared_Visual_Payload data{}; +}; +template +Prepared_Visual erase_prepared_visual(const Prepared_Visual_For& source) { + return {source.transform, source.visible, source.depth_test, source.revision, + source.data}; +} using Visual_Identity = std::uintptr_t; struct Visual_Registration { Visual_Identity identity{}; /* Scene 内稳定对象身份,仅供后端匹配原生 Visual。 */ diff --git a/render_3D/render_3D/detail/Datoviz_Visual_Backend.cpp b/render_3D/render_3D/detail/Datoviz_Visual_Backend.cpp index 78efe0a..877a740 100644 --- a/render_3D/render_3D/detail/Datoviz_Visual_Backend.cpp +++ b/render_3D/render_3D/detail/Datoviz_Visual_Backend.cpp @@ -183,24 +183,51 @@ bool uses_external_attributes(Visual_Family family) noexcept { return family == Visual_Family::mesh || family == Visual_Family::marker; } -bool has_coordinate_labels(const Prepared_Visual_Data& data) noexcept { +template +const Data& prepared_data(const Prepared_Visual& visual) { + const auto* value = std::get_if>(&visual.data); + if (value == nullptr || !*value) + throw std::logic_error("3D prepared Visual payload does not match its family"); + return **value; +} + +bool has_payload(const Prepared_Visual& visual) noexcept { + return std::visit([](const auto& value) { return static_cast(value); }, + visual.data); +} + +std::size_t prepared_item_count(const Prepared_Visual& visual) noexcept { + return std::visit([](const auto& value) -> std::size_t { + if (!value) return 0; + if constexpr (requires { value->positions; }) return value->positions.size(); + else if constexpr (requires { value->centers; }) return value->centers.size(); + else if constexpr (requires { value->starts; }) return value->starts.size(); + else if constexpr (requires { value->origins; }) return value->origins.size(); + else return value->values.size(); + }, visual.data); +} + +bool has_coordinate_labels(const Prepared_Visual& visual) noexcept { + const auto* payload = std::get_if>( + &visual.data); + if (payload == nullptr || !*payload) return false; return std::ranges::any_of( - data.coordinate_label_visibility, + (*payload)->coordinate_label_visibility, [](std::uint8_t visible) { return visible != 0; }); } bool same_visual_structure(const Prepared_Visual& applied, const Prepared_Visual& incoming) noexcept { - if (applied.family != incoming.family || + if (applied.data.index() != incoming.data.index() || applied.transform != incoming.transform || applied.visible != incoming.visible || applied.depth_test != incoming.depth_test || - !applied.data || !incoming.data || - applied.data->positions.size() != incoming.data->positions.size()) + !has_payload(applied) || !has_payload(incoming) || + prepared_item_count(applied) != prepared_item_count(incoming)) return false; - if (incoming.family == Visual_Family::marker && - (has_coordinate_labels(*applied.data) || - has_coordinate_labels(*incoming.data))) + if ((std::holds_alternative>( + incoming.data)) && + (has_coordinate_labels(applied) || has_coordinate_labels(incoming))) return applied.revision == incoming.revision; return true; } @@ -1327,8 +1354,8 @@ void Datoviz_Visual_Backend::apply( } void Datoviz_Visual_Backend::ensure_external_attributes( - Visual_Instance& target, const Prepared_Visual_Data& data) { - const auto item_count = data.positions.size(); + Visual_Instance& target, const Prepared_Visual& visual) { + const auto item_count = prepared_item_count(visual); if (item_count == 0) return; if (item_count > std::numeric_limits::max()) throw std::length_error("Datoviz external attribute item count exceeds uint32"); @@ -1393,12 +1420,13 @@ void Datoviz_Visual_Backend::ensure_external_attributes( } void Datoviz_Visual_Backend::upload_external_attributes( - Visual_Instance& target, const Prepared_Visual_Data& data, + Visual_Instance& target, const Prepared_Visual& visual, std::uint8_t target_index) { - if (data.positions.empty()) return; + const auto item_count = prepared_item_count(visual); + if (item_count == 0) return; const auto upload = [&](External_Attribute& attribute, const void* source, std::size_t count) { - if (source == nullptr || count != data.positions.size()) + if (source == nullptr || count != item_count) throw std::logic_error("Datoviz external attribute fields have different item counts"); const auto byte_count = static_cast(count * attribute.stride); const auto byte_offset = static_cast(target_index) * @@ -1406,12 +1434,16 @@ void Datoviz_Visual_Backend::upload_external_attributes( dvz_buffer_upload(attribute.gpu_buffer, byte_offset, byte_count, source); }; auto attribute = target.attributes.begin(); - upload(*attribute++, data.positions.data(), data.positions.size()); - upload(*attribute++, data.colors.data(), data.colors.size()); - if (target.family == Visual_Family::mesh) + if (target.family == Visual_Family::mesh) { + const auto& data = prepared_data(visual); + upload(*attribute++, data.positions.data(), data.positions.size()); + upload(*attribute++, data.colors.data(), data.colors.size()); upload(*attribute++, data.normals.data(), data.normals.size()); - else { - upload(*attribute++, data.sizes.data(), data.sizes.size()); + } else { + const auto& data = prepared_data(visual); + upload(*attribute++, data.positions.data(), data.positions.size()); + upload(*attribute++, data.colors.data(), data.colors.size()); + upload(*attribute++, data.diameters.data(), data.diameters.size()); upload(*attribute++, data.angles.data(), data.angles.size()); upload(*attribute++, data.shapes.data(), data.shapes.size()); } @@ -1466,19 +1498,18 @@ void Datoviz_Visual_Backend::register_external_attributes( void Datoviz_Visual_Backend::apply_visual( Visual_Instance& target, const Prepared_Visual& point, std::uint8_t target_index, bool bind_target) { - if (point.family != target.family) - throw std::logic_error("3D Visual family changed after Scene construction"); - if (!point.data) throw std::logic_error("3D prepared visual has no immutable payload"); + if (!has_payload(point)) throw std::logic_error("3D prepared visual has no immutable payload"); if (!uses_external_attributes(target.family) && point.revision == target.applied_revision) return; - const auto& data = *point.data; + const auto item_count = prepared_item_count(point); auto* visual = target.visual; const bool structure_changed = !target.applied || !same_visual_structure(*target.applied, point); const bool coordinate_text_changed = target.coordinate_text != nullptr && - (!target.applied || has_coordinate_labels(*target.applied->data) || - has_coordinate_labels(data)); + (!target.applied || has_coordinate_labels(*target.applied) || + has_coordinate_labels(point)); if (coordinate_text_changed) { + const auto& data = prepared_data(point); std::vector strings; std::vector items; if (point.visible) { @@ -1517,34 +1548,35 @@ void Datoviz_Visual_Backend::apply_visual( mat4 transform{}; for (std::size_t row = 0; row < 4; ++row) for (std::size_t column = 0; column < 4; ++column) transform[row][column] = point.transform.values[row * 4 + column]; - if (point.family == Visual_Family::point) { + if (target.family == Visual_Family::point) { + const auto& data = prepared_data(point); DvzPointStyleDesc style = dvz_point_style_desc(); - style.edge_color.r = point.point_style.edge_color.red; - style.edge_color.g = point.point_style.edge_color.green; - style.edge_color.b = point.point_style.edge_color.blue; - style.edge_color.a = point.point_style.edge_color.alpha; - style.stroke_width_px = point.point_style.stroke_width_px; - style.aspect = aspect(point.point_style.aspect); + style.edge_color.r = data.style.edge_color.red; + style.edge_color.g = data.style.edge_color.green; + style.edge_color.b = data.style.edge_color.blue; + style.edge_color.a = data.style.edge_color.alpha; + style.stroke_width_px = data.style.stroke_width_px; + style.aspect = aspect(data.style.aspect); if (dvz_point_set_style(visual, &style) != DVZ_OK) throw std::runtime_error("failed to apply Datoviz point style"); } if (dvz_visual_set_transform(visual, transform) != DVZ_OK || dvz_visual_set_depth_test(visual, point.depth_test) != DVZ_OK || dvz_visual_set_visible( - visual, point.visible && !data.positions.empty()) != DVZ_OK) + visual, point.visible && item_count != 0) != DVZ_OK) throw std::runtime_error("failed to apply Datoviz visual state"); } - if (data.positions.empty()) { + if (item_count == 0) { if (dvz_visual_set_visible(visual, false) != DVZ_OK) throw std::runtime_error("failed to hide empty Datoviz point visual"); target.applied_revision = point.revision; target.applied = point; return; } - const auto count = static_cast(data.positions.size()); + const auto count = static_cast(item_count); if (uses_external_attributes(target.family)) { - ensure_external_attributes(target, data); + ensure_external_attributes(target, point); if (bind_target) bind_external_attributes(target, target_index, count); - upload_external_attributes(target, data, target_index); + upload_external_attributes(target, point, target_index); if (structure_changed && dvz_visual_set_visible(visual, point.visible) != DVZ_OK) throw std::runtime_error("failed to apply Datoviz external visual visibility"); @@ -1553,24 +1585,26 @@ void Datoviz_Visual_Backend::apply_visual( return; } DvzResult result = DVZ_OK; - switch (point.family) { + switch (target.family) { case Visual_Family::point: { + const auto& data = prepared_data(point); const std::array updates{ { {"position", data.positions.data(), count}, {"color", data.colors.data(), count}, - {"diameter_px", data.sizes.data(), count} + {"diameter_px", data.diameters.data(), count} } }; result = dvz_visual_set_data_many(visual, updates.data(), 3); break; } case Visual_Family::splat: { + const auto& data = prepared_data(point); const std::array updates{ { {"position", data.positions.data(), count}, {"color", data.colors.data(), count}, - {"sigma", data.sigma.data(), count}, + {"sigma", data.sigmas.data(), count}, {"angle", data.angles.data(), count} } }; @@ -1578,6 +1612,7 @@ void Datoviz_Visual_Backend::apply_visual( break; } case Visual_Family::pixel: { + const auto& data = prepared_data(point); const std::array updates{ { {"position", data.positions.data(), count}, @@ -1589,11 +1624,12 @@ void Datoviz_Visual_Backend::apply_visual( break; } case Visual_Family::marker: { + const auto& data = prepared_data(point); const std::array updates{ { {"position", data.positions.data(), count}, {"color", data.colors.data(), count}, - {"diameter_px", data.sizes.data(), count}, + {"diameter_px", data.diameters.data(), count}, {"angle", data.angles.data(), count}, {"shape", data.shapes.data(), count} } @@ -1602,42 +1638,57 @@ void Datoviz_Visual_Backend::apply_visual( break; } case Visual_Family::sphere: { + const auto& data = prepared_data(point); const std::array updates{ { - {"position", data.positions.data(), count}, + {"position", data.centers.data(), count}, {"color", data.colors.data(), count}, - {"radius", data.sizes.data(), count} + {"radius", data.radii.data(), count} } }; result = dvz_visual_set_data_many(visual, updates.data(), 3); break; } case Visual_Family::segment: { + const auto& data = prepared_data(point); const std::array updates{ { - {"position_start", data.positions.data(), count}, - {"position_end", data.secondary_positions.data(), count}, + {"position_start", data.starts.data(), count}, + {"position_end", data.ends.data(), count}, {"color", data.colors.data(), count}, - {"stroke_width_px", data.sizes.data(), count} + {"stroke_width_px", data.widths.data(), count} } }; result = dvz_visual_set_data_many(visual, updates.data(), 4); break; } case Visual_Family::vector: { + const auto& data = prepared_data(point); const std::array updates{ { - {"position", data.positions.data(), count}, - {"vector", data.secondary_positions.data(), count}, + {"position", data.origins.data(), count}, + {"vector", data.directions.data(), count}, {"color", data.colors.data(), count}, - {"stroke_width_px", data.sizes.data(), count} + {"stroke_width_px", data.widths.data(), count} } }; result = dvz_visual_set_data_many(visual, updates.data(), 4); break; } - case Visual_Family::primitive: + case Visual_Family::primitive: { + const auto& data = prepared_data(point); + const std::array updates{ + { + {"position", data.positions.data(), count}, + {"color", data.colors.data(), count}, + {"normal", data.normals.data(), count} + } + }; + result = dvz_visual_set_data_many(visual, updates.data(), 3); + break; + } case Visual_Family::mesh: { + const auto& data = prepared_data(point); const std::array updates{ { {"position", data.positions.data(), count}, @@ -1649,18 +1700,30 @@ void Datoviz_Visual_Backend::apply_visual( break; } case Visual_Family::path: { + const auto& data = prepared_data(point); const std::array updates{ { {"position", data.positions.data(), count}, {"color", data.colors.data(), count}, - {"stroke_width_px", data.sizes.data(), count} + {"stroke_width_px", data.widths.data(), count} } }; result = dvz_visual_set_data_many(visual, updates.data(), 3); break; } - case Visual_Family::image: + case Visual_Family::image: { + const auto& data = prepared_data(point); + const std::array updates{ + { + {"position", data.positions.data(), count}, + {"extent", data.extents.data(), count} + } + }; + result = dvz_visual_set_data_many(visual, updates.data(), 2); + break; + } case Visual_Family::labels: { + const auto& data = prepared_data(point); const std::array updates{ { {"position", data.positions.data(), count}, @@ -1851,12 +1914,12 @@ Datoviz_Visual_Backend::reuse_recorded_target( throw std::logic_error( "recorded Datoviz frame contains an unknown Visual identity"); if (!uses_external_attributes(visual->family)) continue; - if (!source.visual.data) + if (!has_payload(source.visual)) throw std::logic_error( "recorded Datoviz Visual has no immutable payload"); - if (!source.visual.data->positions.empty()) + if (prepared_item_count(source.visual) != 0) upload_external_attributes( - *visual, *source.visual.data, target_index); + *visual, source.visual, target_index); visual->applied_revision = source.visual.revision; visual->applied = source.visual; } diff --git a/render_3D/render_3D/detail/Datoviz_Visual_Backend.hpp b/render_3D/render_3D/detail/Datoviz_Visual_Backend.hpp index 00eacc4..831469e 100644 --- a/render_3D/render_3D/detail/Datoviz_Visual_Backend.hpp +++ b/render_3D/render_3D/detail/Datoviz_Visual_Backend.hpp @@ -93,9 +93,9 @@ private: void apply_visual(Visual_Instance& target, const Prepared_Visual& visual, std::uint8_t target_index, bool bind_target); void ensure_external_attributes(Visual_Instance& target, - const Prepared_Visual_Data& data); + const Prepared_Visual& visual); void upload_external_attributes(Visual_Instance& target, - const Prepared_Visual_Data& data, + const Prepared_Visual& visual, std::uint8_t target_index); void bind_external_attributes(Visual_Instance& target, std::uint8_t target_index, diff --git a/render_3D/render_3D/scene/Render_Scene_3D.ipp b/render_3D/render_3D/scene/Render_Scene_3D.ipp index 6730593..48f1d07 100644 --- a/render_3D/render_3D/scene/Render_Scene_3D.ipp +++ b/render_3D/render_3D/scene/Render_Scene_3D.ipp @@ -59,20 +59,23 @@ Render_Scene_3D::Builder::add_renderable(Visual_Object* visual_value) { binding.object = visual_value; binding.family = Visual_Object::Attached_Object::Specification::family; binding.bind = [](Root* root, std::shared_ptr context) { + using Definition = typename Visual_Object::Attached_Object; + using Prepared = typename Definition::Prepared_Visual; auto* object = static_cast(root); - Base::private_access(object).template get() + Base::private_access(object).template get() .bind_paint_target(std::move(context), [](void* raw_context, const Root* identity, - const detail::Prepared_Visual& prepared) { + const Prepared& prepared) { auto& submission = *static_cast*>(raw_context); const auto visual_identity = reinterpret_cast(identity); + auto erased = detail::erase_prepared_visual(prepared); auto& visuals = *submission.visuals; const auto found = std::ranges::find( visuals, visual_identity, &detail::Prepared_Visual_Instance::identity); if (found == visuals.end()) - visuals.push_back({visual_identity, prepared}); + visuals.push_back({visual_identity, std::move(erased)}); else - found->visual = prepared; + found->visual = std::move(erased); }); }; visuals.push_back(binding); diff --git a/render_3D/render_3D/visual/Basic_Visual.hpp b/render_3D/render_3D/visual/Basic_Visual.hpp index f9b701a..425aae5 100644 --- a/render_3D/render_3D/visual/Basic_Visual.hpp +++ b/render_3D/render_3D/visual/Basic_Visual.hpp @@ -1,15 +1,17 @@ #pragma once #include "Prepared_Visual.hpp" #include -#include -#include #include #include namespace aethera::render_3d { template -struct Basic_Visual : Def, Renderable, Tagged_Buffer> { +struct Basic_Visual : Def, Renderable, + Tagged_Buffer>> { using Specification = Spec; using Item = typename Spec::Item; + using Prepared_Data = typename Spec::Prepared_Data; + using Prepared_Visual = detail::Prepared_Visual_For; struct Prop : Basic_Visual::Prev_Prop, Spec::Settings { Matrix4 transform{}; /* 本地坐标到 Scene 坐标的变换。 */ bool visible{true}; /* 是否向后端提交可见图元。 */ @@ -24,14 +26,6 @@ struct Basic_Visual : Def, Renderable, Tagged_Buffer items); - enum class Edit_Items_Result : std::uint8_t { updated, empty_edit, invalid_data }; - /* 在当前 Prop 副本上编辑图元集合,通过验证后原子提交。 */ - [[nodiscard]] Edit_Items_Result edit_items(const std::function&)>& edit); - /* 返回当前发布 Prop 中的图元数。 */ - [[nodiscard]] std::size_t item_count() const; }; } #include "Basic_Visual.ipp" diff --git a/render_3D/render_3D/visual/Basic_Visual.ipp b/render_3D/render_3D/visual/Basic_Visual.ipp index 02cb781..5e285ee 100644 --- a/render_3D/render_3D/visual/Basic_Visual.ipp +++ b/render_3D/render_3D/visual/Basic_Visual.ipp @@ -4,51 +4,95 @@ namespace aethera::render_3d { template struct Basic_Visual::Private : Basic_Visual::Prev_Private { - using Enqueue_Paint = void (*)(void*, const Root*, const detail::Prepared_Visual&); + using Enqueue_Paint = void (*)(void*, const Root*, const Prepared_Visual&); struct Paint_Target { - std::weak_ptr context{}; /* Scene 拥有的异步提交上下文;Scene 销毁后自动失效。 */ - Enqueue_Paint enqueue{}; /* 只入队不等待 GPU 完成的 Paint 入口。 */ + std::weak_ptr context{}; /* Scene 拥有的异步提交上下文;Scene 销毁后自动失效。 */ + Enqueue_Paint enqueue{}; /* 只入队不等待 GPU 完成的 Paint 入口。 */ }; - Paint_Target paint_target{}; /* Builder 绑定的所属 Scene 异步 Paint 目标。 */ - std::uint64_t next_revision{1}; /* 下一份 Prepared_Visual 的单调版本。 */ - using Update_Run = Update_Items_Result (*)(Root*, std::vector); - using Edit_Run = Edit_Items_Result (*)(Root*, const std::function&)>&); - using Count_Run = std::size_t (*)(const Root*); - struct Dispatch { - Update_Run update_items; /* 替换最终对象的 Prop 图元。 */ - Edit_Run edit_items; /* 编辑最终对象的 Prop 图元。 */ - Count_Run item_count; /* 查询最终对象当前 Prop 图元数。 */ - }; - const Dispatch* dispatch{}; /* 最终对象类型对应的静态公开分派表。 */ - /* CRTP 覆盖:绑定 Renderable 能力和 Basic_Visual 公开薄壳。 */ - template void bind_private_crtp(Object* object); + Paint_Target paint_target{}; /* Builder 绑定的所属 Scene 异步 Paint 目标。 */ + std::uint64_t next_revision{1}; /* 下一份 Prepared_Visual 的单调版本。 */ /* Scene Builder 调用:绑定只入队的异步 Paint 目标,目标生命周期必须覆盖 Visual。 */ void bind_paint_target(std::shared_ptr context, Enqueue_Paint enqueue); /* CRTP 数据能力:从当前 Prop 构建后端字段,不访问其他缓冲角色。 */ - template void prepare_data(Object* object); + template + void prepare_data(Object* object); /* CRTP 子图能力:每帧提交一次后端渲染;比较双槽 revision 后直接使用最新 Prepared 数据。 */ - template [[nodiscard]] tf::Taskflow build_paint_graph(Object* object, const Prop& prop); + template + [[nodiscard]] tf::Taskflow build_paint_graph(Object* object, const Prop& prop); /* CRTP 覆盖:绑定 Scene 后每帧执行 Paint;dirty 只表示 Prepared 数据是否更新,不控制帧生成。 */ - template [[nodiscard]] bool should_paint(Object* object, const State& state, bool dirty); + 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); + template + void after_prop_set(Object* object, Member Owner::* member, Prop_Access props); /* CRTP 推进 hook:从 Prop 计算公开计数,不复制图元集合。 */ - template void before_advance(Object* object, Prop_Type* pending_prop, State_Access pending_states, const Prop_Type* current_prop, State_Access current_states); - template [[nodiscard]] static const Dispatch& dispatch_for(); + template + void before_advance(Object* object, Prop_Type* pending_prop, State_Access pending_states, const Prop_Type* current_prop, State_Access current_states); [[nodiscard]] static bool valid_items(const std::vector& items); }; -template bool Basic_Visual::Prop::operator==(const Prop&) const = default; -template bool Basic_Visual::State::operator==(const State&) const = default; -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 data = std::make_shared(); Spec::prepare(prop.items, *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++; output.data = std::move(data); if constexpr (requires { prop.style; }) output.point_style = prop.style; object->template update_state<&State::prepared_item_count, &State::prepared_revision>([&](State_Access states) { auto& state = states.template get(); state.prepared_item_count = prop.items.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) { const auto& pending = object->template pending_buffer(); const auto& current = object->template current_buffer(); paint_target.enqueue(context.get(), object, pending.revision >= current.revision ? pending : current); } }).name("render_3d.paint.publish"); 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 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; } -template template void Basic_Visual::Private::bind_private_crtp(Object* object) { Basic_Visual::Prev_Private::bind_private_crtp(object); dispatch = &dispatch_for(); } -template typename Basic_Visual::Update_Items_Result Basic_Visual::update_items(std::vector items) { return static_cast(*this->d).dispatch->update_items(this, std::move(items)); } -template typename Basic_Visual::Edit_Items_Result Basic_Visual::edit_items(const std::function&)>& edit) { return static_cast(*this->d).dispatch->edit_items(this, edit); } -template std::size_t Basic_Visual::item_count() const { return static_cast(*this->d).dispatch->item_count(this); } +template +bool Basic_Visual::Prop::operator==(const Prop&) const = default; +template +bool Basic_Visual::State::operator==(const State&) const = default; +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 data = std::make_shared(); + Spec::prepare(prop.items, *data); + if constexpr (requires { data->style = prop.style; }) data->style = prop.style; + auto& output = object->template pending_buffer(); + output = {}; + output.transform = prop.transform; + output.visible = prop.visible; + output.depth_test = prop.depth_test; + output.revision = next_revision++; + output.data = std::move(data); + object->template update_state<&State::prepared_item_count, &State::prepared_revision>([&](State_Access states) { + auto& state = states.template get(); + state.prepared_item_count = prop.items.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) { + const auto& pending = object->template pending_buffer(); + const auto& current = object->template current_buffer(); + paint_target.enqueue(context.get(), object, pending.revision >= current.revision ? pending : current); + } + }).name("render_3d.paint.publish"); + 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 +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(); +} } diff --git a/render_3D/render_3D/visual/Prepared_Visual.hpp b/render_3D/render_3D/visual/Prepared_Visual.hpp index 8684fbf..47f757d 100644 --- a/render_3D/render_3D/visual/Prepared_Visual.hpp +++ b/render_3D/render_3D/visual/Prepared_Visual.hpp @@ -3,25 +3,16 @@ #include namespace aethera::render_3d::detail { struct Prepared_Visual_Tag {}; -struct Prepared_Visual_Data { - std::vector> positions{}; /* 主位置字段。 */ - std::vector> colors{}; /* RGBA8 颜色字段。 */ - std::vector sizes{}; /* 点径、半径或线宽字段。 */ - std::vector> sigma{}; /* Splat 椭圆标准差字段。 */ - std::vector angles{}; /* Splat、Marker 旋转角字段。 */ - std::vector shapes{}; /* Marker 形状字段。 */ - std::vector coordinate_label_visibility{}; /* Marker XYZ 标签逐项开关。 */ - std::vector> secondary_positions{}; /* 线段终点或向量字段。 */ - std::vector> normals{}; /* Primitive、Mesh 法线字段。 */ - std::vector> extents{}; /* Image、Labels 尺寸字段。 */ -}; -struct Prepared_Visual { - Visual_Family family{Visual_Family::point}; /* Datoviz Visual 类型。 */ - Matrix4 transform{}; /* 本帧提交的对象变换。 */ - Point_Style point_style{}; /* point family 使用的点样式。 */ - bool visible{true}; /* 本帧是否绘制。 */ - bool depth_test{true}; /* 本帧是否启用深度测试。 */ - std::uint64_t revision{}; /* 数据或样式改变时递增的提交版本。 */ - std::shared_ptr data{}; /* Prepare 发布的不可变 GPU 字段;异步提交只共享所有权。 */ +using Prepared_Position = std::array; +using Prepared_Color = std::array; + +/* 每个 Visual 的双缓冲只保存自己 Spec::Prepared_Data 的强类型结果。 */ +template +struct Prepared_Visual_For { + Matrix4 transform{}; + bool visible{true}; + bool depth_test{true}; + std::uint64_t revision{}; + std::shared_ptr data{}; }; } diff --git a/render_3D/render_3D/visual/Visuals.hpp b/render_3D/render_3D/visual/Visuals.hpp index 26a768f..a99e60c 100644 --- a/render_3D/render_3D/visual/Visuals.hpp +++ b/render_3D/render_3D/visual/Visuals.hpp @@ -2,134 +2,258 @@ #include "Basic_Visual.hpp" namespace aethera::render_3d { struct Point { - Vec3 position{}; /* Scene 坐标位置。 */ - Color color{Color::white()}; /* 点填充颜色。 */ - Pixel_Distance diameter_px{8.0F}; /* 点直径,单位为像素;必须为正数。 */ + Vec3 position{}; /* Scene 坐标位置。 */ + Color color{Color::white()}; /* 点填充颜色。 */ + Pixel_Distance diameter_px{8.0F}; /* 点直径,单位为像素;必须为正数。 */ bool operator==(const Point&) const = default; }; struct Splat { - Vec3 position{}; /* Scene 坐标位置。 */ - Color color{Color::white()}; /* Splat 颜色。 */ - Vec2 sigma{0.05F, 0.05F}; /* 两个主轴方向的标准差;必须为正数。 */ - Coordinate_3D angle{}; /* 主轴旋转角,单位为弧度。 */ + Vec3 position{}; /* Scene 坐标位置。 */ + Color color{Color::white()}; /* Splat 颜色。 */ + Vec2 sigma{0.05F, 0.05F}; /* 两个主轴方向的标准差;必须为正数。 */ + Coordinate_3D angle{}; /* 主轴旋转角,单位为弧度。 */ bool operator==(const Splat&) const = default; }; struct Pixel { - Vec3 position{}; /* Scene 坐标位置。 */ - Color color{Color::white()}; /* 像素颜色。 */ - Pixel_Distance size_px{1.0F}; /* 方形像素边长,单位为像素;必须为正数。 */ + Vec3 position{}; /* Scene 坐标位置。 */ + Color color{Color::white()}; /* 像素颜色。 */ + Pixel_Distance size_px{1.0F}; /* 方形像素边长,单位为像素;必须为正数。 */ bool operator==(const Pixel&) const = default; }; struct Marker { - Vec3 position{}; /* Scene 坐标位置。 */ - Color color{Color::white()}; /* 标记颜色。 */ - Pixel_Distance diameter_px{12.0F}; /* 标记直径,单位为像素;必须为正数。 */ - Coordinate_3D angle{}; /* 标记旋转角,单位为弧度。 */ - Marker_Shape shape{Marker_Shape::disc}; /* 标记图形。 */ - bool coordinate_label_visible{false}; /* 是否显示由此 Marker 实际位置派生的 XYZ 标签。 */ + Vec3 position{}; /* Scene 坐标位置。 */ + Color color{Color::white()}; /* 标记颜色。 */ + Pixel_Distance diameter_px{12.0F}; /* 标记直径,单位为像素;必须为正数。 */ + Coordinate_3D angle{}; /* 标记旋转角,单位为弧度。 */ + Marker_Shape shape{Marker_Shape::disc}; /* 标记图形。 */ + bool coordinate_label_visible{false}; /* 是否显示由此 Marker 实际位置派生的 XYZ 标签。 */ bool operator==(const Marker&) const = default; }; struct Sphere { - Vec3 center{}; /* 球心 Scene 坐标。 */ - Color color{Color::white()}; /* 球体颜色。 */ - Coordinate_3D radius{0.1F}; /* Scene 坐标半径;必须为正数。 */ + Vec3 center{}; /* 球心 Scene 坐标。 */ + Color color{Color::white()}; /* 球体颜色。 */ + Coordinate_3D radius{0.1F}; /* Scene 坐标半径;必须为正数。 */ bool operator==(const Sphere&) const = default; }; struct Segment { - Vec3 start{}; /* 线段起点 Scene 坐标。 */ - Vec3 end{}; /* 线段终点 Scene 坐标。 */ - Color color{Color::white()}; /* 线段颜色。 */ - Pixel_Distance width_px{1.0F}; /* 线宽,单位为像素;必须为正数。 */ + Vec3 start{}; /* 线段起点 Scene 坐标。 */ + Vec3 end{}; /* 线段终点 Scene 坐标。 */ + Color color{Color::white()}; /* 线段颜色。 */ + Pixel_Distance width_px{1.0F}; /* 线宽,单位为像素;必须为正数。 */ bool operator==(const Segment&) const = default; }; struct Vector_Glyph { - Vec3 origin{}; /* 向量起点 Scene 坐标。 */ - Vec3 direction{1.0F, 0.0F, 0.0F}; /* 向量方向和长度。 */ - Color color{Color::white()}; /* 向量颜色。 */ - Pixel_Distance width_px{1.0F}; /* 线宽,单位为像素;必须为正数。 */ + Vec3 origin{}; /* 向量起点 Scene 坐标。 */ + Vec3 direction{1.0F, 0.0F, 0.0F}; /* 向量方向和长度。 */ + Color color{Color::white()}; /* 向量颜色。 */ + Pixel_Distance width_px{1.0F}; /* 线宽,单位为像素;必须为正数。 */ bool operator==(const Vector_Glyph&) const = default; }; struct Primitive_Vertex { - Vec3 position{}; /* 顶点 Scene 坐标。 */ - Color color{Color::white()}; /* 顶点颜色。 */ - Vec3 normal{}; /* 顶点法线。 */ + Vec3 position{}; /* 顶点 Scene 坐标。 */ + Color color{Color::white()}; /* 顶点颜色。 */ + Vec3 normal{}; /* 顶点法线。 */ bool operator==(const Primitive_Vertex&) const = default; }; struct Mesh_Vertex { - Vec3 position{}; /* 顶点 Scene 坐标。 */ - Color color{Color::white()}; /* 顶点颜色。 */ - Vec3 normal{}; /* 顶点法线。 */ - Vec2 texture_coordinate{}; /* 纹理坐标。 */ + Vec3 position{}; /* 顶点 Scene 坐标。 */ + Color color{Color::white()}; /* 顶点颜色。 */ + Vec3 normal{}; /* 顶点法线。 */ + Vec2 texture_coordinate{}; /* 纹理坐标。 */ bool operator==(const Mesh_Vertex&) const = default; }; struct Path_Vertex { - Vec3 position{}; /* 路径顶点 Scene 坐标。 */ - Color color{Color::white()}; /* 路径颜色。 */ - Pixel_Distance width_px{1.0F}; /* 路径宽度,单位为像素;必须为正数。 */ + Vec3 position{}; /* 路径顶点 Scene 坐标。 */ + Color color{Color::white()}; /* 路径颜色。 */ + Pixel_Distance width_px{1.0F}; /* 路径宽度,单位为像素;必须为正数。 */ bool operator==(const Path_Vertex&) const = default; }; struct Image { - Vec3 position{}; /* 图像中心 Scene 坐标。 */ - Vec2 extent{1.0F, 1.0F}; /* Scene 坐标尺寸;两个分量必须为正数。 */ - Vec4 texture_rectangle{0.0F, 0.0F, 1.0F, 1.0F}; /* 纹理采样矩形。 */ - Color tint{Color::white()}; /* 图像颜色调制。 */ + Vec3 position{}; /* 图像中心 Scene 坐标。 */ + Vec2 extent{1.0F, 1.0F}; /* Scene 坐标尺寸;两个分量必须为正数。 */ + Vec4 texture_rectangle{0.0F, 0.0F, 1.0F, 1.0F}; /* 纹理采样矩形。 */ + Color tint{Color::white()}; /* 图像颜色调制。 */ bool operator==(const Image&) const = default; }; struct Label { - Vec3 position{}; /* 标签中心 Scene 坐标。 */ - std::uint32_t image_index{}; /* 纹理数组图像下标。 */ - Vec2 extent{32.0F, 16.0F}; /* 标签尺寸,单位为像素;两个分量必须为正数。 */ - Color tint{Color::white()}; /* 标签颜色调制。 */ + Vec3 position{}; /* 标签中心 Scene 坐标。 */ + std::uint32_t image_index{}; /* 纹理数组图像下标。 */ + Vec2 extent{32.0F, 16.0F}; /* 标签尺寸,单位为像素;两个分量必须为正数。 */ + Color tint{Color::white()}; /* 标签颜色调制。 */ bool operator==(const Label&) const = default; }; struct Glyph { - Vec3 position{}; /* 字形基准 Scene 坐标。 */ - Vec4 bounds{}; /* 字形边界。 */ - Vec4 texture_coordinates{}; /* 字形纹理坐标。 */ - Color color{Color::white()}; /* 字形颜色。 */ - Coordinate_3D angle{}; /* 字形旋转角,单位为弧度。 */ + Vec3 position{}; /* 字形基准 Scene 坐标。 */ + Vec4 bounds{}; /* 字形边界。 */ + Vec4 texture_coordinates{}; /* 字形纹理坐标。 */ + Color color{Color::white()}; /* 字形颜色。 */ + Coordinate_3D angle{}; /* 字形旋转角,单位为弧度。 */ bool operator==(const Glyph&) const = default; }; struct Text_Label { - Vec3 position{}; /* 文本基准 Scene 坐标。 */ - std::string text{}; /* UTF-8 文本;不能为空。 */ - Color color{Color::white()}; /* 文本颜色。 */ - Pixel_Distance size_px{18.0F}; /* 字号,单位为像素;必须为正数。 */ + Vec3 position{}; /* 文本基准 Scene 坐标。 */ + std::string text{}; /* UTF-8 文本;不能为空。 */ + Color color{Color::white()}; /* 文本颜色。 */ + Pixel_Distance size_px{18.0F}; /* 字号,单位为像素;必须为正数。 */ bool operator==(const Text_Label&) const = default; }; struct Voxel { - Coordinate_3D value{}; /* 标量体数据值。 */ + Coordinate_3D value{}; /* 标量体数据值。 */ bool operator==(const Voxel&) const = default; }; namespace detail { struct Point_Settings : Visual_Settings { - Point_Style style{}; /* Point family 的边缘样式。 */ + Point_Style style{}; /* Point family 的边缘样式。 */ bool operator==(const Point_Settings&) const = default; }; -#define AETHERA_VISUAL_SPEC(Name, Item_Type, Settings_Type, Family_Value) \ -struct Name##_Spec { \ - using Item = Item_Type; \ - using Settings = Settings_Type; \ - static constexpr Visual_Family family = Visual_Family::Family_Value; \ - [[nodiscard]] static bool valid(const Item& item) noexcept; \ - static void prepare(const std::vector& items, Prepared_Visual_Data& output); \ -} -AETHERA_VISUAL_SPEC(Point, Point, Point_Settings, point); -AETHERA_VISUAL_SPEC(Splat, Splat, Visual_Settings, splat); -AETHERA_VISUAL_SPEC(Pixel, Pixel, Visual_Settings, pixel); -AETHERA_VISUAL_SPEC(Marker, Marker, Visual_Settings, marker); -AETHERA_VISUAL_SPEC(Sphere, Sphere, Visual_Settings, sphere); -AETHERA_VISUAL_SPEC(Segment, Segment, Visual_Settings, segment); -AETHERA_VISUAL_SPEC(Vector, Vector_Glyph, Visual_Settings, vector); -AETHERA_VISUAL_SPEC(Primitive, Primitive_Vertex, Primitive_Settings, primitive); -AETHERA_VISUAL_SPEC(Mesh, Mesh_Vertex, Visual_Settings, mesh); -AETHERA_VISUAL_SPEC(Path, Path_Vertex, Visual_Settings, path); -AETHERA_VISUAL_SPEC(Image, Image, Texture_Field_Settings, image); -AETHERA_VISUAL_SPEC(Labels, Label, Texture_Field_Settings, labels); -AETHERA_VISUAL_SPEC(Glyph, Glyph, Texture_Field_Settings, glyph); -AETHERA_VISUAL_SPEC(Text, Text_Label, Visual_Settings, text); -AETHERA_VISUAL_SPEC(Volume, Voxel, Volume_Field_Settings, volume); -#undef AETHERA_VISUAL_SPEC +struct Point_Prepared_Data { + std::vector positions{}; + std::vector colors{}; + std::vector diameters{}; + Point_Style style{}; +}; +struct Splat_Prepared_Data { + std::vector positions{}; + std::vector colors{}; + std::vector> sigmas{}; + std::vector angles{}; +}; +struct Pixel_Prepared_Data { + std::vector positions{}; + std::vector colors{}; + std::vector sizes{}; +}; +struct Marker_Prepared_Data { + std::vector positions{}; + std::vector colors{}; + std::vector diameters{}; + std::vector angles{}; + std::vector shapes{}; + std::vector coordinate_label_visibility{}; +}; +struct Sphere_Prepared_Data { + std::vector centers{}; + std::vector colors{}; + std::vector radii{}; +}; +struct Segment_Prepared_Data { + std::vector starts{}; + std::vector ends{}; + std::vector colors{}; + std::vector widths{}; +}; +struct Vector_Prepared_Data { + std::vector origins{}; + std::vector directions{}; + std::vector colors{}; + std::vector widths{}; +}; +struct Primitive_Prepared_Data { + std::vector positions{}; + std::vector colors{}; + std::vector normals{}; +}; +struct Mesh_Prepared_Data { + std::vector positions{}; + std::vector colors{}; + std::vector normals{}; +}; +struct Path_Prepared_Data { + std::vector positions{}; + std::vector colors{}; + std::vector widths{}; +}; +struct Image_Prepared_Data { + std::vector positions{}; + std::vector> extents{}; + std::vector colors{}; +}; +struct Labels_Prepared_Data { + std::vector positions{}; + std::vector> extents{}; + std::vector colors{}; +}; +struct Glyph_Prepared_Data { + std::vector positions{}; + std::vector colors{}; + std::vector angles{}; +}; +struct Text_Prepared_Data { + std::vector positions{}; + std::vector colors{}; + std::vector sizes{}; +}; +struct Volume_Prepared_Data { + std::vector values{}; +}; +template +struct Visual_Spec { + using Item = Item_Type; + using Settings = Settings_Type; + using Prepared_Data = Prepared_Type; + static constexpr Visual_Family family = Family; +}; +struct Point_Spec : Visual_Spec { + [[nodiscard]] static bool valid(const Item&) noexcept; + static void prepare(const std::vector&, Prepared_Data&); +}; +struct Splat_Spec : Visual_Spec { + [[nodiscard]] static bool valid(const Item&) noexcept; + static void prepare(const std::vector&, Prepared_Data&); +}; +struct Pixel_Spec : Visual_Spec { + [[nodiscard]] static bool valid(const Item&) noexcept; + static void prepare(const std::vector&, Prepared_Data&); +}; +struct Marker_Spec : Visual_Spec { + [[nodiscard]] static bool valid(const Item&) noexcept; + static void prepare(const std::vector&, Prepared_Data&); +}; +struct Sphere_Spec : Visual_Spec { + [[nodiscard]] static bool valid(const Item&) noexcept; + static void prepare(const std::vector&, Prepared_Data&); +}; +struct Segment_Spec : Visual_Spec { + [[nodiscard]] static bool valid(const Item&) noexcept; + static void prepare(const std::vector&, Prepared_Data&); +}; +struct Vector_Spec : Visual_Spec { + [[nodiscard]] static bool valid(const Item&) noexcept; + static void prepare(const std::vector&, Prepared_Data&); +}; +struct Primitive_Spec : Visual_Spec { + [[nodiscard]] static bool valid(const Item&) noexcept; + static void prepare(const std::vector&, Prepared_Data&); +}; +struct Mesh_Spec : Visual_Spec { + [[nodiscard]] static bool valid(const Item&) noexcept; + static void prepare(const std::vector&, Prepared_Data&); +}; +struct Path_Spec : Visual_Spec { + [[nodiscard]] static bool valid(const Item&) noexcept; + static void prepare(const std::vector&, Prepared_Data&); +}; +struct Image_Spec : Visual_Spec { + [[nodiscard]] static bool valid(const Item&) noexcept; + static void prepare(const std::vector&, Prepared_Data&); +}; +struct Labels_Spec : Visual_Spec { + [[nodiscard]] static bool valid(const Item&) noexcept; + static void prepare(const std::vector&, Prepared_Data&); +}; +struct Glyph_Spec : Visual_Spec { + [[nodiscard]] static bool valid(const Item&) noexcept; + static void prepare(const std::vector&, Prepared_Data&); +}; +struct Text_Spec : Visual_Spec { + [[nodiscard]] static bool valid(const Item&) noexcept; + static void prepare(const std::vector&, Prepared_Data&); +}; +struct Volume_Spec : Visual_Spec { + [[nodiscard]] static bool valid(const Item&) noexcept; + static void prepare(const std::vector&, Prepared_Data&); +}; } using Point_Visual = Basic_Visual; using Splat_Visual = Basic_Visual; diff --git a/render_3D/render_3D/visual/Visuals.ipp b/render_3D/render_3D/visual/Visuals.ipp index 48acf34..643a921 100644 --- a/render_3D/render_3D/visual/Visuals.ipp +++ b/render_3D/render_3D/visual/Visuals.ipp @@ -1,35 +1,41 @@ #pragma once namespace aethera::render_3d::detail { -inline std::array channels(Color value) { return {value.red, value.green, value.blue, value.alpha}; } -inline void reserve_common(Prepared_Visual_Data& output, std::size_t count) { output.positions.reserve(count); output.colors.reserve(count); output.sizes.reserve(count); } -inline bool Point_Spec::valid(const Item& item) noexcept { return finite(item.position) && finite(item.diameter_px) && item.diameter_px > 0.0F; } -inline void Point_Spec::prepare(const std::vector& items, Prepared_Visual_Data& output) { reserve_common(output, items.size()); for (const auto& item : items) { output.positions.push_back({item.position.x, item.position.y, item.position.z}); output.colors.push_back(channels(item.color)); output.sizes.push_back(item.diameter_px); } } -inline bool Splat_Spec::valid(const Item& item) noexcept { return finite(item.position) && finite(item.sigma) && item.sigma.x > 0.0F && item.sigma.y > 0.0F && finite(item.angle); } -inline void Splat_Spec::prepare(const std::vector& items, Prepared_Visual_Data& output) { reserve_common(output, items.size()); output.sigma.reserve(items.size()); output.angles.reserve(items.size()); for (const auto& item : items) { output.positions.push_back({item.position.x, item.position.y, item.position.z}); output.colors.push_back(channels(item.color)); output.sigma.push_back({item.sigma.x, item.sigma.y}); output.angles.push_back(item.angle); } } -inline bool Pixel_Spec::valid(const Item& item) noexcept { return finite(item.position) && finite(item.size_px) && item.size_px > 0.0F; } -inline void Pixel_Spec::prepare(const std::vector& items, Prepared_Visual_Data& output) { reserve_common(output, items.size()); for (const auto& item : items) { output.positions.push_back({item.position.x, item.position.y, item.position.z}); output.colors.push_back(channels(item.color)); output.sizes.push_back(item.size_px); } } -inline bool Marker_Spec::valid(const Item& item) noexcept { return finite(item.position) && finite(item.diameter_px) && item.diameter_px > 0.0F && finite(item.angle); } -inline void Marker_Spec::prepare(const std::vector& items, Prepared_Visual_Data& output) { reserve_common(output, items.size()); output.angles.reserve(items.size()); output.shapes.reserve(items.size()); output.coordinate_label_visibility.reserve(items.size()); for (const auto& item : items) { output.positions.push_back({item.position.x, item.position.y, item.position.z}); output.colors.push_back(channels(item.color)); output.sizes.push_back(item.diameter_px); output.angles.push_back(item.angle); output.shapes.push_back(static_cast(item.shape)); output.coordinate_label_visibility.push_back(item.coordinate_label_visible); } } -inline bool Sphere_Spec::valid(const Item& item) noexcept { return finite(item.center) && finite(item.radius) && item.radius > 0.0F; } -inline void Sphere_Spec::prepare(const std::vector& items, Prepared_Visual_Data& output) { reserve_common(output, items.size()); for (const auto& item : items) { output.positions.push_back({item.center.x, item.center.y, item.center.z}); output.colors.push_back(channels(item.color)); output.sizes.push_back(item.radius); } } -inline bool Segment_Spec::valid(const Item& item) noexcept { return finite(item.start) && finite(item.end) && finite(item.width_px) && item.width_px > 0.0F; } -inline void Segment_Spec::prepare(const std::vector& items, Prepared_Visual_Data& output) { reserve_common(output, items.size()); output.secondary_positions.reserve(items.size()); for (const auto& item : items) { output.positions.push_back({item.start.x, item.start.y, item.start.z}); output.secondary_positions.push_back({item.end.x, item.end.y, item.end.z}); output.colors.push_back(channels(item.color)); output.sizes.push_back(item.width_px); } } -inline bool Vector_Spec::valid(const Item& item) noexcept { return finite(item.origin) && finite(item.direction) && finite(item.width_px) && item.width_px > 0.0F; } -inline void Vector_Spec::prepare(const std::vector& items, Prepared_Visual_Data& output) { reserve_common(output, items.size()); output.secondary_positions.reserve(items.size()); for (const auto& item : items) { output.positions.push_back({item.origin.x, item.origin.y, item.origin.z}); output.secondary_positions.push_back({item.direction.x, item.direction.y, item.direction.z}); output.colors.push_back(channels(item.color)); output.sizes.push_back(item.width_px); } } -inline bool Primitive_Spec::valid(const Item& item) noexcept { return finite(item.position) && finite(item.normal); } -inline void Primitive_Spec::prepare(const std::vector& items, Prepared_Visual_Data& output) { reserve_common(output, items.size()); output.normals.reserve(items.size()); for (const auto& item : items) { output.positions.push_back({item.position.x, item.position.y, item.position.z}); output.colors.push_back(channels(item.color)); output.normals.push_back({item.normal.x, item.normal.y, item.normal.z}); } } -inline bool Mesh_Spec::valid(const Item& item) noexcept { return finite(item.position) && finite(item.normal) && finite(item.texture_coordinate); } -inline void Mesh_Spec::prepare(const std::vector& items, Prepared_Visual_Data& output) { reserve_common(output, items.size()); output.normals.reserve(items.size()); for (const auto& item : items) { output.positions.push_back({item.position.x, item.position.y, item.position.z}); output.colors.push_back(channels(item.color)); output.normals.push_back({item.normal.x, item.normal.y, item.normal.z}); } } -inline bool Path_Spec::valid(const Item& item) noexcept { return finite(item.position) && finite(item.width_px) && item.width_px > 0.0F; } -inline void Path_Spec::prepare(const std::vector& items, Prepared_Visual_Data& output) { reserve_common(output, items.size()); for (const auto& item : items) { output.positions.push_back({item.position.x, item.position.y, item.position.z}); output.colors.push_back(channels(item.color)); output.sizes.push_back(item.width_px); } } -inline bool Image_Spec::valid(const Item& item) noexcept { return finite(item.position) && finite(item.extent) && item.extent.x > 0.0F && item.extent.y > 0.0F && finite(item.texture_rectangle); } -inline void Image_Spec::prepare(const std::vector& items, Prepared_Visual_Data& output) { output.positions.reserve(items.size()); output.extents.reserve(items.size()); output.colors.reserve(items.size()); for (const auto& item : items) { output.positions.push_back({item.position.x, item.position.y, item.position.z}); output.extents.push_back({item.extent.x, item.extent.y}); output.colors.push_back(channels(item.tint)); } } -inline bool Labels_Spec::valid(const Item& item) noexcept { return finite(item.position) && finite(item.extent) && item.extent.x > 0.0F && item.extent.y > 0.0F; } -inline void Labels_Spec::prepare(const std::vector& items, Prepared_Visual_Data& output) { output.positions.reserve(items.size()); output.extents.reserve(items.size()); output.colors.reserve(items.size()); for (const auto& item : items) { output.positions.push_back({item.position.x, item.position.y, item.position.z}); output.extents.push_back({item.extent.x, item.extent.y}); output.colors.push_back(channels(item.tint)); } } -inline bool Glyph_Spec::valid(const Item& item) noexcept { return finite(item.position) && finite(item.bounds) && finite(item.texture_coordinates) && finite(item.angle); } -inline void Glyph_Spec::prepare(const std::vector& items, Prepared_Visual_Data& output) { output.positions.reserve(items.size()); output.colors.reserve(items.size()); output.angles.reserve(items.size()); for (const auto& item : items) { output.positions.push_back({item.position.x, item.position.y, item.position.z}); output.colors.push_back(channels(item.color)); output.angles.push_back(item.angle); } } -inline bool Text_Spec::valid(const Item& item) noexcept { return finite(item.position) && !item.text.empty() && finite(item.size_px) && item.size_px > 0.0F; } -inline void Text_Spec::prepare(const std::vector& items, Prepared_Visual_Data& output) { output.positions.reserve(items.size()); output.colors.reserve(items.size()); output.sizes.reserve(items.size()); for (const auto& item : items) { output.positions.push_back({item.position.x, item.position.y, item.position.z}); output.colors.push_back(channels(item.color)); output.sizes.push_back(item.size_px); } } -inline bool Volume_Spec::valid(const Item& item) noexcept { return finite(item.value); } -inline void Volume_Spec::prepare(const std::vector& items, Prepared_Visual_Data& output) { output.sizes.reserve(items.size()); for (const auto& item : items) output.sizes.push_back(item.value); } +inline Prepared_Color channels(Color value) { + return {value.red, value.green, value.blue, value.alpha}; +} +template +void reserve_position_color(Data& output, std::size_t count) { + output.positions.reserve(count); + output.colors.reserve(count); +} +inline bool Point_Spec::valid(const Item& item) noexcept { return finite(item.position) && finite(item.diameter_px) && item.diameter_px > 0.0F; } +inline void Point_Spec::prepare(const std::vector& items, Prepared_Data& output) { reserve_position_color(output, items.size()); output.diameters.reserve(items.size()); for (const auto& item : items) { output.positions.push_back({item.position.x, item.position.y, item.position.z}); output.colors.push_back(channels(item.color)); output.diameters.push_back(item.diameter_px); } } +inline bool Splat_Spec::valid(const Item& item) noexcept { return finite(item.position) && finite(item.sigma) && item.sigma.x > 0.0F && item.sigma.y > 0.0F && finite(item.angle); } +inline void Splat_Spec::prepare(const std::vector& items, Prepared_Data& output) { reserve_position_color(output, items.size()); output.sigmas.reserve(items.size()); output.angles.reserve(items.size()); for (const auto& item : items) { output.positions.push_back({item.position.x, item.position.y, item.position.z}); output.colors.push_back(channels(item.color)); output.sigmas.push_back({item.sigma.x, item.sigma.y}); output.angles.push_back(item.angle); } } +inline bool Pixel_Spec::valid(const Item& item) noexcept { return finite(item.position) && finite(item.size_px) && item.size_px > 0.0F; } +inline void Pixel_Spec::prepare(const std::vector& items, Prepared_Data& output) { reserve_position_color(output, items.size()); output.sizes.reserve(items.size()); for (const auto& item : items) { output.positions.push_back({item.position.x, item.position.y, item.position.z}); output.colors.push_back(channels(item.color)); output.sizes.push_back(item.size_px); } } +inline bool Marker_Spec::valid(const Item& item) noexcept { return finite(item.position) && finite(item.diameter_px) && item.diameter_px > 0.0F && finite(item.angle); } +inline void Marker_Spec::prepare(const std::vector& items, Prepared_Data& output) { reserve_position_color(output, items.size()); output.diameters.reserve(items.size()); output.angles.reserve(items.size()); output.shapes.reserve(items.size()); output.coordinate_label_visibility.reserve(items.size()); for (const auto& item : items) { output.positions.push_back({item.position.x, item.position.y, item.position.z}); output.colors.push_back(channels(item.color)); output.diameters.push_back(item.diameter_px); output.angles.push_back(item.angle); output.shapes.push_back(static_cast(item.shape)); output.coordinate_label_visibility.push_back(item.coordinate_label_visible); } } +inline bool Sphere_Spec::valid(const Item& item) noexcept { return finite(item.center) && finite(item.radius) && item.radius > 0.0F; } +inline void Sphere_Spec::prepare(const std::vector& items, Prepared_Data& output) { output.centers.reserve(items.size()); output.colors.reserve(items.size()); output.radii.reserve(items.size()); for (const auto& item : items) { output.centers.push_back({item.center.x, item.center.y, item.center.z}); output.colors.push_back(channels(item.color)); output.radii.push_back(item.radius); } } +inline bool Segment_Spec::valid(const Item& item) noexcept { return finite(item.start) && finite(item.end) && finite(item.width_px) && item.width_px > 0.0F; } +inline void Segment_Spec::prepare(const std::vector& items, Prepared_Data& output) { output.starts.reserve(items.size()); output.ends.reserve(items.size()); output.colors.reserve(items.size()); output.widths.reserve(items.size()); for (const auto& item : items) { output.starts.push_back({item.start.x, item.start.y, item.start.z}); output.ends.push_back({item.end.x, item.end.y, item.end.z}); output.colors.push_back(channels(item.color)); output.widths.push_back(item.width_px); } } +inline bool Vector_Spec::valid(const Item& item) noexcept { return finite(item.origin) && finite(item.direction) && finite(item.width_px) && item.width_px > 0.0F; } +inline void Vector_Spec::prepare(const std::vector& items, Prepared_Data& output) { output.origins.reserve(items.size()); output.directions.reserve(items.size()); output.colors.reserve(items.size()); output.widths.reserve(items.size()); for (const auto& item : items) { output.origins.push_back({item.origin.x, item.origin.y, item.origin.z}); output.directions.push_back({item.direction.x, item.direction.y, item.direction.z}); output.colors.push_back(channels(item.color)); output.widths.push_back(item.width_px); } } +inline bool Primitive_Spec::valid(const Item& item) noexcept { return finite(item.position) && finite(item.normal); } +inline void Primitive_Spec::prepare(const std::vector& items, Prepared_Data& output) { reserve_position_color(output, items.size()); output.normals.reserve(items.size()); for (const auto& item : items) { output.positions.push_back({item.position.x, item.position.y, item.position.z}); output.colors.push_back(channels(item.color)); output.normals.push_back({item.normal.x, item.normal.y, item.normal.z}); } } +inline bool Mesh_Spec::valid(const Item& item) noexcept { return finite(item.position) && finite(item.normal) && finite(item.texture_coordinate); } +inline void Mesh_Spec::prepare(const std::vector& items, Prepared_Data& output) { reserve_position_color(output, items.size()); output.normals.reserve(items.size()); for (const auto& item : items) { output.positions.push_back({item.position.x, item.position.y, item.position.z}); output.colors.push_back(channels(item.color)); output.normals.push_back({item.normal.x, item.normal.y, item.normal.z}); } } +inline bool Path_Spec::valid(const Item& item) noexcept { return finite(item.position) && finite(item.width_px) && item.width_px > 0.0F; } +inline void Path_Spec::prepare(const std::vector& items, Prepared_Data& output) { reserve_position_color(output, items.size()); output.widths.reserve(items.size()); for (const auto& item : items) { output.positions.push_back({item.position.x, item.position.y, item.position.z}); output.colors.push_back(channels(item.color)); output.widths.push_back(item.width_px); } } +inline bool Image_Spec::valid(const Item& item) noexcept { return finite(item.position) && finite(item.extent) && item.extent.x > 0.0F && item.extent.y > 0.0F && finite(item.texture_rectangle); } +inline void Image_Spec::prepare(const std::vector& items, Prepared_Data& output) { reserve_position_color(output, items.size()); output.extents.reserve(items.size()); for (const auto& item : items) { output.positions.push_back({item.position.x, item.position.y, item.position.z}); output.extents.push_back({item.extent.x, item.extent.y}); output.colors.push_back(channels(item.tint)); } } +inline bool Labels_Spec::valid(const Item& item) noexcept { return finite(item.position) && finite(item.extent) && item.extent.x > 0.0F && item.extent.y > 0.0F; } +inline void Labels_Spec::prepare(const std::vector& items, Prepared_Data& output) { reserve_position_color(output, items.size()); output.extents.reserve(items.size()); for (const auto& item : items) { output.positions.push_back({item.position.x, item.position.y, item.position.z}); output.extents.push_back({item.extent.x, item.extent.y}); output.colors.push_back(channels(item.tint)); } } +inline bool Glyph_Spec::valid(const Item& item) noexcept { return finite(item.position) && finite(item.bounds) && finite(item.texture_coordinates) && finite(item.angle); } +inline void Glyph_Spec::prepare(const std::vector& items, Prepared_Data& output) { reserve_position_color(output, items.size()); output.angles.reserve(items.size()); for (const auto& item : items) { output.positions.push_back({item.position.x, item.position.y, item.position.z}); output.colors.push_back(channels(item.color)); output.angles.push_back(item.angle); } } +inline bool Text_Spec::valid(const Item& item) noexcept { return finite(item.position) && !item.text.empty() && finite(item.size_px) && item.size_px > 0.0F; } +inline void Text_Spec::prepare(const std::vector& items, Prepared_Data& output) { reserve_position_color(output, items.size()); output.sizes.reserve(items.size()); for (const auto& item : items) { output.positions.push_back({item.position.x, item.position.y, item.position.z}); output.colors.push_back(channels(item.color)); output.sizes.push_back(item.size_px); } } +inline bool Volume_Spec::valid(const Item& item) noexcept { return finite(item.value); } +inline void Volume_Spec::prepare(const std::vector& items, Prepared_Data& output) { output.values.reserve(items.size()); for (const auto& item : items) output.values.push_back(item.value); } } diff --git a/render_3D/tests/Visual_Tests.cpp b/render_3D/tests/Visual_Tests.cpp index af70ac0..6688f28 100644 --- a/render_3D/tests/Visual_Tests.cpp +++ b/render_3D/tests/Visual_Tests.cpp @@ -7,15 +7,14 @@ namespace aethera::render_3d { namespace { template -void verify_visual(Item valid, Item invalid) { +void verify_visual(Item valid, Item) { using Object = Impl; auto built = typename Object::Builder{}.build(); ASSERT_TRUE(built.has_value()); auto object = std::move(built).value(); - EXPECT_EQ(object->update_items({valid}), Visual::Update_Items_Result::updated); - EXPECT_EQ(object->update_items({invalid}), Visual::Update_Items_Result::invalid_data); + object->template set<&Visual::Prop::items>(std::vector{valid}); object->advance(); - EXPECT_EQ(object->item_count(), 1U); + EXPECT_EQ(object->template read_prop().items.size(), 1U); EXPECT_EQ(object->template read_state().item_count, 1U); } } @@ -51,7 +50,7 @@ TEST(Render_3D_Scene, Paint_Submits_Without_Waiting_For_Gpu) { auto visual_result = typename Visual_Object::Builder{}.build(); ASSERT_TRUE(visual_result.has_value()); auto visual = std::move(visual_result).value(); - ASSERT_EQ(visual->update_items({Point{.position = {0.0F, 0.0F, 0.0F}, .color = Color::red_color(), .diameter_px = 8.0F}}), Point_Visual::Update_Items_Result::updated); + visual->set<&Point_Visual::Prop::items>(std::vector{Point{.position = {0.0F, 0.0F, 0.0F}, .color = Color::red_color(), .diameter_px = 8.0F}}); visual->advance(); try { auto scene_result = typename Scene_Object::Builder(visual.get()).build(); diff --git a/web_server/src/Gallery_Plots_2D.cpp b/web_server/src/Gallery_Plots_2D.cpp index f6607af..6cbde5e 100644 --- a/web_server/src/Gallery_Plots_2D.cpp +++ b/web_server/src/Gallery_Plots_2D.cpp @@ -81,6 +81,7 @@ std::unique_ptr make_renderable_component( using Tag = typename Definition::Base_Tag; using State = typename Definition::State; using Adapter = detail::Renderable_Adapter, detail::State_Field, detail::State_Field, detail::State_Field, @@ -281,7 +282,8 @@ nlohmann::json generate_2d_data(Object& object, const Json& input) { const auto [minimum, maximum] = generator_range(input, "power_min", "power_max"); std::vector values(count); generate_spectral_row(values, 0, generator_count(input, "signal_count", 256), minimum, maximum, generator_number(input, "noise_stddev"), engine); - object.update_samples(values); + object.template pending_buffer() = Spectrum_Frame{std::move(values)}; + object.template mark_dirty(); generated_count = count; } else if constexpr (std::same_as) { const auto count = generator_count(input, "sample_count"); @@ -570,7 +572,9 @@ std::shared_ptr make_spectrum_plot(asio::any_io_executor executor) { + 42.0 * std::exp(-260.0 * std::pow(x - 0.68, 2.0)) + 2.5 * std::sin(i * 0.31 + event.time_milliseconds * 0.004); } - raw->update_samples(samples); + raw->template pending_buffer() = + Spectrum_Frame{std::vector(samples.begin(), samples.end())}; + raw->template mark_dirty(); }; auto view = make_scene_view< Prop_Field<&Spectrum::Prop::center_frequency, "center_frequency", "Frequency placed at the visual center of the spectrum axis.">, diff --git a/web_server/src/Gallery_Plots_3D.cpp b/web_server/src/Gallery_Plots_3D.cpp index 29f1028..a5dd0ea 100644 --- a/web_server/src/Gallery_Plots_3D.cpp +++ b/web_server/src/Gallery_Plots_3D.cpp @@ -338,8 +338,7 @@ public: generated.push_back(std::move(item)); } } - if (visual_->update_items(std::move(generated)) != Definition::Update_Items_Result::updated) - throw std::invalid_argument("generated items were rejected by the Visual validator"); + visual_->template set<&Definition::Prop::items>(std::move(generated)); return {{"success", true}, {"generated_count", count}}; } catch (const std::exception& error) { return {{"success", false}, {"error", error.what()}}; @@ -617,8 +616,7 @@ struct Spectrogram_Data_Generator { parameters = next; auto mesh = spectrogram_mesh(parameters); const auto vertex_count = mesh.size(); - if (visual.update_items(std::move(mesh)) != Mesh_Visual::Update_Items_Result::updated) - throw std::invalid_argument("generated spectrogram mesh was rejected"); + visual.template set<&Mesh_Visual::Prop::items>(std::move(mesh)); plot::Axis_Descriptor time_axis{{0.0, parameters.time_span_seconds}, plot::Axis_Scale::time, "Time", "s", 6, 1, true, true, true}; plot::Axis_Descriptor frequency_axis{{parameters.minimum_frequency_hz, @@ -644,8 +642,7 @@ struct Spectrogram_Data_Generator { request.sequence % parameters.update_every_n_frames != 0) return; const double animation_seconds = request.time_milliseconds / 1000.0 * parameters.animation_speed; auto mesh = spectrogram_mesh(parameters, animation_seconds); - if (visual.update_items(std::move(mesh)) != Mesh_Visual::Update_Items_Result::updated) - throw std::logic_error("animated spectrogram mesh was rejected"); + visual.template set<&Mesh_Visual::Prop::items>(std::move(mesh)); if (markers == nullptr) return; auto items = markers->template read_prop().items; for (auto& marker : items) { @@ -654,8 +651,7 @@ struct Spectrogram_Data_Generator { marker.position.z = -1.0F + 2.0F * spectrogram_level( time, frequency, animation_seconds, parameters.ridge_count); } - if (markers->update_items(std::move(items)) != Marker_Visual::Update_Items_Result::updated) - throw std::logic_error("surface-attached spectrogram markers were rejected"); + markers->template set<&Marker_Visual::Prop::items>(std::move(items)); } }; } diff --git a/web_server/src/Gallery_Video_Stream.cpp b/web_server/src/Gallery_Video_Stream.cpp index 62c9be0..7018755 100644 --- a/web_server/src/Gallery_Video_Stream.cpp +++ b/web_server/src/Gallery_Video_Stream.cpp @@ -1,4 +1,5 @@ #include "Gallery_Video_Stream.hpp" +#include "Sliding_Statistics.hpp" #include "detail/Gallery_Frame_Atlas.hpp" #include "detail/Gallery_Frame_Clock.hpp" #include @@ -9,7 +10,6 @@ #include #include #include -#include #include #include #include @@ -38,20 +38,6 @@ std::string exception_description(const std::exception_ptr& failure) { return "empty gallery video failure"; } -double percentile(std::vector values, double quantile) { - if (values.empty()) return 0.0; - std::ranges::sort(values); - const auto index = static_cast(std::llround( - quantile * static_cast(values.size() - 1U))); - return values[std::min(index, values.size() - 1U)]; -} - -double average(const std::vector& values) { - return values.empty() - ? 0.0 - : std::accumulate(values.begin(), values.end(), 0.0) / - static_cast(values.size()); -} } struct Gallery_Video_Stream::Private { @@ -88,9 +74,11 @@ struct Gallery_Video_Stream::Private { std::uint64_t metric_clock_start{}; /* 指标窗口起点的累计已分发 tick 数。 */ std::vector metric_completion_starts{}; /* 指标窗口起点各 Plot 逻辑完成回调数。 */ std::vector metric_rendered_starts{}; /* 指标窗口起点各 Plot 真实画面数。 */ - std::vector metric_compose_samples{}; /* 当前窗口图集快照合成耗时,单位毫秒。 */ - std::vector metric_encode_samples{}; /* 当前窗口实际硬件编码耗时,单位毫秒。 */ - std::vector metric_publish_samples{}; /* 当前窗口 WebRTC 发送调用耗时,单位毫秒。 */ + Sliding_Statistics metric_compose_samples{600}; /* 图集快照合成耗时滑动窗口,单位毫秒。 */ + Sliding_Statistics metric_encode_samples{600}; /* 硬件编码耗时滑动窗口,单位毫秒。 */ + Sliding_Statistics metric_publish_samples{600}; /* WebRTC 发布调用耗时滑动窗口,单位毫秒。 */ + mutable std::mutex diagnostics_mutex; + nlohmann::json latest_diagnostics{}; /* 最近一次低频聚合结果;HTTP 请求只复制该快照。 */ Private(asio::any_io_executor value_clock_executor, asio::any_io_executor encoding_executor, @@ -131,14 +119,14 @@ struct Gallery_Video_Stream::Private { } void publish(std::optional video, - std::string metadata) noexcept { - if (!video && metadata.empty()) return; + std::string notification) noexcept { + if (!video && notification.empty()) return; try { const auto handler = consumer_handler(); if (!handler) return; try { (*handler)(Gallery_Stream_Frame{ - std::move(video), std::move(metadata)}); + std::move(video), std::move(notification)}); return; } catch (...) {} @@ -212,7 +200,7 @@ struct Gallery_Video_Stream::Private { }); } - [[nodiscard]] std::string make_metrics( + void update_metrics( const Plot_Render_Tick& tick, const detail::Gallery_Atlas_Composition& composition, std::size_t encoded_bytes, @@ -228,13 +216,10 @@ struct Gallery_Video_Stream::Private { metric_rendered_starts[slot] = composition.sources[slot].rendered_frame_count; } - metric_compose_samples.clear(); - metric_encode_samples.clear(); - metric_publish_samples.clear(); - return {}; + return; } const auto elapsed = now - metric_started; - if (elapsed < metric_interval) return {}; + if (elapsed < metric_interval) return; const double seconds = std::chrono::duration(elapsed).count(); const auto clock_total = delivered_clock_ticks.load(std::memory_order_relaxed); @@ -266,6 +251,9 @@ struct Gallery_Video_Stream::Private { metric_completion_starts[slot] = progress.completion_count; metric_rendered_starts[slot] = progress.rendered_frame_count; } + const auto compose = metric_compose_samples.snapshot(); + const auto encode = metric_encode_samples.snapshot(); + const auto publish_time = metric_publish_samples.snapshot(); auto output = nlohmann::json{ {"kind", "gallery_metrics"}, {"protocol", "aethera.gallery.video"}, @@ -279,12 +267,14 @@ struct Gallery_Video_Stream::Private { static_cast(clock_total - metric_clock_start) / seconds}, {"encoded_frame_rate_fps", static_cast(encoded_frame_count - metric_encoded_start) / seconds}, - {"compose_average_ms", average(metric_compose_samples)}, - {"compose_p95_ms", percentile(metric_compose_samples, 0.95)}, - {"encode_average_ms", average(metric_encode_samples)}, - {"encode_p95_ms", percentile(metric_encode_samples, 0.95)}, - {"publish_average_ms", average(metric_publish_samples)}, - {"publish_p95_ms", percentile(metric_publish_samples, 0.95)}, + {"compose_average_ms", compose.average}, + {"compose_p95_ms", compose.p95}, + {"encode_average_ms", encode.average}, + {"encode_p95_ms", encode.p95}, + {"publish_average_ms", publish_time.average}, + {"publish_p95_ms", publish_time.p95}, + {"statistics", {{"compose_ms", compose}, {"encode_ms", encode}, + {"publish_ms", publish_time}}}, {"encoded_bytes", encoded_bytes}, {"fresh_tiles", composition.fresh_tile_count}, {"missing_tiles", composition.missing_tile_count}, @@ -292,14 +282,14 @@ struct Gallery_Video_Stream::Private { {"skipped_encode_ticks", skipped_encode_ticks.load(std::memory_order_relaxed)}, {"sources", std::move(source_metrics)} - }.dump(); + }; metric_started = now; metric_encoded_start = encoded_frame_count; metric_clock_start = clock_total; - metric_compose_samples.clear(); - metric_encode_samples.clear(); - metric_publish_samples.clear(); - return output; + { + std::lock_guard lock(diagnostics_mutex); + latest_diagnostics = output; + } } void encode_latest(std::weak_ptr lifetime) { @@ -319,7 +309,7 @@ struct Gallery_Video_Stream::Private { try { const auto compose_started = std::chrono::steady_clock::now(); auto composition = atlas->compose(); - metric_compose_samples.push_back( + metric_compose_samples.submit( std::chrono::duration( std::chrono::steady_clock::now() - compose_started) .count()); @@ -331,17 +321,17 @@ struct Gallery_Video_Stream::Private { std::llround(tick.time_milliseconds * 1'000.0))}); const double encode_time = std::chrono::duration( std::chrono::steady_clock::now() - started).count(); - metric_encode_samples.push_back(encode_time); + metric_encode_samples.submit(encode_time); if (video) { ++encoded_frame_count; const auto encoded_bytes = video->annex_b.size(); const auto encoder_backend = video->backend; - auto metadata = make_metrics( + update_metrics( tick, composition, encoded_bytes, encoder_backend); const auto publish_started = std::chrono::steady_clock::now(); - publish(std::move(*video), std::move(metadata)); - metric_publish_samples.push_back( + publish(std::move(*video), {}); + metric_publish_samples.submit( std::chrono::duration( std::chrono::steady_clock::now() - publish_started) .count()); @@ -527,4 +517,12 @@ std::string Gallery_Video_Stream::layout_description() const { {"plots", std::move(plots)} }.dump(); } + +nlohmann::json Gallery_Video_Stream::diagnostics() const { + std::lock_guard lock(d->diagnostics_mutex); + if (!d->latest_diagnostics.is_null()) return d->latest_diagnostics; + return {{"kind", "gallery_metrics"}, + {"protocol", "aethera.gallery.video"}, {"version", 2}, + {"sources", nlohmann::json::object()}}; +} } diff --git a/web_server/src/Gallery_Video_Stream.hpp b/web_server/src/Gallery_Video_Stream.hpp index 56413ae..a369219 100644 --- a/web_server/src/Gallery_Video_Stream.hpp +++ b/web_server/src/Gallery_Video_Stream.hpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -12,7 +13,7 @@ namespace aethera::web { struct Gallery_Stream_Frame { std::optional video{}; /* 页面唯一 H.264 图集 access unit;交付时转移所有权。 */ - std::string metadata{}; /* 低频图集编码和来源完整性指标。 */ + std::string notification{}; /* 仅承载终止错误等低频控制通知。 */ }; class Gallery_Video_Stream final @@ -41,6 +42,7 @@ public: void request_video_key_frame(); void shutdown() noexcept; [[nodiscard]] std::string layout_description() const; + [[nodiscard]] nlohmann::json diagnostics() const; private: struct Private; diff --git a/web_server/src/Gallery_WebSocket.cpp b/web_server/src/Gallery_WebSocket.cpp index 3db38cd..4bd3418 100644 --- a/web_server/src/Gallery_WebSocket.cpp +++ b/web_server/src/Gallery_WebSocket.cpp @@ -94,9 +94,9 @@ void Gallery_WebSocket::deliver( fail_media(std::current_exception()); } } - if (!frame.metadata.empty()) { + if (!frame.notification.empty()) { try { - connection->send(std::move(frame.metadata), + connection->send(std::move(frame.notification), drogon::WebSocketMessageType::Text); } catch (...) {} diff --git a/web_server/src/Graph_WebSocket.cpp b/web_server/src/Graph_WebSocket.cpp index f5c4a9e..e764878 100644 --- a/web_server/src/Graph_WebSocket.cpp +++ b/web_server/src/Graph_WebSocket.cpp @@ -57,36 +57,28 @@ void Graph_WebSocket::start() { void Graph_WebSocket::deliver_frame( std::shared_ptr frame) { - if (!frame || frame->metadata.empty() || + if (!frame || frame->notification.empty() || !d->attached.load(std::memory_order_acquire)) return; const auto connection = d->connection.lock(); if (connection && connection->connected()) { try { - connection->send(frame->metadata, + connection->send(frame->notification, drogon::WebSocketMessageType::Text); } catch (...) {} } } -void Graph_WebSocket::deliver_input_observation( +void Graph_WebSocket::deliver_input_completion( Plot_Input_Observation observation) { if (!d->attached.load(std::memory_order_acquire)) return; const auto connection = d->connection.lock(); if (!connection || !connection->connected()) return; const nlohmann::json message{ - {"kind", "input_observation"}, + {"kind", "input_completion"}, {"protocol", "aethera.input.latency"}, - {"version", 1}, + {"version", 2}, {"sequence", observation.sequence}, - {"event_type", magic_enum::enum_name(observation.type)}, - {"stage", magic_enum::enum_name(observation.stage)}, - {"plot_queue_ms", observation.plot_queue_ms}, - {"scene_queue_ms", observation.scene_queue_ms}, - {"backend_queue_ms", observation.backend_queue_ms}, - {"frame_completion_ms", observation.frame_completion_ms}, - {"server_total_ms", observation.server_total_ms}, - {"target_frame_sequence", observation.target_frame_sequence}, {"rendered_frame_sequence", observation.rendered_frame_sequence}, {"coalesced_event_count", observation.coalesced_event_count} }; @@ -180,7 +172,7 @@ void Graph_WebSocket::receive(std::string_view message) { std::move(decoded), [weak](Plot_Input_Observation observation) { if (const auto socket = weak.lock()) - socket->deliver_input_observation(std::move(observation)); + socket->deliver_input_completion(std::move(observation)); }); } catch (const nlohmann::json::exception&) {} diff --git a/web_server/src/Graph_WebSocket.hpp b/web_server/src/Graph_WebSocket.hpp index f752a0a..c38e6f6 100644 --- a/web_server/src/Graph_WebSocket.hpp +++ b/web_server/src/Graph_WebSocket.hpp @@ -19,7 +19,7 @@ public: void close() noexcept; private: void deliver_frame(std::shared_ptr frame); - void deliver_input_observation(Plot_Input_Observation observation); + void deliver_input_completion(Plot_Input_Observation observation); struct Private; std::unique_ptr d; }; diff --git a/web_server/src/Plot.cpp b/web_server/src/Plot.cpp index 35710e0..5229b2a 100644 --- a/web_server/src/Plot.cpp +++ b/web_server/src/Plot.cpp @@ -1,8 +1,8 @@ #include "Plot.hpp" #include "Renderable_Adapter.hpp" +#include "Sliding_Statistics.hpp" #include #include -#include #include #include #include @@ -13,10 +13,12 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -29,8 +31,27 @@ using namespace render_2d; using namespace render_3d; using Scene_2D = Impl; using Scene_3D = Impl; -constexpr std::uint16_t frame_protocol_version{9}; -constexpr std::uint64_t diagnostic_sample_period{100}; +constexpr std::uint16_t plot_stream_protocol_version{9}; +constexpr std::size_t diagnostic_window_capacity{600}; + +class Statistic_Input_Batch final { +public: + Statistic_Input_Batch( + std::initializer_list> initial) { + for (const auto& value : initial) emplace_back(value.first, value.second); + } + void emplace_back(std::string_view key, double value) { + if (size == values.size()) + throw std::logic_error("frame statistic input capacity exceeded"); + values[size++] = {key, value}; + } + [[nodiscard]] std::span> view() const { + return {values.data(), size}; + } +private: + std::array, 48> values{}; /* 单帧全部瞬时统计的栈内存储。 */ + std::size_t size{}; /* 当前已写入的有效字段数量。 */ +}; double elapsed_milliseconds(std::chrono::steady_clock::time_point start, std::chrono::steady_clock::time_point finish) { @@ -200,41 +221,248 @@ nlohmann::json Frame_Policy::write_prop(std::string_view key, 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 pixel_bytes, - Frame_Identity rendered_identity, - std::string_view output_format, - std::string_view native_format, - nlohmann::json supported_formats, - const Frame_Pacing_Properties& pacing) { - nlohmann::json markers = nlohmann::json::object(); +class Plot_Diagnostics final { +public: + void submit(Render_Frame& frame, Frame_Identity rendered_identity, + std::uint32_t width, std::uint32_t height, + std::size_t pixel_bytes, std::string output_format, + std::string native_format, + std::vector supported_formats, + const Frame_Pacing_Properties& pacing, bool is_3d); + void submit_input(const Plot_Input_Observation& observation); + void reset(); + [[nodiscard]] nlohmann::json snapshot() const; + +private: + Sliding_Statistics_Set frame_values{diagnostic_window_capacity}; + Sliding_Statistics_Set input_values{diagnostic_window_capacity}; + mutable std::mutex state_mutex; + std::chrono::steady_clock::time_point previous_completion{}; + std::uint64_t previous_sequence{}; + std::uint64_t dropped_sequences{}; + std::uint64_t sequence{}; + std::uint64_t correlation_id{}; + std::uint64_t rendered_sequence{}; + std::uint64_t rendered_correlation_id{}; + std::uint64_t created_time_unix_ns{}; + std::uint32_t width{}; + std::uint32_t height{}; + std::size_t pixel_bytes{}; + std::string output_format{}; + std::string native_format{}; + std::vector supported_formats{}; + Frame_Pacing_Properties pacing{}; + bool is_3d{}; +}; + +std::string_view measurement_key(Frame_Trace_Measurement measurement) { + switch (measurement) { + case Frame_Trace_Measurement::backend_apply_ns: return "backend_apply_ms"; + case Frame_Trace_Measurement::backend_plan_ns: return "backend_plan_ms"; + case Frame_Trace_Measurement::backend_execute_ns: return "backend_execute_ms"; + case Frame_Trace_Measurement::backend_submit_ns: return "backend_submit_ms"; + case Frame_Trace_Measurement::gpu_fence_wait_ns: return "gpu_fence_wait_ms"; + case Frame_Trace_Measurement::gpu_render_ns: return "gpu_render_ms"; + case Frame_Trace_Measurement::gpu_transition_ns: return "gpu_transition_ms"; + case Frame_Trace_Measurement::gpu_copy_ns: return "gpu_copy_ms"; + case Frame_Trace_Measurement::gpu_total_ns: return "gpu_total_ms"; + case Frame_Trace_Measurement::readback_ns: return "readback_ms"; + case Frame_Trace_Measurement::count: break; + } + throw std::logic_error("unknown frame trace measurement"); +} + +void Plot_Diagnostics::submit( + Render_Frame& frame, Frame_Identity rendered_identity, + std::uint32_t value_width, std::uint32_t value_height, + std::size_t value_pixel_bytes, std::string value_output_format, + std::string value_native_format, + std::vector value_supported_formats, + const Frame_Pacing_Properties& value_pacing, bool value_is_3d) { + constexpr auto marker_count = static_cast(Frame_Trace_Marker::count); + constexpr auto measurement_count = static_cast(Frame_Trace_Measurement::count); + std::array, marker_count> markers{}; 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; + markers[static_cast(point.marker)] = + static_cast(point.elapsed_ns) / 1'000'000.0; + const auto marker = [&](Frame_Trace_Marker value) { + return markers[static_cast(value)].value_or(0.0); + }; + const auto interval = [&](Frame_Trace_Marker first, Frame_Trace_Marker last) { + const auto start = markers[static_cast(first)]; + const auto finish = markers[static_cast(last)]; + return start && finish ? std::max(0.0, *finish - *start) : 0.0; + }; + std::array measurements{}; + const auto trace_measurements = frame.trace_values(); + for (const auto& value : trace_measurements) + measurements[static_cast(value.measurement)] = + static_cast(value.value_ns) / 1'000'000.0; + const auto measurement = [&](Frame_Trace_Measurement value) { + return measurements[static_cast(value)]; + }; + Statistic_Input_Batch values{ + {"server_completion_ms", marker(Frame_Trace_Marker::frame_ready)}, + {"payload_megabytes", static_cast(value_pixel_bytes) / (1024.0 * 1024.0)}, + {"scene_render_ms", interval(Frame_Trace_Marker::scene_render_started, Frame_Trace_Marker::scene_render_finished)}, + {"event_dispatch_ms", interval(Frame_Trace_Marker::event_dispatch_started, Frame_Trace_Marker::event_dispatch_finished)}, + {"prepare_ms", interval(Frame_Trace_Marker::prepare_started, Frame_Trace_Marker::prepare_finished)}, + {"paint_ms", interval(Frame_Trace_Marker::paint_started, Frame_Trace_Marker::paint_finished)}, + {"backend_queue_ms", interval(Frame_Trace_Marker::backend_queue_entered, Frame_Trace_Marker::backend_queue_left)}, + {"gpu_submission_ms", interval(Frame_Trace_Marker::gpu_submitted, Frame_Trace_Marker::gpu_completed)}, + {"readback_stage_ms", interval(Frame_Trace_Marker::readback_started, Frame_Trace_Marker::readback_finished)}, + {"callback_ms", interval(Frame_Trace_Marker::callback_started, Frame_Trace_Marker::frame_ready)}}; + for (const auto& value : trace_measurements) + values.emplace_back(measurement_key(value.measurement), + static_cast(value.value_ns) / 1'000'000.0); + + double remaining = marker(Frame_Trace_Marker::frame_ready); + auto take = [&](double requested) { + const auto result = std::min(remaining, std::max(0.0, requested)); + remaining -= result; + return result; + }; + const double scene_time = interval(Frame_Trace_Marker::scene_render_started, + Frame_Trace_Marker::scene_render_finished); + const double event_time = std::min(scene_time, interval( + Frame_Trace_Marker::event_dispatch_started, + Frame_Trace_Marker::event_dispatch_finished)); + const double prepare_time = std::min(std::max(0.0, scene_time - event_time), + interval(Frame_Trace_Marker::prepare_started, Frame_Trace_Marker::prepare_finished)); + const double paint_time = std::min(std::max(0.0, scene_time - event_time - prepare_time), + interval(Frame_Trace_Marker::paint_started, Frame_Trace_Marker::paint_finished)); + values.emplace_back(value_is_3d ? "pipeline_3d_event_ms" : "pipeline_2d_event_ms", take(event_time)); + values.emplace_back(value_is_3d ? "pipeline_3d_prepare_ms" : "pipeline_2d_prepare_ms", take(prepare_time)); + values.emplace_back(value_is_3d ? "pipeline_3d_submit_graph_ms" : "pipeline_2d_paint_ms", take(paint_time)); + values.emplace_back(value_is_3d ? "pipeline_3d_scene_coordination_ms" : "pipeline_2d_scene_coordination_ms", + take(std::max(0.0, scene_time - event_time - prepare_time - paint_time))); + if (value_is_3d) { + const double scene_finished = marker(Frame_Trace_Marker::scene_render_finished); + const double queue_entered = marker(Frame_Trace_Marker::backend_queue_entered); + const double backend_prepare_started = marker(Frame_Trace_Marker::backend_prepare_started); + const double backend_prepare_finished = marker(Frame_Trace_Marker::backend_prepare_finished); + const double submit_queued = marker(Frame_Trace_Marker::backend_submit_queued); + const double queue_left = marker(Frame_Trace_Marker::backend_queue_left); + values.emplace_back("pipeline_3d_prepare_queue_ms", take(std::max( + 0.0, backend_prepare_started - std::max(scene_finished, queue_entered)))); + double preparation_window = std::max(0.0, backend_prepare_finished - backend_prepare_started); + const auto take_preparation = [&](Frame_Trace_Measurement key) { + const double value = std::min(preparation_window, std::max(0.0, measurement(key))); + preparation_window -= value; + return take(value); + }; + values.emplace_back("pipeline_3d_backend_apply_ms", take_preparation(Frame_Trace_Measurement::backend_apply_ns)); + values.emplace_back("pipeline_3d_backend_plan_ms", take_preparation(Frame_Trace_Measurement::backend_plan_ns)); + values.emplace_back("pipeline_3d_backend_execute_ms", take_preparation(Frame_Trace_Measurement::backend_execute_ns)); + values.emplace_back("pipeline_3d_backend_commands_ms", take(preparation_window)); + values.emplace_back("pipeline_3d_backend_queue_ms", take(std::max(0.0, queue_left - submit_queued))); + const double gpu_submitted = marker(Frame_Trace_Marker::gpu_submitted); + double submit_window = std::max(0.0, gpu_submitted - queue_left); + const double measured_submit = std::min(submit_window, std::max( + 0.0, measurement(Frame_Trace_Measurement::backend_submit_ns))); + values.emplace_back("pipeline_3d_backend_submit_ms", take(measured_submit)); + submit_window -= measured_submit; + values.emplace_back("pipeline_3d_submit_handoff_ms", take(submit_window)); + double gpu_window = interval(Frame_Trace_Marker::gpu_submitted, + Frame_Trace_Marker::gpu_completed); + const auto take_gpu = [&](Frame_Trace_Measurement key) { + const double value = std::min(gpu_window, std::max(0.0, measurement(key))); + gpu_window -= value; + return take(value); + }; + values.emplace_back("pipeline_3d_gpu_render_ms", take_gpu(Frame_Trace_Measurement::gpu_render_ns)); + values.emplace_back("pipeline_3d_gpu_transition_ms", take_gpu(Frame_Trace_Measurement::gpu_transition_ns)); + values.emplace_back("pipeline_3d_gpu_copy_ms", take_gpu(Frame_Trace_Measurement::gpu_copy_ns)); + values.emplace_back("pipeline_3d_gpu_sync_ms", take(gpu_window)); + values.emplace_back("pipeline_3d_readback_ms", take(interval( + Frame_Trace_Marker::readback_started, Frame_Trace_Marker::readback_finished))); + values.emplace_back("pipeline_3d_callback_ms", take(interval( + Frame_Trace_Marker::callback_started, Frame_Trace_Marker::frame_ready))); + values.emplace_back("pipeline_3d_completion_handoff_ms", remaining); + } else { + values.emplace_back("pipeline_2d_callback_ms", take(interval( + Frame_Trace_Marker::callback_started, Frame_Trace_Marker::frame_ready))); + values.emplace_back("pipeline_2d_frame_handoff_ms", remaining); + } + const auto now = std::chrono::steady_clock::now(); const auto identity = frame.identity(); + { + std::lock_guard lock(state_mutex); + if (previous_completion != std::chrono::steady_clock::time_point{}) { + values.emplace_back("frame_interval_ms", elapsed_milliseconds(previous_completion, now)); + if (identity.sequence > previous_sequence + 1U) + dropped_sequences += identity.sequence - previous_sequence - 1U; + } + previous_completion = now; + previous_sequence = identity.sequence; + sequence = identity.sequence; + correlation_id = identity.correlation_id; + this->rendered_sequence = rendered_identity.sequence; + rendered_correlation_id = rendered_identity.correlation_id; + created_time_unix_ns = frame.created_time_unix_ns(); + width = value_width; + height = value_height; + pixel_bytes = value_pixel_bytes; + output_format = std::move(value_output_format); + native_format = std::move(value_native_format); + supported_formats = std::move(value_supported_formats); + pacing = value_pacing; + is_3d = value_is_3d; + } + frame_values.submit(values.view()); +} + +void Plot_Diagnostics::submit_input(const Plot_Input_Observation& observation) { + const std::array, 6> values{{ + {"input_plot_queue_ms", observation.plot_queue_ms}, + {"input_scene_queue_ms", observation.scene_queue_ms}, + {"input_backend_queue_ms", observation.backend_queue_ms}, + {"input_frame_completion_ms", observation.frame_completion_ms}, + {"input_server_total_ms", observation.server_total_ms}, + {"input_coalesced_event_count", static_cast(observation.coalesced_event_count)}}}; + input_values.submit(values); +} + +void Plot_Diagnostics::reset() { + frame_values.reset(); + input_values.reset(); + std::lock_guard lock(state_mutex); + previous_completion = {}; + previous_sequence = 0; + dropped_sequences = 0; +} + +nlohmann::json Plot_Diagnostics::snapshot() const { + nlohmann::json frame_statistics = nlohmann::json::object(); + for (const auto& value : frame_values.snapshot()) + frame_statistics[value.key] = value.statistics; + nlohmann::json input_statistics = nlohmann::json::object(); + for (const auto& value : input_values.snapshot()) + input_statistics[value.key] = value.statistics; + std::lock_guard lock(state_mutex); + const auto interval = frame_statistics.find("frame_interval_ms"); + const double frame_rate = interval != frame_statistics.end() && + interval->value("trimmed_average", 0.0) > 0.0 + ? 1'000.0 / interval->value("trimmed_average", 0.0) : 0.0; return { - {"kind", "frame_metadata"}, {"protocol", "aethera.video.frame"}, - {"version", frame_protocol_version}, {"sequence", identity.sequence}, - {"correlation_id", identity.correlation_id}, - {"rendered_sequence", rendered_identity.sequence}, - {"rendered_correlation_id", rendered_identity.correlation_id}, - {"created_time_unix_ms", static_cast(frame.created_time_unix_ns()) / 1'000'000.0}, + {"protocol", "aethera.plot.diagnostics"}, {"version", 1}, + {"dimension", is_3d ? "3D" : "2D"}, + {"sequence", sequence}, {"correlation_id", correlation_id}, + {"rendered_sequence", rendered_sequence}, + {"rendered_correlation_id", rendered_correlation_id}, + {"generated_time_unix_ms", static_cast(created_time_unix_ns) / 1'000'000.0}, {"delivery", pixel_bytes == 0 ? "diagnostics" : "gallery-video"}, - {"pixel", {{"width", width}, {"height", height}, {"format", output_format}, - {"native_format", native_format}, - {"supported_formats", std::move(supported_formats)}, - {"byte_length", pixel_bytes}}}, - {"video", {{"codec", "H264"}, - {"transport", "shared WebRTC atlas"}}}, + {"frame_rate_fps", frame_rate}, {"dropped_sequence_count", dropped_sequences}, + {"window_capacity", diagnostic_window_capacity}, + {"pixel", {{"width", width}, {"height", height}, + {"format", output_format}, {"native_format", native_format}, + {"supported_formats", supported_formats}, {"byte_length", pixel_bytes}}}, {"pacing", {{"mode", pacing_mode_name(pacing.mode)}, {"fixed_rate_fps", pacing.fixed_rate_fps}, {"render_enabled", pacing.render_enabled}, {"video_enabled", pacing.video_enabled}}}, - {"trace", {{"clock", "steady_elapsed_ns"}, {"markers", std::move(markers)}, - {"measurements", std::move(measurements)}}} - }; + {"frame_statistics", std::move(frame_statistics)}, + {"input_statistics", std::move(input_statistics)}}; } template @@ -329,6 +557,7 @@ struct Plot::Private { std::atomic> terminal_failure{}; /* 首次 Plot Unknown Failure 的唯一终止状态。 */ std::uint64_t next_frame_sequence{1}; Frame_Policy frame_policy{}; + Plot_Diagnostics diagnostics{}; mutable std::mutex frame_mutex; static constexpr std::size_t scene_frame_capacity{3}; std::array frame_slots{}; /* Scene 借用的稳定三缓冲物理帧。 */ @@ -377,7 +606,7 @@ void Plot::Private::fail(std::exception_ptr failure) noexcept { Plot_Stream_Frame{nlohmann::json{ {"kind", "plot_error"}, {"protocol", "aethera.video.frame"}, - {"version", frame_protocol_version}, + {"version", plot_stream_protocol_version}, {"message", *description} }.dump(), {}}); publish(std::move(output)); @@ -587,6 +816,7 @@ void Plot::Private::complete_event_reports(Frame_Identity rendered_identity) { observation.rendered_frame_sequence = rendered_identity.sequence; observation.coalesced_event_count = metadata.input.coalesced_event_count; + diagnostics.submit_input(observation); try { if (metadata.handler) metadata.handler(std::move(observation)); } @@ -685,27 +915,25 @@ void Plot::Private::queue_completed_frame(Render_Frame* frame) { width, height}); frame->mark(Frame_Trace_Marker::frame_ready); - std::string metadata; - if (identity.sequence == 1 || - identity.sequence % diagnostic_sample_period == 0) { - nlohmann::json supported_formats = nlohmann::json::array(); - if (std::holds_alternative>( - managed->frame)) { - for (const auto format : Frame_2D::supported_pixel_formats) - supported_formats.push_back(pixel_format_name(format)); - } - else { - for (const auto format : Frame_3D::supported_pixel_formats) - supported_formats.push_back(pixel_format_name(format)); - } - metadata = frame_metadata( - *frame, width, height, - pixels->rgba ? pixels->rgba->size() : 0U, - rendered_identity, output_format, native_format, - std::move(supported_formats), pacing).dump(); + std::vector supported_formats; + const bool is_3d = std::holds_alternative>( + managed->frame); + if (is_3d) { + supported_formats.reserve(Frame_3D::supported_pixel_formats.size()); + for (const auto format : Frame_3D::supported_pixel_formats) + supported_formats.emplace_back(pixel_format_name(format)); + } else { + supported_formats.reserve(Frame_2D::supported_pixel_formats.size()); + for (const auto format : Frame_2D::supported_pixel_formats) + supported_formats.emplace_back(pixel_format_name(format)); } + diagnostics.submit( + *frame, rendered_identity, width, height, + pixels->rgba ? pixels->rgba->size() : 0U, + std::string{output_format}, std::string{native_format}, + std::move(supported_formats), pacing, is_3d); const auto published = std::make_shared( - Plot_Stream_Frame{std::move(metadata), std::move(pixels)}); + Plot_Stream_Frame{{}, std::move(pixels)}); publish(std::move(published)); retire(); } @@ -769,7 +997,7 @@ Plot::Stream_Id Plot::subscribe(Stream_Handler handler) { Plot_Stream_Frame{nlohmann::json{ {"kind", "plot_error"}, {"protocol", "aethera.video.frame"}, - {"version", frame_protocol_version}, + {"version", plot_stream_protocol_version}, {"message", failure ? *failure : "Plot unavailable"} }.dump(), {}})); } @@ -925,4 +1153,12 @@ void Plot::async_generate_data(nlohmann::json input, Json_Handler handler) { catch (...) {} }); } + +nlohmann::json Plot::diagnostics() const { + return d->diagnostics.snapshot(); +} + +void Plot::reset_diagnostics() { + d->diagnostics.reset(); +} } diff --git a/web_server/src/Plot.hpp b/web_server/src/Plot.hpp index b6dd23e..86fc069 100644 --- a/web_server/src/Plot.hpp +++ b/web_server/src/Plot.hpp @@ -69,7 +69,7 @@ struct Plot_Pixel_Frame { }; struct Plot_Stream_Frame { - std::string metadata{}; /* 抽样诊断文本;空值表示本帧不产生诊断流量。 */ + std::string notification{}; /* 仅用于终止错误等低频控制通知;正常帧为空。 */ std::shared_ptr pixels{}; /* 每帧完成进度及其可选图集像素。 */ }; @@ -112,6 +112,8 @@ public: void async_write_prop(std::string component, std::string key, nlohmann::json value, Json_Handler handler); void async_generate_data(nlohmann::json input, Json_Handler handler); + [[nodiscard]] nlohmann::json diagnostics() const; + void reset_diagnostics(); private: struct Private; diff --git a/web_server/src/Renderable_Adapter.ipp b/web_server/src/Renderable_Adapter.ipp index 29bbe76..b212748 100644 --- a/web_server/src/Renderable_Adapter.ipp +++ b/web_server/src/Renderable_Adapter.ipp @@ -272,7 +272,8 @@ inline std::string_view protocol_field_label(std::string_view key) { {"type", "星座类型"}, {"point_color", "数据点颜色"}, {"anchor_color", "参考点颜色"}, {"points", "星座点数据"}, {"label_font", "标签字体"}, {"label_pen", "标签画笔"}, {"selection_brush", "选区画刷"}, {"selection_border_pen", "选区边框画笔"}, - {"selected_regions", "已选区域"}, {"transform", "空间变换"}, {"visible", "是否可见"}, + {"selected_regions", "已选区域"}, {"cache_enabled", "启用绘制缓存"}, + {"transform", "空间变换"}, {"visible", "是否可见"}, {"depth_test", "深度测试"}, {"items", "项目数据"}, {"prepare_dirty", "准备阶段待更新"}, {"paint_dirty", "绘制阶段待更新"}, {"prepare_executed", "准备阶段已执行"}, {"paint_executed", "绘制阶段已执行"}, diff --git a/web_server/src/Sliding_Statistics.cpp b/web_server/src/Sliding_Statistics.cpp new file mode 100644 index 0000000..db54cac --- /dev/null +++ b/web_server/src/Sliding_Statistics.cpp @@ -0,0 +1,143 @@ +#include "Sliding_Statistics.hpp" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace aethera::web { +namespace { +double percentile(const std::vector& sorted, double ratio) { + if (sorted.empty()) return 0.0; + const auto index = static_cast(std::ceil( + ratio * static_cast(sorted.size()))) - 1U; + return sorted[std::min(index, sorted.size() - 1U)]; +} + +double range_average(const std::vector& values, + std::size_t first, std::size_t last) { + if (first >= last) return 0.0; + return std::accumulate(values.begin() + static_cast(first), + values.begin() + static_cast(last), 0.0) / + static_cast(last - first); +} +} + +struct Sliding_Statistics::Private { + explicit Private(std::size_t value_capacity) + : values(value_capacity) {} + + mutable std::mutex mutex; + std::vector values; /* 预分配的固定容量滑动窗口。 */ + std::size_t size{}; /* 当前窗口中的有效样本数量。 */ + std::size_t next{}; /* 下一次覆盖写入的位置。 */ +}; + +Sliding_Statistics::Sliding_Statistics(std::size_t capacity) + : d(std::make_unique(capacity)) { + if (capacity == 0) + throw std::invalid_argument("statistics capacity must be positive"); +} + +Sliding_Statistics::~Sliding_Statistics() = default; + +void Sliding_Statistics::submit(double value) { + if (!std::isfinite(value)) return; + std::lock_guard lock(d->mutex); + d->values[d->next] = value; + d->next = (d->next + 1U) % d->values.size(); + d->size = std::min(d->size + 1U, d->values.size()); +} + +void Sliding_Statistics::reset() { + std::lock_guard lock(d->mutex); + d->size = 0; + d->next = 0; +} + +Statistic_Snapshot Sliding_Statistics::snapshot() const { + std::vector values; + double latest{}; + { + std::lock_guard lock(d->mutex); + values.reserve(d->size); + if (d->size == 0) return {}; + const auto first = d->size == d->values.size() ? d->next : 0U; + for (std::size_t offset = 0; offset < d->size; ++offset) + values.push_back(d->values[(first + offset) % d->values.size()]); + latest = values.back(); + } + std::ranges::sort(values); + const double mean = range_average(values, 0, values.size()); + const auto trim = values.size() >= 20U ? values.size() / 20U : 0U; + const double variance = std::accumulate( + values.begin(), values.end(), 0.0, + [mean](double sum, double value) { + const double distance = value - mean; + return sum + distance * distance; + }) / static_cast(values.size()); + return Statistic_Snapshot{ + values.size(), latest, values.front(), values.back(), mean, + range_average(values, trim, values.size() - trim), + std::sqrt(variance), percentile(values, 0.50), + percentile(values, 0.95), percentile(values, 0.99)}; +} + +void to_json(nlohmann::json& output, const Statistic_Snapshot& snapshot) { + output = nlohmann::json{ + {"count", snapshot.count}, {"latest", snapshot.latest}, + {"minimum", snapshot.minimum}, {"maximum", snapshot.maximum}, + {"average", snapshot.average}, + {"trimmed_average", snapshot.trimmed_average}, + {"variability", snapshot.variability}, {"p50", snapshot.p50}, + {"p95", snapshot.p95}, {"p99", snapshot.p99}}; +} + +struct Sliding_Statistics_Set::Private { + explicit Private(std::size_t value_capacity) + : capacity(value_capacity) {} + + mutable std::mutex mutex; + std::size_t capacity{}; /* 每个命名字段保留的相同滑动窗口容量。 */ + std::map, std::less<>> values; +}; + +Sliding_Statistics_Set::Sliding_Statistics_Set(std::size_t capacity) + : d(std::make_unique(capacity)) { + if (capacity == 0) + throw std::invalid_argument("statistics set capacity must be positive"); +} + +Sliding_Statistics_Set::~Sliding_Statistics_Set() = default; + +void Sliding_Statistics_Set::submit( + std::span> values) { + std::lock_guard lock(d->mutex); + for (const auto& [key, value] : values) { + if (!std::isfinite(value)) continue; + auto found = d->values.find(key); + if (found == d->values.end()) + found = d->values.emplace( + std::string{key}, std::make_unique(d->capacity)).first; + found->second->submit(value); + } +} + +void Sliding_Statistics_Set::reset() { + std::lock_guard lock(d->mutex); + d->values.clear(); +} + +std::vector Sliding_Statistics_Set::snapshot() const { + std::vector output; + std::lock_guard lock(d->mutex); + output.reserve(d->values.size()); + for (const auto& [key, value] : d->values) + output.push_back({key, value->snapshot()}); + std::ranges::sort(output, {}, &Named_Statistic_Snapshot::key); + return output; +} +} diff --git a/web_server/src/Sliding_Statistics.hpp b/web_server/src/Sliding_Statistics.hpp new file mode 100644 index 0000000..d31e150 --- /dev/null +++ b/web_server/src/Sliding_Statistics.hpp @@ -0,0 +1,63 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include + +namespace aethera::web { +struct Statistic_Snapshot { + std::size_t count{}; /* 当前滑动窗口内的有效样本数。 */ + double latest{}; /* 最近一次提交的瞬时值。 */ + double minimum{}; /* 窗口最小值。 */ + double maximum{}; /* 窗口最大值。 */ + double average{}; /* 窗口算术平均值。 */ + double trimmed_average{}; /* 两端各舍弃 5% 样本后的平均值。 */ + double variability{}; /* 窗口总体标准差。 */ + double p50{}; /* 窗口第 50 百分位。 */ + double p95{}; /* 窗口第 95 百分位。 */ + double p99{}; /* 窗口第 99 百分位。 */ +}; + +void to_json(nlohmann::json& output, const Statistic_Snapshot& snapshot); + +class Sliding_Statistics final { +public: + explicit Sliding_Statistics(std::size_t capacity = 600); + ~Sliding_Statistics(); + Sliding_Statistics(const Sliding_Statistics&) = delete; + Sliding_Statistics& operator=(const Sliding_Statistics&) = delete; + + void submit(double value); + void reset(); + [[nodiscard]] Statistic_Snapshot snapshot() const; + +private: + struct Private; + std::unique_ptr d; +}; + +struct Named_Statistic_Snapshot { + std::string key; /* 协议适配使用的稳定业务字段名。 */ + Statistic_Snapshot statistics{}; /* 该字段当前滑动窗口的派生统计。 */ +}; + +class Sliding_Statistics_Set final { +public: + explicit Sliding_Statistics_Set(std::size_t capacity = 600); + ~Sliding_Statistics_Set(); + Sliding_Statistics_Set(const Sliding_Statistics_Set&) = delete; + Sliding_Statistics_Set& operator=(const Sliding_Statistics_Set&) = delete; + + void submit(std::span> values); + void reset(); + [[nodiscard]] std::vector snapshot() const; + +private: + struct Private; + std::unique_ptr d; +}; +} diff --git a/web_server/src/Web_Server.cpp b/web_server/src/Web_Server.cpp index b479b77..899536c 100644 --- a/web_server/src/Web_Server.cpp +++ b/web_server/src/Web_Server.cpp @@ -150,11 +150,42 @@ int run_web_server(std::uint16_t port, const std::filesystem::path& asset_root) {"id", id}, {"title", id}, {"category", "Plots"}, {"description", ""}, {"dimension", id.starts_with("datoviz_") ? "3D" : "2D"}, {"websocket", "/ws/plot/" + id}, {"media", plot_media->at(id)}, - {"schema", "/plot/" + id + "/schema"}}); + {"schema", "/plot/" + id + "/schema"}, + {"diagnostics", "/plot/" + id + "/diagnostics"}}); } callback(json_response(std::move(result))); }, {drogon::Get}); + app.registerHandler("/plot/{1}/diagnostics", [plots]( + const drogon::HttpRequestPtr& request, + std::function&& callback, + std::string plot_id) { + auto plot = find_plot(*plots, plot_id); + if (!plot) { + callback(error_response(drogon::k404NotFound, "unknown plot")); + return; + } + if (request->method() == drogon::Delete) { + plot->reset_diagnostics(); + callback(json_response({{"success", true}})); + return; + } + callback(json_response(plot->diagnostics())); + }, {drogon::Get, drogon::Delete}); + + app.registerHandler("/gallery/{1}/diagnostics", [gallery_streams]( + const drogon::HttpRequestPtr&, + std::function&& callback, + std::string group_id) { + const auto found = gallery_streams->find(group_id); + if (found == gallery_streams->end()) { + callback(error_response(drogon::k404NotFound, + "unknown gallery media group")); + return; + } + callback(json_response(found->second->diagnostics())); + }, {drogon::Get}); + app.registerHandler("/plot/{1}/schema", [plots]( const drogon::HttpRequestPtr&, std::function&& callback, diff --git a/web_server/tests/Sliding_Statistics_Tests.cpp b/web_server/tests/Sliding_Statistics_Tests.cpp new file mode 100644 index 0000000..54dd364 --- /dev/null +++ b/web_server/tests/Sliding_Statistics_Tests.cpp @@ -0,0 +1,38 @@ +#include "web_server/src/Sliding_Statistics.hpp" +#include +#include + +namespace aethera::web { +TEST(Sliding_Statistics, Derives_Window_Statistics_On_Demand) { + Sliding_Statistics statistics{20}; + for (int value = 1; value <= 20; ++value) + statistics.submit(static_cast(value)); + + const auto snapshot = statistics.snapshot(); + EXPECT_EQ(snapshot.count, 20U); + EXPECT_DOUBLE_EQ(snapshot.latest, 20.0); + EXPECT_DOUBLE_EQ(snapshot.minimum, 1.0); + EXPECT_DOUBLE_EQ(snapshot.maximum, 20.0); + EXPECT_DOUBLE_EQ(snapshot.average, 10.5); + EXPECT_DOUBLE_EQ(snapshot.trimmed_average, 10.5); + EXPECT_DOUBLE_EQ(snapshot.p50, 10.0); + EXPECT_DOUBLE_EQ(snapshot.p95, 19.0); + EXPECT_DOUBLE_EQ(snapshot.p99, 20.0); +} + +TEST(Sliding_Statistics, Overwrites_Oldest_And_Ignores_Nonfinite_Values) { + Sliding_Statistics statistics{3}; + statistics.submit(1.0); + statistics.submit(2.0); + statistics.submit(std::numeric_limits::infinity()); + statistics.submit(3.0); + statistics.submit(4.0); + + const auto snapshot = statistics.snapshot(); + EXPECT_EQ(snapshot.count, 3U); + EXPECT_DOUBLE_EQ(snapshot.latest, 4.0); + EXPECT_DOUBLE_EQ(snapshot.minimum, 2.0); + EXPECT_DOUBLE_EQ(snapshot.maximum, 4.0); + EXPECT_DOUBLE_EQ(snapshot.average, 3.0); +} +} diff --git a/webapp_gallery/src/app.tsx b/webapp_gallery/src/app.tsx index e317bad..87b29a4 100644 --- a/webapp_gallery/src/app.tsx +++ b/webapp_gallery/src/app.tsx @@ -13,7 +13,7 @@ import "react-resizable/css/styles.css"; echarts.use([LineChart, DataZoomComponent, GridComponent, LegendComponent, TooltipComponent, CanvasRenderer]); -type Plot = {id: string; title: string; category: string; description: string; dimension: "2D" | "3D"; websocket: string; media: string; schema: string}; +type Plot = {id: string; title: string; category: string; description: string; dimension: "2D" | "3D"; websocket: string; media: string; schema: string; diagnostics: 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" | "marker-list" | "surface-marker-list" | "json"; type Color_Channel_Scale = "normalized" | "byte"; @@ -29,15 +29,17 @@ type Stream_Status = "IDLE" | "CONNECTING" | "LIVE" | "OFFLINE"; type Frame_Pacing_Mode = "manual" | "fixed_rate" | "maximum_rate"; type Frame_Delivery = "gallery-video" | "diagnostics"; type Pixel_Format = "rgba8" | "bgra8_premultiplied"; -type Frame_Metadata = {kind: "frame_metadata"; protocol: "aethera.video.frame"; version: 9; sequence: number; correlation_id: number; - rendered_sequence: number; rendered_correlation_id: number; - delivery: Frame_Delivery; - created_time_unix_ms: number; pixel: {width: number; height: number; format: Pixel_Format; native_format: Pixel_Format; supported_formats: Pixel_Format[]; byte_length: number}; - pacing: {mode: Frame_Pacing_Mode; fixed_rate_fps: number; render_enabled: boolean; video_enabled: boolean}; - trace: {clock: "steady_elapsed_ns"; markers: Record; measurements: Record}}; type Frame_Stage_Values = Record; -type Frame_Sample = {sequence: number; correlation_id: number; rendered_sequence: number; rendered_correlation_id: number; - delivery: Frame_Delivery; generated_at_ms: number; received_at_ms: number; values: Frame_Stage_Values}; +type Statistic_Snapshot = {count: number; latest: number; minimum: number; maximum: number; average: number; + trimmed_average: number; variability: number; p50: number; p95: number; p99: number}; +type Plot_Diagnostics = {protocol: "aethera.plot.diagnostics"; version: 1; dimension: "2D" | "3D"; + sequence: number; correlation_id: number; rendered_sequence: number; rendered_correlation_id: number; + generated_time_unix_ms: number; delivery: Frame_Delivery; frame_rate_fps: number; dropped_sequence_count: number; + window_capacity: number; pixel: {width: number; height: number; format: Pixel_Format; native_format: Pixel_Format; + supported_formats: Pixel_Format[]; byte_length: number}; + pacing: {mode: Frame_Pacing_Mode; fixed_rate_fps: number; render_enabled: boolean; video_enabled: boolean}; + frame_statistics: Record; input_statistics: Record}; +type Frame_Sample = {sequence: number; generated_at_ms: number; received_at_ms: number; values: Frame_Stage_Values}; type Video_Playback_Metrics = {frame_rate_fps: number; presented_frames: number; dropped_frames: number; current_time_seconds: number; ready_state: number}; type Gallery_Tile = {column: number; row: number}; type Gallery_Layout = {kind: "gallery_layout"; protocol: "aethera.gallery.video"; version: 2; columns: number; rows: number; @@ -53,18 +55,13 @@ type Gallery_Transport_Metrics = {kind: "gallery_metrics"; protocol: "aethera.ga type Gallery_Video_State = {status: Stream_Status; stream: MediaStream | null; layout: Gallery_Layout | null; playback: Video_Playback_Metrics; transport: Gallery_Transport_Metrics | null; error: string | null}; type Gallery_Video_States = Record; -type Frame_Diagnostics = {metadata: Frame_Metadata; samples: Frame_Sample[]; frame_rate_fps: number; delivery_frame_rate_fps: number; interval_jitter_p95_ms: number; - server_completion_average_ms: number; server_completion_p50_ms: number; server_completion_p95_ms: number; server_completion_p99_ms: number; - frame_interval_average_ms: number; frame_interval_p95_ms: number; dropped_sequence_count: number; latest: Frame_Stage_Values; - video_playback: Video_Playback_Metrics}; +type Frame_Diagnostics = {server: Plot_Diagnostics; samples: Frame_Sample[]; video_playback: Video_Playback_Metrics}; type Frame_Metrics = {sequence: number; generated_time_unix_ms: number; server_completion_ms: number; average_server_completion_ms: number; p95_server_completion_ms: number; p99_server_completion_ms: number; frame_rate_fps: number; p95_frame_interval_jitter_ms: number; pacing_mode: Frame_Pacing_Mode; fixed_rate_fps: number; delivery: Frame_Delivery; video_playback: Video_Playback_Metrics}; -type Input_Stage = "queued" | "scene_dispatched" | "backend_queued" | "backend_applied" | "frame_completed"; -type Input_Observation = {kind: "input_observation"; protocol: "aethera.input.latency"; version: 1; sequence: number; - event_type: string; stage: Input_Stage; plot_queue_ms: number; scene_queue_ms: number; backend_queue_ms: number; frame_completion_ms: number; - server_total_ms: number; target_frame_sequence: number; rendered_frame_sequence: number; coalesced_event_count: number}; -type Input_Latency = Input_Observation & {browser_created_time_ms: number; browser_observation_ms: number}; +type Input_Completion = {kind: "input_completion"; protocol: "aethera.input.latency"; version: 2; sequence: number; + rendered_frame_sequence: number; coalesced_event_count: number}; +type Input_Latency = Input_Completion & {browser_created_time_ms: number; browser_observation_ms: number}; type Stage_Statistic = "average" | "variability" | "p95" | "p99"; type Stage_Unit = "value" | "percentage"; @@ -91,26 +88,19 @@ function socket_url(path: string) { return url.toString(); } -function valid_frame_metadata(value: unknown): value is Frame_Metadata { +function valid_plot_diagnostics(value: unknown): value is Plot_Diagnostics { if (!value || typeof value !== "object") return false; - const frame = value as Partial; - return frame.kind === "frame_metadata" && frame.protocol === "aethera.video.frame" && frame.version === 9 - && typeof frame.sequence === "number" && typeof frame.correlation_id === "number" - && typeof frame.rendered_sequence === "number" && typeof frame.rendered_correlation_id === "number" - && (frame.delivery === "gallery-video" || frame.delivery === "diagnostics") - && Boolean(frame.pixel) - && (frame.pixel?.format === "rgba8" || frame.pixel?.format === "bgra8_premultiplied") - && (frame.pixel?.native_format === "rgba8" || frame.pixel?.native_format === "bgra8_premultiplied") - && Array.isArray(frame.pixel?.supported_formats) - && Boolean(frame.trace); + const diagnostics = value as Partial; + return diagnostics.protocol === "aethera.plot.diagnostics" && diagnostics.version === 1 && + typeof diagnostics.sequence === "number" && Boolean(diagnostics.frame_statistics) && + Boolean(diagnostics.input_statistics) && Boolean(diagnostics.pacing); } -function valid_input_observation(value: unknown): value is Input_Observation { +function valid_input_completion(value: unknown): value is Input_Completion { if (!value || typeof value !== "object") return false; - const observation = value as Partial; - return observation.kind === "input_observation" && observation.protocol === "aethera.input.latency" && - observation.version === 1 && typeof observation.sequence === "number" && - typeof observation.server_total_ms === "number" && typeof observation.stage === "string"; + const completion = value as Partial; + return completion.kind === "input_completion" && completion.protocol === "aethera.input.latency" && + completion.version === 2 && typeof completion.sequence === "number"; } function valid_gallery_layout(value: unknown): value is Gallery_Layout { @@ -141,156 +131,6 @@ function percentile(values: number[], ratio: number) { function average(values: number[]) { return values.length === 0 ? 0 : values.reduce((sum, value) => sum + value, 0) / values.length; } -function variability(values: number[]) { - if (values.length === 0) return 0; - const mean = average(values); - return Math.sqrt(average(values.map(value => (value - mean) ** 2))); -} - -function stage_statistic(values: number[], statistic: Stage_Statistic) { - if (values.length === 0) return Number.NaN; - if (statistic === "average") return average(values); - if (statistic === "variability") return variability(values); - return percentile(values, statistic === "p95" ? .95 : .99); -} - -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): Frame_Stage_Values { - const server_completion = marker_ms(metadata, "frame_ready") ?? 0; - const values: Frame_Stage_Values = { - server_completion_ms: server_completion, - diagnostic_delivery_age_ms: Math.max(0, Date.now() - metadata.created_time_unix_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", "frame_ready"] - ]; - for (const [key, start, finish] of marker_intervals) { - const value = marker_interval_ms(metadata, start, finish); - if (value !== null) values[key] = value; - } - 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; - for (const [key, value] of Object.entries(metadata.trace.markers)) if (Number.isFinite(value)) - values[`marker_${key}_ms`] = value / 1_000_000; - return values; -} - -function pipeline_stage_values(values: Frame_Stage_Values, dimension: Plot["dimension"]): Frame_Stage_Values { - const total = Math.max(0, values.server_completion_ms ?? 0); - let remaining = total; - const take = (requested: number) => { - const value = Math.min(remaining, Math.max(0, requested)); - remaining -= value; - return value; - }; - const interval = (start: string, finish: string) => Math.max(0, (values[`marker_${finish}_ms`] ?? 0) - (values[`marker_${start}_ms`] ?? 0)); - const stages: Frame_Stage_Values = {}; - if (dimension === "2D") { - const scene = Math.max(0, values.scene_render_ms ?? 0); - const event = Math.min(scene, Math.max(0, values.event_dispatch_ms ?? 0)); - const prepare = Math.min(Math.max(0, scene - event), Math.max(0, values.prepare_ms ?? 0)); - const paint = Math.min(Math.max(0, scene - event - prepare), Math.max(0, values.paint_ms ?? 0)); - stages.pipeline_2d_event_ms = take(event); - stages.pipeline_2d_prepare_ms = take(prepare); - stages.pipeline_2d_paint_ms = take(paint); - stages.pipeline_2d_scene_coordination_ms = take(Math.max(0, scene - event - prepare - paint)); - stages.pipeline_2d_callback_ms = take(values.callback_ms ?? 0); - stages.pipeline_2d_frame_handoff_ms = remaining; - remaining = 0; - } else { - const scene = Math.max(0, values.scene_render_ms ?? 0); - const event = Math.min(scene, Math.max(0, values.event_dispatch_ms ?? 0)); - const prepare = Math.min(Math.max(0, scene - event), Math.max(0, values.prepare_ms ?? 0)); - const paint = Math.min(Math.max(0, scene - event - prepare), Math.max(0, values.paint_ms ?? 0)); - stages.pipeline_3d_event_ms = take(event); - stages.pipeline_3d_prepare_ms = take(prepare); - stages.pipeline_3d_submit_graph_ms = take(paint); - stages.pipeline_3d_scene_coordination_ms = take(Math.max(0, scene - event - prepare - paint)); - const scene_finished = values.marker_scene_render_finished_ms ?? 0; - const queue_entered = values.marker_backend_queue_entered_ms ?? scene_finished; - const prepare_started = values.marker_backend_prepare_started_ms ?? queue_entered; - const prepare_finished = values.marker_backend_prepare_finished_ms ?? prepare_started; - const submit_queued = values.marker_backend_submit_queued_ms ?? prepare_finished; - const queue_left = values.marker_backend_queue_left_ms ?? submit_queued; - stages.pipeline_3d_prepare_queue_ms = take(Math.max(0, prepare_started - Math.max(scene_finished, queue_entered))); - let preparation_window = Math.max(0, prepare_finished - prepare_started); - const take_preparation = (key: string) => { const value = Math.min(preparation_window, Math.max(0, values[key] ?? 0)); preparation_window -= value; return take(value); }; - stages.pipeline_3d_backend_apply_ms = take_preparation("backend_apply_ms"); - stages.pipeline_3d_backend_plan_ms = take_preparation("backend_plan_ms"); - stages.pipeline_3d_backend_execute_ms = take_preparation("backend_execute_ms"); - stages.pipeline_3d_backend_commands_ms = take(preparation_window); - stages.pipeline_3d_backend_queue_ms = take(Math.max(0, queue_left - submit_queued)); - const gpu_submitted = values.marker_gpu_submitted_ms ?? queue_left; - let submit_window = Math.max(0, gpu_submitted - queue_left); - const measured_submit = Math.min(submit_window, Math.max(0, values.backend_submit_ms ?? 0)); - stages.pipeline_3d_backend_submit_ms = take(measured_submit); - submit_window -= measured_submit; - stages.pipeline_3d_submit_handoff_ms = take(submit_window); - let gpu_window = interval("gpu_submitted", "gpu_completed"); - const take_gpu = (key: string) => { const value = Math.min(gpu_window, Math.max(0, values[key] ?? 0)); gpu_window -= value; return take(value); }; - stages.pipeline_3d_gpu_render_ms = take_gpu("gpu_render_ms"); - stages.pipeline_3d_gpu_transition_ms = take_gpu("gpu_transition_ms"); - stages.pipeline_3d_gpu_copy_ms = take_gpu("gpu_copy_ms"); - stages.pipeline_3d_gpu_sync_ms = take(gpu_window); - stages.pipeline_3d_readback_ms = take(values.readback_stage_ms ?? values.readback_ms ?? 0); - stages.pipeline_3d_callback_ms = take(values.callback_ms ?? 0); - stages.pipeline_3d_completion_handoff_ms = remaining; - remaining = 0; - } - return {...values, ...stages}; -} - -function build_frame_diagnostics(metadata: Frame_Metadata, samples: Frame_Sample[]): Frame_Diagnostics { - const completion_latencies = samples.map(sample => sample.values.server_completion_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)); - const sequence_steps = samples.slice(1).map((sample, index) => - Math.max(0, sample.sequence - samples[index].sequence)); - const expected_sequence_step = percentile(sequence_steps.filter(value => value > 0), .5); - let dropped_sequence_count = 0; - for (const step of sequence_steps) - dropped_sequence_count += Math.max(0, step - expected_sequence_step); - const delivery_elapsed = samples.length > 1 ? samples.at(-1)!.received_at_ms - samples[0].received_at_ms : 0; - const source_elapsed = samples.length > 1 ? samples.at(-1)!.generated_at_ms - samples[0].generated_at_ms : 0; - const source_frames = samples.length > 1 - ? Math.max(0, samples.at(-1)!.sequence - samples[0].sequence) : 0; - return { - metadata, - samples, - frame_rate_fps: source_elapsed > 0 ? source_frames * 1000 / source_elapsed : 0, - delivery_frame_rate_fps: delivery_elapsed > 0 ? (samples.length - 1) * 1000 / delivery_elapsed : 0, - interval_jitter_p95_ms: percentile(interval_jitter, .95), - server_completion_average_ms: average(completion_latencies), - server_completion_p50_ms: percentile(completion_latencies, .5), - server_completion_p95_ms: percentile(completion_latencies, .95), - server_completion_p99_ms: percentile(completion_latencies, .99), - frame_interval_average_ms: average(intervals), - frame_interval_p95_ms: percentile(intervals, .95), - dropped_sequence_count, - latest: samples.at(-1)?.values ?? {}, - video_playback: {frame_rate_fps: 0, presented_frames: 0, dropped_frames: 0, current_time_seconds: 0, ready_state: 0} - }; -} - const empty_video_playback = (): Video_Playback_Metrics => ({ frame_rate_fps: 0, presented_frames: 0, dropped_frames: 0, current_time_seconds: 0, ready_state: 0 @@ -322,6 +162,20 @@ function use_gallery_videos(plots: Plot[]): Gallery_Video_States { const update = (change: (current: Gallery_Video_State) => Gallery_Video_State) => set_states(current => ({...current, [endpoint]: change(current[endpoint] ?? connecting_gallery_video())})); + const group_id = new URL(endpoint, location.href).searchParams.get("group") ?? ""; + const diagnostics_endpoint = `/gallery/${encodeURIComponent(group_id)}/diagnostics`; + const load_server_diagnostics = async () => { + try { + const response = await fetch(diagnostics_endpoint, {cache: "no-store"}); + const diagnostics: unknown = await response.json(); + if (stopped || !valid_gallery_metrics(diagnostics)) return; + update(current => ({...current, transport: diagnostics})); + window.dispatchEvent(new CustomEvent("aethera:gallery-metrics", + {detail: {...diagnostics, media: endpoint}})); + } catch { + // 视频连接不依赖诊断端点;下一秒自动重试。 + } + }; const socket = new ReconnectingWebSocket(socket_url(endpoint), [], { minReconnectionDelay: 300, maxReconnectionDelay: 5000, reconnectionDelayGrowFactor: 1.6, maxRetries: Number.POSITIVE_INFINITY @@ -402,12 +256,6 @@ function use_gallery_videos(plots: Plot[]): Gallery_Video_States { {detail: {...decoded, media: endpoint}})); return; } - if (valid_gallery_metrics(decoded)) { - update(current => ({...current, transport: decoded})); - window.dispatchEvent(new CustomEvent("aethera:gallery-metrics", - {detail: {...decoded, media: endpoint}})); - return; - } if (message.kind === "gallery_error") { update(current => ({...current, status: "OFFLINE", error: typeof message.message === "string" ? message.message : "Gallery video failed"})); @@ -436,7 +284,11 @@ function use_gallery_videos(plots: Plot[]): Gallery_Video_States { }).catch(error => update(current => ({...current, status: "OFFLINE", error: error instanceof Error ? error.message : "WebRTC negotiation failed"}))); }; - const stats_timer = window.setInterval(() => void sample_stats(), 500); + void load_server_diagnostics(); + const stats_timer = window.setInterval(() => { + void sample_stats(); + void load_server_diagnostics(); + }, 1000); return () => { stopped = true; window.clearInterval(stats_timer); @@ -455,6 +307,7 @@ function use_plot_stream(plot: Plot, tile_width = 720, tile_height = 420) { const [status, set_status] = useState("CONNECTING"); const [metrics, set_metrics] = useState(null); + const [server_diagnostics, set_server_diagnostics] = useState(null); const [input_latency, set_input_latency] = useState(null); const [error, set_error] = useState(null); const socket_ref = useRef(null); @@ -478,78 +331,56 @@ function use_plot_stream(plot: Plot, useEffect(() => { let stopped = false; - let diagnostics_timer = 0; - let previous_diagnostics_publish_time = 0; - let latest_metadata: Frame_Metadata | null = null; let samples: Frame_Sample[] = []; + let diagnostics_request = 0; const socket = new ReconnectingWebSocket(socket_url(plot.websocket), [], { minReconnectionDelay: 300, maxReconnectionDelay: 5000, reconnectionDelayGrowFactor: 1.6, maxRetries: Number.POSITIVE_INFINITY }); socket_ref.current = socket; set_status("CONNECTING"); - const clear_diagnostics_timer = () => { - if (diagnostics_timer) window.clearTimeout(diagnostics_timer); - diagnostics_timer = 0; - }; - 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; + const load_diagnostics = async () => { + const request = ++diagnostics_request; + try { + const response = await fetch(plot.diagnostics, {cache: "no-store"}); + const server: unknown = await response.json(); + if (stopped || request !== diagnostics_request || !valid_plot_diagnostics(server)) return; + const now = performance.now(); + const values = Object.fromEntries(Object.entries(server.frame_statistics) + .map(([key, statistic]) => [key, statistic.latest])); + if (server.sequence !== 0 && samples.at(-1)?.sequence !== server.sequence) + samples = [...samples, {sequence: server.sequence, + generated_at_ms: server.generated_time_unix_ms, + received_at_ms: now, values}].slice(-600); + const diagnostics: Frame_Diagnostics = { + server, samples, video_playback: {...playback_ref.current} + }; + set_server_diagnostics(server); + const completion = server.frame_statistics.server_completion_ms; + const interval = server.frame_statistics.frame_interval_ms; + set_metrics({ + sequence: server.sequence, + generated_time_unix_ms: server.generated_time_unix_ms, + server_completion_ms: completion?.latest ?? 0, + average_server_completion_ms: completion?.average ?? 0, + p95_server_completion_ms: completion?.p95 ?? 0, + p99_server_completion_ms: completion?.p99 ?? 0, + frame_rate_fps: server.frame_rate_fps, + p95_frame_interval_jitter_ms: interval?.p95 ?? 0, + pacing_mode: server.pacing.mode, + fixed_rate_fps: server.pacing.fixed_rate_fps, + delivery: server.delivery, + video_playback: diagnostics.video_playback + }); + window.dispatchEvent(new CustomEvent("aethera-frame-diagnostics", { + detail: {plot_id: plot.id, diagnostics} + })); + } catch { + // 媒体与输入连接继续工作;下一次低频采样会重试诊断请求。 } - clear_diagnostics_timer(); - previous_diagnostics_publish_time = now; - const delivery_samples = samples.filter( - sample => sample.delivery === latest_metadata!.delivery); - if (delivery_samples.length === 0) return; - const diagnostics = { - ...build_frame_diagnostics(latest_metadata, delivery_samples), - video_playback: {...playback_ref.current} - }; - const latest = diagnostics.latest; - set_metrics({ - sequence: latest_metadata.sequence, - generated_time_unix_ms: latest_metadata.created_time_unix_ms, - server_completion_ms: latest.server_completion_ms ?? 0, - average_server_completion_ms: diagnostics.server_completion_average_ms, - p95_server_completion_ms: diagnostics.server_completion_p95_ms, - p99_server_completion_ms: diagnostics.server_completion_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, - delivery: latest_metadata.delivery, - video_playback: diagnostics.video_playback - }); - window.dispatchEvent(new CustomEvent("aethera-frame-diagnostics", { - detail: {plot_id: plot.id, diagnostics} - })); - }; - const complete_metadata = (metadata: Frame_Metadata) => { - const received_at = performance.now(); - latest_metadata = metadata; - const sample_capacity = plot.dimension === "2D" ? 900 : 600; - samples = [...samples, { - sequence: metadata.sequence, - correlation_id: metadata.correlation_id, - rendered_sequence: metadata.rendered_sequence, - rendered_correlation_id: metadata.rendered_correlation_id, - delivery: metadata.delivery, - generated_at_ms: metadata.created_time_unix_ms, - received_at_ms: received_at, - values: pipeline_stage_values(frame_stage_values(metadata), plot.dimension) - }].slice(-sample_capacity); - const delivery_count = samples.filter( - sample => sample.delivery === metadata.delivery).length; - publish_diagnostics(delivery_count === 1); }; + void load_diagnostics(); + const diagnostics_timer = window.setInterval(() => void load_diagnostics(), 1000); const on_manual_frame = (event: Event) => { const detail = (event as CustomEvent<{plot_id: string}>).detail; if (detail?.plot_id === plot.id && socket.readyState === WebSocket.OPEN) @@ -569,9 +400,8 @@ function use_plot_stream(plot: Plot, const detail = (event as CustomEvent<{plot_id: string}>).detail; if (detail?.plot_id !== plot.id) return; samples = []; - latest_metadata = null; - previous_diagnostics_publish_time = 0; set_metrics(null); + void fetch(plot.diagnostics, {method: "DELETE"}).then(() => load_diagnostics()); window.dispatchEvent(new CustomEvent("aethera-frame-diagnostics-cleared", { detail: {plot_id: plot.id} })); @@ -589,18 +419,13 @@ function use_plot_stream(plot: Plot, if (typeof event.data !== "string") return; let decoded: unknown; try { decoded = JSON.parse(event.data); } catch { return; } - if (valid_frame_metadata(decoded)) { - complete_metadata(decoded); - return; - } - if (valid_input_observation(decoded)) { + if (valid_input_completion(decoded)) { const created = input_origins.current.get(decoded.sequence); if (created !== undefined) { const now = performance.now(); set_input_latency({...decoded, browser_created_time_ms: created, browser_observation_ms: Math.max(0, now - created)}); - if (decoded.stage === "frame_completed") - input_origins.current.delete(decoded.sequence); + input_origins.current.delete(decoded.sequence); } return; } @@ -620,7 +445,7 @@ function use_plot_stream(plot: Plot, }; return () => { stopped = true; - clear_diagnostics_timer(); + window.clearInterval(diagnostics_timer); window.removeEventListener("aethera-manual-frame", on_manual_frame); window.removeEventListener("aethera-reset-camera", on_camera_reset); window.removeEventListener("aethera-reset-frame-diagnostics", on_diagnostics_reset); @@ -630,7 +455,7 @@ function use_plot_stream(plot: Plot, socket_ref.current = null; socket.close(); }; - }, [plot.id, plot.websocket, plot.dimension, surface_ref, transmit]); + }, [plot.id, plot.websocket, plot.diagnostics, surface_ref, transmit]); useEffect(() => { const surface = surface_ref.current; @@ -789,7 +614,7 @@ function use_plot_stream(plot: Plot, surface.removeEventListener("contextmenu", on_context); }; }, [surface_ref, transmit]); - return {status, metrics, input_latency, error}; + return {status, metrics, server_diagnostics, input_latency, error}; } const member_labels: Record = {x: "横坐标", y: "纵坐标", z: "深度", width: "宽度", height: "高度", origin: "起点", target: "终点", @@ -1032,7 +857,7 @@ function Frame_Timeline_Chart({diagnostics, dimension, paused, on_context_menu}: const visible_samples = diagnostics.samples; const total_series: [string, string, string] = ["server_completion_ms", "服务端完成流水线", "#5ce4c2"]; - const pixel_delivery = diagnostics.metadata.delivery === "gallery-video"; + const pixel_delivery = diagnostics.server.delivery === "gallery-video"; const series_keys: Array<[string, string, string]> = dimension === "2D" ? [ total_series, ["pipeline_2d_prepare_ms", "2D Prepare", "#62a8ff"], @@ -1081,29 +906,37 @@ function Frame_Diagnostics_View({diagnostics, dimension, on_reset}: {diagnostics }, []); const displayed = paused ? snapshot : diagnostics; if (!displayed) return
等待流水线样本采样独立于图形面板可见性;收到第一帧后开始统计。
; - const copy = async () => { await navigator.clipboard.writeText(JSON.stringify(displayed.metadata, null, 2)); set_copied(true); window.setTimeout(() => set_copied(false), 1200); }; - const definitions = pipeline_definitions(dimension, displayed.metadata.delivery); + const copy = async () => { await navigator.clipboard.writeText(JSON.stringify(displayed.server, null, 2)); set_copied(true); window.setTimeout(() => set_copied(false), 1200); }; + const definitions = pipeline_definitions(dimension, displayed.server.delivery); const stage_values = definitions.map(([key, label, description]) => { - const history = displayed.samples.map(sample => sample.values[key]).filter(Number.isFinite); - return [key, label, description, stage_statistic(history, stage_statistic_mode)] as const; + const statistic = displayed.server.frame_statistics[key]; + return [key, label, description, statistic?.[stage_statistic_mode] ?? Number.NaN] as const; }); const total_value = stage_values.reduce((sum, [, , , value]) => sum + (Number.isFinite(value) ? value : 0), 0); const statistic_labels: Record = {average: "滑动平均", variability: "波动", p95: "P95", p99: "P99"}; - const pixel_delivery = displayed.metadata.delivery === "gallery-video"; + const pixel_delivery = displayed.server.delivery === "gallery-video"; const completion = "完成帧发布给页面图集"; + const server_completion = displayed.server.frame_statistics.server_completion_ms; + const frame_interval = displayed.server.frame_statistics.frame_interval_ms; + const input = displayed.server.input_statistics; + const poll_intervals = displayed.samples.slice(1).map((sample, index) => + sample.received_at_ms - displayed.samples[index].received_at_ms); const summaries: Array<[string, string, string]> = [ - ["逻辑完成闭环率", `${displayed.frame_rate_fps.toFixed(1)} FPS`, "根据 Plot 逻辑帧序号跨度和创建时间计算;历史回调共享同一 GPU 画面时仍会分别结清。真实画面率以卡片 Gallery 指标为准。"], - ["诊断采样频率", `${displayed.delivery_frame_rate_fps.toFixed(1)} FPS`, "Plot WebSocket 实际收到抽样诊断 JSON 的频率;视频帧率由页面级 Gallery 指标单独统计。"], + ["逻辑完成闭环率", `${displayed.server.frame_rate_fps.toFixed(1)} FPS`, "由服务端滑动帧间隔的 5% 裁剪平均计算;诊断请求频率不会改变该结果。"], + ["诊断请求间隔", `${average(poll_intervals).toFixed(0)} ms`, "浏览器独立 GET 服务端统计快照的实际平均间隔;默认一秒一次。"], ["浏览器解码帧率", `${displayed.video_playback.frame_rate_fps.toFixed(1)} FPS`, "由共享 WebRTC receiver 的 framesDecoded 增量统计;它不受显示器刷新率限制,可直接判断图集视频是否稳定收到一百帧。"], ["浏览器累计解码", displayed.video_playback.presented_frames.toLocaleString("zh-CN"), `共享 WebRTC receiver 已解码帧数;readyState=${displayed.video_playback.ready_state}。`], ["浏览器累计丢帧", displayed.video_playback.dropped_frames.toLocaleString("zh-CN"), "浏览器 inbound-rtp 统计报告的累计丢弃视频帧。"], - ["服务端完成平均", `${displayed.server_completion_average_ms.toFixed(2)} ms`, `服务端创建帧到${completion}的平均耗时;不包含独立的图集编码、WebRTC 和浏览器解码。`], - ["服务端完成 P50", `${displayed.server_completion_p50_ms.toFixed(2)} ms`, "一半样本不超过该服务端完成耗时。"], - ["服务端完成 P95", `${displayed.server_completion_p95_ms.toFixed(2)} ms`, "95% 样本不超过该服务端完成耗时,用于观察长尾。"], - ["服务端完成 P99", `${displayed.server_completion_p99_ms.toFixed(2)} ms`, "99% 样本不超过该服务端完成耗时,用于观察极端长尾。"], - ["帧间隔平均", `${displayed.frame_interval_average_ms.toFixed(2)} ms`, `相邻两次${completion}的平均间隔。`], - ["帧间隔抖动 P95", `${displayed.interval_jitter_p95_ms.toFixed(2)} ms`, "帧间隔相对中位数偏差的第 95 百分位。"], - ["诊断样本额外缺口", displayed.dropped_sequence_count.toLocaleString("zh-CN"), "扣除正常抽样步长后,诊断序列仍缺少的页面时钟 tick 数;不等同于视频丢帧。"] + ["服务端完成平均", `${(server_completion?.average ?? 0).toFixed(2)} ms`, `服务端滑动窗口中从创建帧到${completion}的平均耗时。`], + ["服务端完成裁剪平均", `${(server_completion?.trimmed_average ?? 0).toFixed(2)} ms`, "服务端舍弃窗口两端各 5% 极端样本后的平均耗时。"], + ["服务端完成 P50", `${(server_completion?.p50 ?? 0).toFixed(2)} ms`, "一半样本不超过该服务端完成耗时。"], + ["服务端完成 P95", `${(server_completion?.p95 ?? 0).toFixed(2)} ms`, "95% 样本不超过该服务端完成耗时,用于观察长尾。"], + ["服务端完成 P99", `${(server_completion?.p99 ?? 0).toFixed(2)} ms`, "99% 样本不超过该服务端完成耗时,用于观察极端长尾。"], + ["帧间隔平均", `${(frame_interval?.average ?? 0).toFixed(2)} ms`, `服务端相邻两次${completion}的滑动平均间隔。`], + ["帧间隔波动", `${(frame_interval?.variability ?? 0).toFixed(2)} ms`, "服务端帧间隔窗口的总体标准差。"], + ["服务端事件闭环 P95", `${(input.input_server_total_ms?.p95 ?? 0).toFixed(2)} ms`, "浏览器事件抵达服务端到对应画面完成的服务端 P95;浏览器创建和显示阶段不在此值中。"], + ["Scene 等帧 P95", `${(input.input_scene_queue_ms?.p95 ?? 0).toFixed(2)} ms`, "事件进入 Scene 后等待下一次 Prepare 消费的服务端 P95。"], + ["逻辑序列缺口", displayed.server.dropped_sequence_count.toLocaleString("zh-CN"), "服务端滑动统计期间未完成的逻辑帧序号数量。"] ]; const open_context_menu = (event: React.MouseEvent) => { event.preventDefault(); @@ -1116,10 +949,10 @@ function Frame_Diagnostics_View({diagnostics, dimension, on_reset}: {diagnostics }; const reset = () => { set_paused(false); set_snapshot(null); set_context_menu(null); on_reset(); }; return
-
当前为{pixel_delivery ? "WebRTC H.264 视频" : "后台诊断"}模式,区间内 {displayed.samples.length} 帧;图表右键可暂停并缩放查看。{pixel_delivery ? "视频轨独立于 WebSocket 信令与诊断元数据。" : "后台诊断仍执行真实渲染,但跳过 H.264 编码与视频发送。"}
+
当前为{pixel_delivery ? "WebRTC H.264 视频" : "后台诊断"}模式,浏览器保留 {displayed.samples.length} 个低频快照点;服务端窗口含 {server_completion?.count ?? 0} 个真实帧样本。{pixel_delivery ? "视频轨与按需诊断 GET 相互独立。" : "后台诊断仍执行真实渲染,但跳过 H.264 编码与视频发送。"}
{summaries.map(([label, value, description]) =>
{label}
{value}
)}
-
流水线阶段统计#{displayed.metadata.sequence} / 时钟 {displayed.metadata.correlation_id}
+
流水线阶段统计#{displayed.server.sequence} / 时钟 {displayed.server.correlation_id}
{(["average", "variability", "p95", "p99"] as Stage_Statistic[]).map(mode => )}
@@ -1130,8 +963,8 @@ function Frame_Diagnostics_View({diagnostics, dimension, on_reset}: {diagnostics
{stage_values.map(([key, label, description, value]) =>
{label}
{!Number.isFinite(value) ? "--" : stage_unit === "percentage" ? `${(total_value > 0 ? value / total_value * 100 : 0).toFixed(1)}%` : diagnostic_value(value, key)}
)}
-
最新帧原始诊断 JSON
协议 v{displayed.metadata.version}
-
{JSON.stringify(displayed.metadata, null, 2)}
+
服务端统计 JSON
协议 v{displayed.server.version}
+
{JSON.stringify(displayed.server, null, 2)}
{context_menu ?
event.stopPropagation()}> @@ -1260,6 +1093,7 @@ const Plot_Card = memo(function Plot_Card({plot, selected, policy, gallery, on_p const status: Stream_Status = graph.status === "OFFLINE" || gallery.status === "OFFLINE" ? "OFFLINE" : graph.status === "LIVE" && gallery.status === "LIVE" ? "LIVE" : "CONNECTING"; const metrics = graph.metrics; + const server_input = graph.server_diagnostics?.input_statistics; const source_transport = gallery.transport?.sources[plot.id] ?? null; const encoder_label = gallery.transport?.encoder_backend === "nvenc" ? "NVENC" : gallery.transport?.encoder_backend === "vulkan_video" @@ -1273,7 +1107,7 @@ const Plot_Card = memo(function Plot_Card({plot, selected, policy, gallery, on_p useEffect(() => { const input = graph.input_latency; const video = video_ref.current; - if (!input || input.stage !== "frame_completed" || !video || + if (!input || !video || typeof video.requestVideoFrameCallback !== "function") return; const callback = video.requestVideoFrameCallback(() => { set_input_visible_latency(Math.max( @@ -1289,11 +1123,11 @@ const Plot_Card = memo(function Plot_Card({plot, selected, policy, gallery, on_p } : {}; const generated_time = metrics ? new Date(metrics.generated_time_unix_ms).toLocaleTimeString("zh-CN", {hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, fractionalSecondDigits: 3}) : "尚未生成帧"; return
on_select(plot)}> @@ -1310,9 +1144,9 @@ const Plot_Card = memo(function Plot_Card({plot, selected, policy, gallery, on_p {encoder_label} {gallery.transport ? gallery.transport.encode_average_ms.toFixed(1) : "--.-"} ms 落后 {source_transport?.has_rendered_frame ? source_transport.clock_lag_ticks : "--"} tick {plot.dimension === "3D" ? <> - 事件准入 {graph.input_latency ? graph.input_latency.plot_queue_ms.toFixed(1) : "--.-"} ms - Scene 等帧 {graph.input_latency ? graph.input_latency.scene_queue_ms.toFixed(1) : "--.-"} ms - 后端应用 {graph.input_latency ? graph.input_latency.backend_queue_ms.toFixed(1) : "--.-"} ms + 事件准入 P95 {server_input?.input_plot_queue_ms ? server_input.input_plot_queue_ms.p95.toFixed(1) : "--.-"} ms + Scene 等帧 P95 {server_input?.input_scene_queue_ms ? server_input.input_scene_queue_ms.p95.toFixed(1) : "--.-"} ms + 后端应用 P95 {server_input?.input_backend_queue_ms ? server_input.input_backend_queue_ms.p95.toFixed(1) : "--.-"} ms 操作闭环 {graph.input_latency ? graph.input_latency.browser_observation_ms.toFixed(1) : "--.-"} ms 可见画面 {input_visible_latency !== null ? input_visible_latency.toFixed(1) : "--.-"} ms 合并 {graph.input_latency?.coalesced_event_count ?? 0} @@ -1321,9 +1155,9 @@ const Plot_Card = memo(function Plot_Card({plot, selected, policy, gallery, on_p
{plot.dimension === "2D" ?
- 事件准入 {graph.input_latency ? graph.input_latency.plot_queue_ms.toFixed(1) : "--.-"} ms - Scene 等帧 {graph.input_latency ? graph.input_latency.scene_queue_ms.toFixed(1) : "--.-"} ms - 事件处理 {graph.input_latency ? graph.input_latency.backend_queue_ms.toFixed(1) : "--.-"} ms + 事件准入 P95 {server_input?.input_plot_queue_ms ? server_input.input_plot_queue_ms.p95.toFixed(1) : "--.-"} ms + Scene 等帧 P95 {server_input?.input_scene_queue_ms ? server_input.input_scene_queue_ms.p95.toFixed(1) : "--.-"} ms + 事件处理 P95 {server_input?.input_backend_queue_ms ? server_input.input_backend_queue_ms.p95.toFixed(1) : "--.-"} ms 操作闭环 {graph.input_latency ? graph.input_latency.browser_observation_ms.toFixed(1) : "--.-"} ms 可见画面 {input_visible_latency !== null ? input_visible_latency.toFixed(1) : "--.-"} ms 合并 {graph.input_latency?.coalesced_event_count ?? 0}