diff --git a/Kernel/src/renderive/base/statistics/Rolling_Statistics.hpp b/Kernel/src/renderive/base/statistics/Rolling_Statistics.hpp new file mode 100644 index 0000000..57d6029 --- /dev/null +++ b/Kernel/src/renderive/base/statistics/Rolling_Statistics.hpp @@ -0,0 +1,76 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +struct Rolling_Statistics_Snapshot { + std::size_t sample_count{}; + double average{}; + double deviation{}; + double p50{}; + double p95{}; + double p99{}; + double minimum{}; + double maximum{}; +}; + +class Rolling_Statistics final { +public: + explicit Rolling_Statistics(std::size_t capacity) : capacity_(capacity) {} + + void add(double value) { + if (!std::isfinite(value) || capacity_ == 0) + return; + samples_.push_back(value); + while (samples_.size() > capacity_) + samples_.pop_front(); + } + + void replace_latest(double value) { + if (!std::isfinite(value) || samples_.empty()) + return; + samples_.back() = value; + } + + void clear() noexcept { + samples_.clear(); + } + + [[nodiscard]] Rolling_Statistics_Snapshot snapshot() const { + if (samples_.empty()) + return {}; + std::vector sorted(samples_.begin(), samples_.end()); + std::ranges::sort(sorted); + const double average = + std::accumulate(sorted.begin(), sorted.end(), 0.0) / + static_cast(sorted.size()); + double squared_deviation{}; + for (const double sample : sorted) { + const double difference = sample - average; + squared_deviation += difference * difference; + } + const auto percentile = [&sorted](double ratio) { + const auto rank = static_cast( + std::ceil(ratio * static_cast(sorted.size()))); + return sorted[std::clamp(rank, 1, sorted.size()) - 1]; + }; + return { + sorted.size(), + average, + std::sqrt(squared_deviation / static_cast(sorted.size())), + percentile(0.50), + percentile(0.95), + percentile(0.99), + sorted.front(), + sorted.back() + }; + } + +private: + std::size_t capacity_; + std::deque samples_; +}; diff --git a/Kernel/src/renderive/renderable/base/Renderable_Task_Observer.hpp b/Kernel/src/renderive/renderable/base/Renderable_Task_Observer.hpp new file mode 100644 index 0000000..4a9a0be --- /dev/null +++ b/Kernel/src/renderive/renderable/base/Renderable_Task_Observer.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include +#include + +enum class Renderable_Task_Observation_Event { + started, + completed, + failed +}; + +struct Renderable_Task_Observation { + Renderable_Task_Observation_Event event{}; + std::uint64_t time_ns{}; + std::uint64_t render_sequence{}; + std::size_t task_count{}; + std::uint64_t task_duration_ns{}; +}; + +class Renderable_Task_Observer { +public: + virtual ~Renderable_Task_Observer() = default; + virtual void observe_task(const Renderable_Task_Observation& observation) noexcept = 0; + virtual void reset_task_observation() noexcept = 0; +}; diff --git a/Kernel/src/renderive/scene/base/Scene_Base.cpp b/Kernel/src/renderive/scene/base/Scene_Base.cpp index f8eb0b3..f071bb6 100644 --- a/Kernel/src/renderive/scene/base/Scene_Base.cpp +++ b/Kernel/src/renderive/scene/base/Scene_Base.cpp @@ -9,6 +9,7 @@ #include #include #include "renderive/renderable/color/Color_Cache.hpp" +#include "renderive/renderable/base/Renderable_Task_Observer.hpp" #include "renderive/state/base/State_Strategy_Base.hpp" static_assert(TF_VERSION == 400100, "Renderive requires Taskflow 4.1.0"); namespace { @@ -562,12 +563,51 @@ void Scene_Base::execute_taskflow(Render_Task& task) { const Scene_Render_Context context{this, task.frame_control_state, task.scene_state_revision, task.render_sequence, &renderable, color_cache}; const auto task_graph = renderable.task_graph(); const auto& nodes = task_graph->nodes(); + auto* task_observer = dynamic_cast(&renderable); + const std::size_t renderable_task_count = nodes.size(); std::pmr::vector internal_tasks(&scratch_resource); internal_tasks.reserve(nodes.size()); for (const auto& node : nodes) { - internal_tasks.push_back(module.graph.emplace(tracked([this, function = node.function, context] { + internal_tasks.push_back(module.graph.emplace(tracked([ + this, function = node.function, context, task_observer, + renderable_task_count + ] { + const auto started_ns = steady_now_ns(); + if (task_observer) { + task_observer->observe_task({ + Renderable_Task_Observation_Event::started, + started_ns, + context.render_sequence, + renderable_task_count, + 0 + }); + } Render_Execution_Scope scope(*this); - function(context); + try { + function(context); + } catch (...) { + const auto finished_ns = steady_now_ns(); + if (task_observer) { + task_observer->observe_task({ + Renderable_Task_Observation_Event::failed, + finished_ns, + context.render_sequence, + renderable_task_count, + finished_ns >= started_ns ? finished_ns - started_ns : 0 + }); + } + throw; + } + const auto finished_ns = steady_now_ns(); + if (task_observer) { + task_observer->observe_task({ + Renderable_Task_Observation_Event::completed, + finished_ns, + context.render_sequence, + renderable_task_count, + finished_ns >= started_ns ? finished_ns - started_ns : 0 + }); + } })).name(std::string(node.name))); } for (std::size_t node_index = 0; node_index < nodes.size(); ++node_index) { @@ -624,6 +664,14 @@ void Scene_Base::execute_taskflow(Render_Task& task) { } finish_task_execution(); } +void Scene_Base::reset_renderable_task_observations() noexcept { + auto idle = lock_render_idle(); + std::lock_guard renderables_lock(renderable_mutex_); + for (const auto& renderable : *cache_renderables_) { + if (auto* observer = dynamic_cast(renderable.get())) + observer->reset_task_observation(); + } +} void Scene_Base::begin_task_execution(std::uint64_t render_sequence) noexcept { task_execution_render_sequence_.store(render_sequence, std::memory_order_release); task_execution_node_count_.store(0, std::memory_order_release); diff --git a/Kernel/src/renderive/scene/base/Scene_Base.hpp b/Kernel/src/renderive/scene/base/Scene_Base.hpp index 22fb814..cb73fc2 100644 --- a/Kernel/src/renderive/scene/base/Scene_Base.hpp +++ b/Kernel/src/renderive/scene/base/Scene_Base.hpp @@ -68,6 +68,7 @@ public: Topology_Snapshot topology_snapshot() const; std::vector paint_order_snapshot() const; [[nodiscard]] Task_Execution_Snapshot task_execution_snapshot() const noexcept; + void reset_renderable_task_observations() noexcept; [[nodiscard]] static std::size_t task_executor_worker_count() noexcept; std::pmr::memory_resource& memory_resource() const noexcept; std::pmr::memory_resource& upstream_memory_resource() const noexcept; diff --git a/Kernel/tests/renderive/base/statistics/Rolling_Statistics_Test.cpp b/Kernel/tests/renderive/base/statistics/Rolling_Statistics_Test.cpp new file mode 100644 index 0000000..b5d15f2 --- /dev/null +++ b/Kernel/tests/renderive/base/statistics/Rolling_Statistics_Test.cpp @@ -0,0 +1,38 @@ +#include + +#include + +TEST(RollingStatistics, ComputesWindowDistributionAndEvictsOldSamples) { + Rolling_Statistics statistics(4); + for (const double sample : {1.0, 2.0, 3.0, 10.0}) + statistics.add(sample); + + auto snapshot = statistics.snapshot(); + EXPECT_EQ(snapshot.sample_count, 4U); + EXPECT_DOUBLE_EQ(snapshot.average, 4.0); + EXPECT_DOUBLE_EQ(snapshot.p50, 2.0); + EXPECT_DOUBLE_EQ(snapshot.p95, 10.0); + EXPECT_DOUBLE_EQ(snapshot.p99, 10.0); + EXPECT_DOUBLE_EQ(snapshot.minimum, 1.0); + EXPECT_DOUBLE_EQ(snapshot.maximum, 10.0); + EXPECT_NEAR(snapshot.deviation, 3.5355339059, 1e-9); + + statistics.add(4.0); + snapshot = statistics.snapshot(); + EXPECT_EQ(snapshot.sample_count, 4U); + EXPECT_DOUBLE_EQ(snapshot.minimum, 2.0); + EXPECT_DOUBLE_EQ(snapshot.average, 4.75); +} + +TEST(RollingStatistics, ReplacesActiveSampleAndClearsTheWindow) { + Rolling_Statistics statistics(3); + statistics.add(5.0); + statistics.replace_latest(8.0); + EXPECT_DOUBLE_EQ(statistics.snapshot().p99, 8.0); + + statistics.clear(); + const auto snapshot = statistics.snapshot(); + EXPECT_EQ(snapshot.sample_count, 0U); + EXPECT_DOUBLE_EQ(snapshot.average, 0.0); + EXPECT_DOUBLE_EQ(snapshot.p99, 0.0); +} diff --git a/render_2D/plottable/Afterglow.cpp b/render_2D/plottable/Afterglow.cpp index aa6e9c4..8113f08 100644 --- a/render_2D/plottable/Afterglow.cpp +++ b/render_2D/plottable/Afterglow.cpp @@ -67,7 +67,7 @@ void Afterglow_Control::publish() { void Afterglow_Control::build_paint_task_graph(Renderable_Task_Graph& graph) { auto& output = impl_->render_frame; const auto view = render_state_view(); - const auto& state = render_properties(view); + const auto state = properties(); const auto& history = view.get(impl_->history); const int width = history.empty() ? 0 @@ -84,7 +84,7 @@ void Afterglow_Control::build_paint_task_graph(Renderable_Task_Graph& graph) { static_cast(Scene_Base::task_executor_worker_count()), work_size, 4096); const auto prepare = graph.emplace([this, partition_count](const Scene_Render_Context&) { prepare_render_frame(render_state_view(), partition_count); - }, "prepare afterglow"); + }, "准备余晖图"); std::vector accumulation; accumulation.reserve(static_cast(partition_count)); for (int index = 0; index < partition_count; ++index) { @@ -92,13 +92,13 @@ void Afterglow_Control::build_paint_task_graph(Renderable_Task_Graph& graph) { [this, index](const Scene_Render_Context&) { accumulate_partition(render_state_view(), index); }, - "accumulate afterglow partition " + std::to_string(index + 1)); + "累积余晖图分区 " + std::to_string(index + 1)); graph.precede(prepare, task); accumulation.push_back(task); } const auto normalize = graph.emplace([this](const Scene_Render_Context&) { normalize_render_frame(); - }, "normalize afterglow"); + }, "归一化余晖图"); for (const auto task : accumulation) graph.precede(task, normalize); std::vector coloring; @@ -108,12 +108,12 @@ void Afterglow_Control::build_paint_task_graph(Renderable_Task_Graph& graph) { [this, index](const Scene_Render_Context&) { color_partition(render_state_view(), index); }, - "color afterglow partition " + std::to_string(index + 1)); + "着色余晖图分区 " + std::to_string(index + 1)); graph.precede(normalize, task); coloring.push_back(task); } const auto paint_image = add_paint_task( - graph, "paint afterglow shared image", + graph, "绘制余晖图共享图像", [this](Painter& painter, const Render_State_View& frame_view, const Scene_Render_Context& context) { paint_render_frame(painter, frame_view, diff --git a/render_2D/plottable/Plottable.h b/render_2D/plottable/Plottable.h index bf7bec5..2c8fd7e 100644 --- a/render_2D/plottable/Plottable.h +++ b/render_2D/plottable/Plottable.h @@ -52,11 +52,19 @@ namespace detail { class Paint_Overlay {}; template -class Plottable_State : public Double_State_Strategy { +class Plottable_State + : public Double_State_Strategy> { public: using Properties = Properties_Type; - using Base = Double_State_Strategy; - Plottable_State(Plot_Core& plot, const Properties& properties) : Base(properties, plot) {} + using State_Observer = Observer_State; + using Base = Double_State_Strategy; + Plottable_State(Plot_Core& plot, const Properties& properties) + : Base(properties, + With_Observer{ + State_Observer(Renderable_State_Observer(this))}, + plot) {} template Value> Plottable_State& set(Value&& value) { Base::template set(std::forward(value)); @@ -76,7 +84,6 @@ public: } } } - this->changed(); return *this; } template diff --git a/render_2D/plottable/Spectrum.cpp b/render_2D/plottable/Spectrum.cpp index ff7c309..f4bef5f 100644 --- a/render_2D/plottable/Spectrum.cpp +++ b/render_2D/plottable/Spectrum.cpp @@ -66,11 +66,20 @@ void draw_curve(Painter& painter, std::span values, Range domain, if(brush.enabled()) { std::vector polygon; polygon.reserve(points.size() + 2); - polygon.push_back(mapped_point(frequency_axis, domain.origin, power_axis, - power_axis.coordinate_range.target)); + PointF first_baseline = points.front(); + PointF last_baseline = points.back(); + const double baseline = + power_axis.coord_to_pixel(power_axis.coordinate_range.target); + if (power_axis.orientation == Orientation::Horizontal) { + first_baseline.x = baseline; + last_baseline.x = baseline; + } else { + first_baseline.y = baseline; + last_baseline.y = baseline; + } + polygon.push_back(first_baseline); polygon.insert(polygon.end(), points.begin(), points.end()); - polygon.push_back(mapped_point(frequency_axis, domain.target, power_axis, - power_axis.coordinate_range.target)); + polygon.push_back(last_baseline); painter.polygon(polygon, Pen{.style = Line_Style::None}, brush); } painter.polyline(points, pen); @@ -244,14 +253,14 @@ void Spectrum_Control::publish() { void Spectrum_Control::build_paint_task_graph(Renderable_Task_Graph& graph) { auto& output = impl_->render_frame; const auto view = render_state_view(); - const auto& state = render_properties(view); + const auto state = properties(); const auto& published_frame = view.get(impl_->frame); const std::size_t work_size = published_frame ? published_frame->samples.size() : 0; const int partition_count = output.partitioner.graph_partition_count( state.partition_mode, state.partition_count.get(), static_cast(Scene_Base::task_executor_worker_count()), work_size, 128); const auto prepare = add_paint_task( - graph, "prepare spectrum and paint background", + graph, "准备频谱并绘制背景", [this, partition_count](Painter& painter, const Render_State_View& frame_view, const Scene_Render_Context&) { prepare_render_frame(frame_view, partition_count); @@ -261,7 +270,7 @@ void Spectrum_Control::build_paint_task_graph(Renderable_Task_Graph& graph) { partitions.reserve(static_cast(partition_count)); for (int index = 0; index < partition_count; ++index) { const auto partition = add_paint_task( - graph, "paint spectrum partition " + std::to_string(index + 1), + graph, "绘制频谱分区 " + std::to_string(index + 1), [this, index](Painter& painter, const Render_State_View& frame_view, const Scene_Render_Context&) { render_partition(painter, frame_view, index); @@ -270,7 +279,7 @@ void Spectrum_Control::build_paint_task_graph(Renderable_Task_Graph& graph) { partitions.push_back(partition); } const auto overlay = add_paint_task( - graph, "paint spectrum overlay", + graph, "绘制频谱覆盖层", [this](Painter& painter, const Render_State_View& frame_view, const Scene_Render_Context& context) { paint_overlay(painter, frame_view, diff --git a/render_2D/plottable/Waterfall.cpp b/render_2D/plottable/Waterfall.cpp index 79fd13f..eb68fb7 100644 --- a/render_2D/plottable/Waterfall.cpp +++ b/render_2D/plottable/Waterfall.cpp @@ -99,7 +99,7 @@ void Waterfall_Control::publish() { void Waterfall_Control::build_paint_task_graph(Renderable_Task_Graph& graph) { auto& output = impl_->render_frame; const auto view = render_state_view(); - const auto& state = render_properties(view); + const auto state = properties(); const auto& rows = view.get(impl_->rows); std::size_t work_size{}; if (!rows.empty()) { @@ -117,7 +117,7 @@ void Waterfall_Control::build_paint_task_graph(Renderable_Task_Graph& graph) { static_cast(Scene_Base::task_executor_worker_count()), work_size, 4096); const auto prepare = graph.emplace([this, partition_count](const Scene_Render_Context&) { prepare_render_frame(render_state_view(), partition_count); - }, "prepare waterfall"); + }, "准备瀑布图"); std::vector partitions; partitions.reserve(static_cast(partition_count)); for (int index = 0; index < partition_count; ++index) { @@ -125,12 +125,12 @@ void Waterfall_Control::build_paint_task_graph(Renderable_Task_Graph& graph) { [this, index](const Scene_Render_Context&) { render_partition(render_state_view(), index); }, - "raster waterfall partition " + std::to_string(index + 1)); + "光栅化瀑布图分区 " + std::to_string(index + 1)); graph.precede(prepare, partition); partitions.push_back(partition); } const auto paint_image = add_paint_task( - graph, "paint waterfall shared image", + graph, "绘制瀑布图共享图像", [this](Painter& painter, const Render_State_View& frame_view, const Scene_Render_Context& context) { paint_render_frame(painter, frame_view, diff --git a/render_2D/renderable/Renderable.cpp b/render_2D/renderable/Renderable.cpp index 4f0d96d..4911edc 100644 --- a/render_2D/renderable/Renderable.cpp +++ b/render_2D/renderable/Renderable.cpp @@ -5,8 +5,156 @@ #include +#include +#include + namespace renderive { +Renderable_Observation Renderable_Observer::observation() const noexcept { + std::lock_guard lock(mutex_); + auto result = observation_; + const auto render = render_duration_statistics_.snapshot(); + const auto task = task_duration_statistics_.snapshot(); + const auto nanoseconds = [](double value) { + return static_cast(std::max(0.0, std::round(value))); + }; + result.render_duration_sample_count = render.sample_count; + result.render_duration_average_ns = nanoseconds(render.average); + result.render_duration_deviation_ns = nanoseconds(render.deviation); + result.render_duration_p50_ns = nanoseconds(render.p50); + result.render_duration_p95_ns = nanoseconds(render.p95); + result.render_duration_p99_ns = nanoseconds(render.p99); + result.task_duration_sample_count = task.sample_count; + result.task_duration_average_ns = nanoseconds(task.average); + result.task_duration_deviation_ns = nanoseconds(task.deviation); + result.task_duration_p50_ns = nanoseconds(task.p50); + result.task_duration_p95_ns = nanoseconds(task.p95); + result.task_duration_p99_ns = nanoseconds(task.p99); + return result; +} + +void Renderable_Observer::observe_state(Renderable_Observer_Event event, + std::uint64_t time_ns, + std::uint64_t cache_update_count, + std::uint64_t publish_count) noexcept { + std::lock_guard lock(mutex_); + observation_.event = event; + observation_.event_time_ns = time_ns; + observation_.cache_update_count = cache_update_count; + observation_.publish_count = publish_count; +} + +void Renderable_Observer::observe_task( + const Renderable_Task_Observation& task) noexcept { + std::lock_guard lock(mutex_); + if (task.event == Renderable_Task_Observation_Event::started) { + if (active_render_sequence_ != task.render_sequence) { + active_render_sequence_ = task.render_sequence; + active_render_started_ns_ = task.time_ns; + active_task_count_ = task.task_count; + completed_task_count_ = 0; + running_task_count_ = 0; + active_peak_parallelism_ = 0; + active_render_failed_ = false; + } + ++running_task_count_; + ++observation_.task_execution_count; + active_peak_parallelism_ = + std::max(active_peak_parallelism_, running_task_count_); + if (!active_render_failed_) { + observation_.event = Renderable_Observer_Event::Render_Started; + observation_.event_time_ns = task.time_ns; + } + observation_.last_render_sequence = task.render_sequence; + return; + } + + if (active_render_sequence_ != task.render_sequence) + return; + if (running_task_count_ != 0) + --running_task_count_; + ++completed_task_count_; + observation_.last_task_duration_ns = task.task_duration_ns; + observation_.total_task_duration_ns += task.task_duration_ns; + observation_.maximum_task_duration_ns = + std::max(observation_.maximum_task_duration_ns, task.task_duration_ns); + task_duration_statistics_.add(static_cast(task.task_duration_ns)); + + const auto render_duration = + task.time_ns >= active_render_started_ns_ + ? task.time_ns - active_render_started_ns_ + : 0; + const auto extend_failed_render_duration = [&] { + if (render_duration > observation_.last_render_duration_ns) { + observation_.total_render_duration_ns += + render_duration - observation_.last_render_duration_ns; + observation_.last_render_duration_ns = render_duration; + render_duration_statistics_.replace_latest( + static_cast(render_duration)); + observation_.maximum_render_duration_ns = + std::max(observation_.maximum_render_duration_ns, render_duration); + } + }; + if (task.event == Renderable_Task_Observation_Event::failed) { + ++observation_.failed_task_count; + if (!active_render_failed_) { + active_render_failed_ = true; + ++observation_.failed_render_count; + observation_.event = Renderable_Observer_Event::Render_Failed; + observation_.event_time_ns = task.time_ns; + observation_.last_render_duration_ns = render_duration; + observation_.total_render_duration_ns += render_duration; + render_duration_statistics_.add(static_cast(render_duration)); + observation_.maximum_render_duration_ns = + std::max(observation_.maximum_render_duration_ns, render_duration); + observation_.last_task_count = active_task_count_; + observation_.peak_parallelism = active_peak_parallelism_; + } else + extend_failed_render_duration(); + return; + } + + if (active_render_failed_) { + extend_failed_render_duration(); + return; + } + + if (completed_task_count_ == active_task_count_) { + ++observation_.successful_render_count; + observation_.event = Renderable_Observer_Event::Render_Completed; + observation_.event_time_ns = task.time_ns; + observation_.last_render_duration_ns = render_duration; + observation_.total_render_duration_ns += render_duration; + render_duration_statistics_.add(static_cast(render_duration)); + observation_.maximum_render_duration_ns = + std::max(observation_.maximum_render_duration_ns, render_duration); + observation_.last_task_count = active_task_count_; + observation_.peak_parallelism = active_peak_parallelism_; + } +} + +void Renderable_Observer::reset_performance() noexcept { + std::lock_guard lock(mutex_); + Renderable_Observation reset; + if (observation_.event == Renderable_Observer_Event::Cache_Updated || + observation_.event == Renderable_Observer_Event::Published) { + reset.event = observation_.event; + reset.event_time_ns = observation_.event_time_ns; + } + reset.cache_update_count = observation_.cache_update_count; + reset.publish_count = observation_.publish_count; + observation_ = reset; + active_render_sequence_ = 0; + active_render_started_ns_ = 0; + active_task_count_ = 0; + completed_task_count_ = 0; + running_task_count_ = 0; + active_peak_parallelism_ = 0; + active_render_failed_ = false; + render_duration_statistics_.clear(); + task_duration_statistics_.clear(); +} + Renderable::Renderable(Plot_Core& plot, bool cache_enabled) : ::Renderable_Base(plot.kernel_scene(), {.cache_enabled = cache_enabled}), plot_(plot) {} @@ -41,6 +189,26 @@ void Renderable::set_visible(bool visible) { changed(); } +Renderable_Observation Renderable::observation() const noexcept { + return observer_.observation(); +} + +void Renderable::observe_state(Renderable_Observer_Event event, + std::uint64_t time_ns, + std::uint64_t cache_update_count, + std::uint64_t publish_count) noexcept { + observer_.observe_state(event, time_ns, cache_update_count, publish_count); +} + +void Renderable::observe_task( + const Renderable_Task_Observation& observation) noexcept { + observer_.observe_task(observation); +} + +void Renderable::reset_task_observation() noexcept { + observer_.reset_performance(); +} + void Renderable::render(const ::Scene_Render_Context& context) { if (!is_visible() || context.color_cache == nullptr) return; @@ -59,7 +227,7 @@ void Renderable::build_task_graph(Renderable_Task_Graph& graph) { } void Renderable::build_paint_task_graph(Renderable_Task_Graph& graph) { - add_paint_task(graph, "paint", [this](detail::Painter& painter, + add_paint_task(graph, "绘制", [this](detail::Painter& painter, const Render_State_View& state, const Scene_Render_Context&) { paint(painter, state); diff --git a/render_2D/renderable/Renderable.h b/render_2D/renderable/Renderable.h index 46f37f4..e7167be 100644 --- a/render_2D/renderable/Renderable.h +++ b/render_2D/renderable/Renderable.h @@ -4,6 +4,8 @@ #include "../event/Event.h" #include +#include +#include #include #include @@ -24,7 +26,72 @@ public: virtual void handle_event(const Event& event) = 0; }; -class LIB_DECL Renderable : public ::Renderable_Base { +enum class Renderable_Observer_Event { + None, + Cache_Updated, + Published, + Render_Started, + Render_Completed, + Render_Failed +}; + +struct Renderable_Observation { + Renderable_Observer_Event event{Renderable_Observer_Event::None}; + std::uint64_t event_time_ns{}; + std::uint64_t cache_update_count{}; + std::uint64_t publish_count{}; + std::uint64_t successful_render_count{}; + std::uint64_t failed_render_count{}; + std::uint64_t last_render_sequence{}; + std::uint64_t last_render_duration_ns{}; + std::uint64_t total_render_duration_ns{}; + std::uint64_t maximum_render_duration_ns{}; + std::size_t render_duration_sample_count{}; + std::uint64_t render_duration_average_ns{}; + std::uint64_t render_duration_deviation_ns{}; + std::uint64_t render_duration_p50_ns{}; + std::uint64_t render_duration_p95_ns{}; + std::uint64_t render_duration_p99_ns{}; + std::uint64_t task_execution_count{}; + std::uint64_t failed_task_count{}; + std::uint64_t last_task_duration_ns{}; + std::uint64_t total_task_duration_ns{}; + std::uint64_t maximum_task_duration_ns{}; + std::size_t task_duration_sample_count{}; + std::uint64_t task_duration_average_ns{}; + std::uint64_t task_duration_deviation_ns{}; + std::uint64_t task_duration_p50_ns{}; + std::uint64_t task_duration_p95_ns{}; + std::uint64_t task_duration_p99_ns{}; + std::size_t last_task_count{}; + std::size_t peak_parallelism{}; +}; + +class LIB_DECL Renderable_Observer final { +public: + [[nodiscard]] Renderable_Observation observation() const noexcept; + void observe_state(Renderable_Observer_Event event, std::uint64_t time_ns, + std::uint64_t cache_update_count, + std::uint64_t publish_count) noexcept; + void observe_task(const Renderable_Task_Observation& observation) noexcept; + void reset_performance() noexcept; + +private: + mutable std::mutex mutex_; + Renderable_Observation observation_; + std::uint64_t active_render_sequence_{}; + std::uint64_t active_render_started_ns_{}; + std::size_t active_task_count_{}; + std::size_t completed_task_count_{}; + std::size_t running_task_count_{}; + std::size_t active_peak_parallelism_{}; + bool active_render_failed_{}; + Rolling_Statistics render_duration_statistics_{256}; + Rolling_Statistics task_duration_statistics_{1024}; +}; + +class LIB_DECL Renderable : public ::Renderable_Base, + public ::Renderable_Task_Observer { public: explicit Renderable(Plot_Core& plot, bool cache_enabled = true); ~Renderable() override; @@ -37,8 +104,10 @@ public: void set_object_name(std::string name); [[nodiscard]] bool is_visible() const noexcept; void set_visible(bool visible); + [[nodiscard]] Renderable_Observation observation() const noexcept; void render(const ::Scene_Render_Context& context) final; + void observe_task(const Renderable_Task_Observation& observation) noexcept final; protected: using Paint_Task_Function = @@ -56,11 +125,16 @@ protected: private: void build_task_graph(Renderable_Task_Graph& graph) final; + void reset_task_observation() noexcept final; + void observe_state(Renderable_Observer_Event event, std::uint64_t time_ns, + std::uint64_t cache_update_count, + std::uint64_t publish_count) noexcept; friend class detail::Renderable_State_Observer; Plot_Core& plot_; mutable std::mutex metadata_mutex_; std::string object_name_; std::atomic visible_{true}; + Renderable_Observer observer_; }; namespace detail { @@ -73,7 +147,16 @@ public: template void observe(const Observation& observation) noexcept { - if (observation.event == decltype(observation.event)::cache_updated) + const bool cache_updated = + observation.event == decltype(observation.event)::cache_updated; + renderable_->observe_state( + cache_updated ? Renderable_Observer_Event::Cache_Updated + : Renderable_Observer_Event::Published, + observation.time_ns, + observation.cache_update_count, + observation.publish_count + ); + if (cache_updated) renderable_->changed(); } diff --git a/render_2D/tests/render_2D_Integration_Tests.cpp b/render_2D/tests/render_2D_Integration_Tests.cpp index ccacd9c..be759a5 100644 --- a/render_2D/tests/render_2D_Integration_Tests.cpp +++ b/render_2D/tests/render_2D_Integration_Tests.cpp @@ -589,6 +589,88 @@ TEST(Renderive_Core2, PlottablePropertiesPublishOnlyAtFrameBoundary) { EXPECT_EQ(state->state_revision(), 1U); ASSERT_TRUE(plot.render_prepared_frame()); } +TEST(Renderive_Core2, EveryStatefulRenderableOwnsItsStateObserver) { + Plot_Core plot; + plot.init(); + const auto root = plot.root_renderable(); + const auto frequency = Frequency_Axis::Builder(root, Orientation::Horizontal).build(); + const auto power = Axis::Builder(root, Orientation::Vertical).build(); + const auto spectrum = Spectrum::Builder{}.build(root, frequency, power); + ASSERT_TRUE(frequency); + ASSERT_TRUE(power); + ASSERT_TRUE(spectrum); + + EXPECT_EQ(frequency->observation().event, Renderable_Observer_Event::None); + EXPECT_EQ(power->observation().event, Renderable_Observer_Event::None); + EXPECT_EQ(spectrum->observation().event, Renderable_Observer_Event::None); + + frequency->set<&Axis_Properties::coordinates>(Range{88'000'000.0, 108'000'000.0}); + spectrum->set<&Spectrum::Properties::partition_count>(2); + const auto frequency_update = frequency->observation(); + const auto spectrum_update = spectrum->observation(); + EXPECT_EQ(frequency_update.event, Renderable_Observer_Event::Cache_Updated); + EXPECT_EQ(frequency_update.cache_update_count, 1U); + EXPECT_EQ(spectrum_update.event, Renderable_Observer_Event::Cache_Updated); + EXPECT_EQ(spectrum_update.cache_update_count, 1U); + EXPECT_EQ(power->observation().event, Renderable_Observer_Event::None); + + ASSERT_TRUE(plot.prepare_frame()); + const auto frequency_publish = frequency->observation(); + const auto power_publish = power->observation(); + const auto spectrum_publish = spectrum->observation(); + EXPECT_EQ(frequency_publish.event, Renderable_Observer_Event::Published); + EXPECT_EQ(power_publish.event, Renderable_Observer_Event::Published); + EXPECT_EQ(spectrum_publish.event, Renderable_Observer_Event::Published); + EXPECT_EQ(frequency_publish.publish_count, 1U); + EXPECT_EQ(power_publish.publish_count, 1U); + EXPECT_EQ(spectrum_publish.publish_count, 1U); + EXPECT_EQ(frequency_publish.cache_update_count, 1U); + EXPECT_EQ(power_publish.cache_update_count, 0U); + EXPECT_EQ(spectrum_publish.cache_update_count, 1U); + + ASSERT_TRUE(plot.render_prepared_frame()); + const auto frequency_render = frequency->observation(); + const auto power_render = power->observation(); + const auto spectrum_render = spectrum->observation(); + EXPECT_EQ(frequency_render.event, Renderable_Observer_Event::Render_Completed); + EXPECT_EQ(power_render.event, Renderable_Observer_Event::Render_Completed); + EXPECT_EQ(spectrum_render.event, Renderable_Observer_Event::Render_Completed); + EXPECT_GT(frequency_render.successful_render_count, 0U); + EXPECT_GT(power_render.successful_render_count, 0U); + EXPECT_GT(spectrum_render.successful_render_count, 0U); + EXPECT_EQ(spectrum_render.last_task_count, spectrum->task_graph()->nodes().size()); + EXPECT_GE(spectrum_render.task_execution_count, spectrum_render.last_task_count); + EXPECT_GE(spectrum_render.peak_parallelism, 1U); +} +TEST(Renderive_Core2, RenderableObserverKeepsFailureTerminalForTheRenderSequence) { + Renderable_Observer observer; + observer.observe_task({Renderable_Task_Observation_Event::started, 100, 7, 2, 0}); + observer.observe_task({Renderable_Task_Observation_Event::failed, 180, 7, 2, 80}); + observer.observe_task({Renderable_Task_Observation_Event::started, 190, 7, 2, 0}); + observer.observe_task({Renderable_Task_Observation_Event::completed, 240, 7, 2, 50}); + + const auto failed = observer.observation(); + EXPECT_EQ(failed.event, Renderable_Observer_Event::Render_Failed); + EXPECT_EQ(failed.failed_render_count, 1U); + EXPECT_EQ(failed.successful_render_count, 0U); + EXPECT_EQ(failed.failed_task_count, 1U); + EXPECT_EQ(failed.task_execution_count, 2U); + EXPECT_EQ(failed.last_task_count, 2U); + EXPECT_EQ(failed.last_render_duration_ns, 140U); + EXPECT_EQ(failed.total_render_duration_ns, 140U); + EXPECT_EQ(failed.render_duration_sample_count, 1U); + EXPECT_EQ(failed.render_duration_p95_ns, 140U); + EXPECT_EQ(failed.render_duration_p99_ns, 140U); + EXPECT_EQ(failed.task_duration_sample_count, 2U); + EXPECT_EQ(failed.task_duration_p95_ns, 80U); + + observer.reset_performance(); + const auto reset = observer.observation(); + EXPECT_EQ(reset.failed_render_count, 0U); + EXPECT_EQ(reset.task_execution_count, 0U); + EXPECT_EQ(reset.render_duration_sample_count, 0U); + EXPECT_EQ(reset.task_duration_sample_count, 0U); +} TEST(Renderive_Core2, PlottableDataUpdatesReachKernelRealTimeDataStrategy) { Plot_Core plot; plot.init(); diff --git a/web_server/app/Gallery_Controls.h b/web_server/app/Gallery_Controls.h index c420993..c75743e 100644 --- a/web_server/app/Gallery_Controls.h +++ b/web_server/app/Gallery_Controls.h @@ -1,13 +1,21 @@ #pragma once +#include "Gallery_Observer_Adminive.h" #include "Gallery_Renderables.h" +#include + #include +#include +#include +#include +#include #include #include #include #include +#include #include #include @@ -28,28 +36,6 @@ public: add(std::move(id), adminive::Type_Descriptor::get().label(), object); } -private: - template - void add(std::string id, std::string title, Object& object) { - using Model = adminive::Object_Model_Type; - auto* target = &object; - entries_.push_back({ - std::move(id), std::move(title), - [target] { - return Json{ - {"descriptor", adminive::to_descriptor_json()}, - {"view", adminive::to_view_json( - adminive::describe_edit_view())}, - {"data", adminive::to_frontend_json(*target)} - }; - }, - [target](const Json& patch) { - return adminive::apply_frontend_patch(*target, patch); - } - }); - } - -public: [[nodiscard]] Json resources() const { Json result = Json::array(); for (const auto& entry : entries_) { @@ -61,6 +47,92 @@ public: return result; } + [[nodiscard]] Json observers(const Scene_Base& scene) const { + Json result = Json::array(); + const auto topology = scene.topology_snapshot(); + for (std::size_t index = 0; index < topology.renderables.size(); ++index) { + const auto* renderable = + dynamic_cast(topology.renderables[index].get()); + if (!renderable) + continue; + const Entry* entry = find(renderable); + std::string title = entry ? entry->title : renderable->object_name(); + if (title.empty()) + title = "渲染节点 " + std::to_string(index + 1); + Json resource{ + {"descriptor", adminive::to_descriptor_json< + Json, renderive::Renderable_Observation>()}, + {"data", adminive::to_frontend_json(renderable->observation())}, + {"target", entry ? entry->id : node_id(index)}, + {"title", std::move(title)} + }; + result.push_back(std::move(resource)); + } + return result; + } + + [[nodiscard]] Json task_graph(const Scene_Base& scene) const { + const auto topology = scene.topology_snapshot(); + const auto paint_order = scene.paint_order_snapshot(); + std::unordered_map indices; + indices.reserve(topology.renderables.size()); + for (std::size_t index = 0; index < topology.renderables.size(); ++index) + indices.emplace(topology.renderables[index].get(), index); + + std::unordered_map paint_indices; + paint_indices.reserve(paint_order.size()); + for (std::size_t index = 0; index < paint_order.size(); ++index) + paint_indices.emplace(paint_order[index].get(), index + 1); + + Json nodes = Json::array(); + Json edges = Json::array(); + Json ordered = Json::array(); + for (std::size_t index = 0; index < topology.renderables.size(); ++index) { + const auto* base = topology.renderables[index].get(); + const auto* renderable = dynamic_cast(base); + const Entry* entry = find(base); + std::string label = entry ? entry->title : std::string{}; + if (label.empty() && renderable) + label = renderable->object_name(); + if (label.empty()) + label = "渲染节点 " + std::to_string(index + 1); + Json node{ + {"id", node_id(index)}, + {"label", std::move(label)}, + {"kind", entry ? "control" : "layer"}, + {"paint_order", paint_indices.contains(base) ? paint_indices.at(base) : 0} + }; + if (entry) + node["target"] = entry->id; + nodes.push_back(std::move(node)); + } + + append_relationships(edges, topology.display, indices, "display"); + append_relationships(edges, topology.dependency, indices, "dependency"); + for (const auto& renderable : paint_order) + ordered.push_back(node_id(indices.at(renderable.get()))); + + Json control_graphs = Json::array(); + for (const auto& entry : entries_) { + if (!entry.renderable) + continue; + control_graphs.push_back({ + {"target", entry.id}, + {"title", entry.title}, + {"graph", renderable_task_graph(*entry.renderable)} + }); + } + + Json result{ + {"nodes", std::move(nodes)}, + {"edges", std::move(edges)}, + {"paint_order", std::move(ordered)}, + {"renderables", std::move(control_graphs)} + }; + result["topology_id"] = topology_id(result); + return result; + } + [[nodiscard]] adminive::Update_Result apply(std::string_view target, const Json& patch) const { for (const auto& entry : entries_) { @@ -79,8 +151,96 @@ private: std::string title; std::function resource; std::function apply; + renderive::Renderable* renderable{}; }; + template + void add(std::string id, std::string title, Object& object) { + using Model = adminive::Object_Model_Type; + auto* target = &object; + renderive::Renderable* renderable{}; + if constexpr (std::derived_from) + renderable = target; + entries_.push_back({ + std::move(id), std::move(title), + [target] { + return Json{ + {"descriptor", adminive::to_descriptor_json()}, + {"view", adminive::to_view_json( + adminive::describe_edit_view())}, + {"data", adminive::to_frontend_json(*target)} + }; + }, + [target](const Json& patch) { + return adminive::apply_frontend_patch(*target, patch); + }, + renderable + }); + } + + [[nodiscard]] const Entry* find(const Renderable_Base* renderable) const noexcept { + const auto found = std::find_if(entries_.begin(), entries_.end(), + [renderable](const Entry& entry) { return entry.renderable == renderable; }); + return found == entries_.end() ? nullptr : &*found; + } + + static std::string node_id(std::size_t index) { + return "renderable-" + std::to_string(index); + } + + static void append_relationships( + Json& edges, + const std::vector& relationships, + const std::unordered_map& indices, + std::string_view kind) { + for (const auto& relationship : relationships) { + if (!relationship.parent) + continue; + edges.push_back({ + {"from", node_id(indices.at(relationship.parent.get()))}, + {"to", node_id(indices.at(relationship.child.get()))}, + {"kind", kind} + }); + } + } + + static std::string topology_id(const Json& topology) { + std::uint64_t hash = 1469598103934665603ULL; + for (const unsigned char byte : topology.dump()) { + hash ^= byte; + hash *= 1099511628211ULL; + } + std::array text{}; + const auto result = std::to_chars(text.data(), text.data() + text.size() - 1, + hash, 16); + return {text.data(), result.ptr}; + } + + static Json renderable_task_graph(Renderable_Base& renderable) { + const auto graph = renderable.task_graph(); + Json nodes = Json::array(); + Json edges = Json::array(); + for (std::size_t index = 0; index < graph->nodes().size(); ++index) { + const auto& node = graph->nodes()[index]; + nodes.push_back({ + {"id", "task-" + std::to_string(index)}, + {"label", node.name.empty() ? "绘制任务 " + std::to_string(index + 1) + : std::string(node.name)}, + {"kind", "task"} + }); + for (const std::size_t successor : node.successors) { + edges.push_back({ + {"from", "task-" + std::to_string(index)}, + {"to", "task-" + std::to_string(successor)}, + {"kind", "dependency"} + }); + } + } + Json result{{"nodes", std::move(nodes)}, {"edges", std::move(edges)}}; + result["topology_id"] = topology_id(result); + return result; + } + std::vector entries_; }; diff --git a/web_server/app/Gallery_Observer_Adminive.h b/web_server/app/Gallery_Observer_Adminive.h index 11eb9e7..155443b 100644 --- a/web_server/app/Gallery_Observer_Adminive.h +++ b/web_server/app/Gallery_Observer_Adminive.h @@ -2,6 +2,7 @@ #include "Gallery_Enum.h" #include "render_2D/plot/Plot_Core.h" +#include "render_2D/renderable/Renderable.h" #include "adminive/adminive.hpp" #include "adminive/adapters/magic_enum.hpp" @@ -24,6 +25,63 @@ struct Gallery_Consumer_Feedback_Snapshot { std::uint64_t manual_interval_ns{}; }; +struct Gallery_Render_Performance { + std::uint64_t render_attempt_count{}; + std::uint64_t successful_render_count{}; + std::uint64_t failed_render_count{}; + double measured_fps{}; + double lifetime_average_fps{}; + double last_render_ms{}; + double average_render_ms{}; + double maximum_render_ms{}; + double render_deviation_ms{}; + double render_p50_ms{}; + double render_p95_ms{}; + double render_p99_ms{}; + std::uint64_t render_sample_count{}; + double pixel_response_fps{}; + double last_pixel_snapshot_ms{}; + double last_pixel_encode_ms{}; + double average_pixel_encode_ms{}; + double maximum_pixel_encode_ms{}; + double pixel_encode_deviation_ms{}; + double pixel_encode_p50_ms{}; + double pixel_encode_p95_ms{}; + double pixel_encode_p99_ms{}; + std::uint64_t pixel_encode_sample_count{}; + double last_pixel_request_ms{}; + double average_pixel_request_ms{}; + double pixel_request_deviation_ms{}; + double pixel_request_p95_ms{}; + double pixel_request_p99_ms{}; + std::uint64_t last_pixel_bytes{}; + double pixel_payload_megabytes_per_second{}; + bool automatic_low_latency_scheduler{}; +}; + +struct Gallery_Client_Performance { + double transport_fps{}; + double presentation_fps{}; + std::uint64_t websocket_buffered_bytes{}; + std::uint64_t changed_pixel_frames{}; + std::uint64_t duplicate_pixel_frames{}; + std::uint64_t frame_request_timeout_count{}; + double frame_round_trip_ms{}; + double frame_round_trip_average_ms{}; + double frame_round_trip_deviation_ms{}; + double frame_round_trip_p95_ms{}; + double frame_round_trip_p99_ms{}; + double display_interval_ms{}; + double display_interval_average_ms{}; + double display_interval_latest_ms{}; + double display_interval_p95_ms{}; + double display_interval_p99_ms{}; + double display_interval_deviation_ms{}; + std::uint64_t overwritten_pixel_frames{}; + double last_pixel_receive_age_ms{}; + double last_pixel_change_age_ms{}; +}; + } // namespace renderive::web namespace adminive { @@ -49,7 +107,10 @@ struct Type_Descriptor { static auto get() { using T = renderive::Frame_Observer_Snapshot; return object("kernel_observer", - ADMINIVE_FIELD_LABEL(T, mode, "模式"), + ADMINIVE_FIELD_LABEL(T, mode, "模式") + .enum_label("手动刷新") + .enum_label("低延迟") + .enum_label("回放队列"), ADMINIVE_FIELD_LABEL(T, last_event, "最近事件"), ADMINIVE_FIELD_LABEL(T, limit_state, "当前瓶颈"), ADMINIVE_FIELD_LABEL(T, frequency_hz, "配置频率"), @@ -60,11 +121,11 @@ struct Type_Descriptor { ADMINIVE_FIELD_LABEL(T, failed_operation_count, "失败"), ADMINIVE_FIELD_LABEL(T, pending_frame_count, "待处理"), ADMINIVE_FIELD_LABEL(T, latest_sequence, "最新序号"), - ADMINIVE_FIELD_LABEL(T, paint_duration_ns, "PaintEvent"), + ADMINIVE_FIELD_LABEL(T, paint_duration_ns, "绘制事件耗时"), ADMINIVE_FIELD_LABEL(T, render_duration_ns, "后台渲染"), ADMINIVE_FIELD_LABEL(T, target_interval_ns, "目标间隔"), ADMINIVE_FIELD_LABEL(T, frequency_limit_enabled, "频率限制"), - ADMINIVE_FIELD_LABEL(T, consumer_feedback_enabled, "Kernel 反馈有效"), + ADMINIVE_FIELD_LABEL(T, consumer_feedback_enabled, "内核反馈有效"), ADMINIVE_FIELD_LABEL(T, bottleneck_duration_ns, "内部瓶颈"), ADMINIVE_FIELD_LABEL(T, consumer_sample_interval_ns, "消费者原始采样"), ADMINIVE_FIELD_LABEL(T, consumer_smoothed_interval_ns, "消费者平滑周期"), @@ -72,14 +133,14 @@ struct Type_Descriptor { ADMINIVE_FIELD_LABEL(T, consumer_safety_interval_ns, "消费者安全期限"), ADMINIVE_FIELD_LABEL(T, consumer_interval_ns, "消费者限速周期"), ADMINIVE_FIELD_LABEL(T, next_refresh_interval_ns, "下次刷新"), - ADMINIVE_FIELD_LABEL(T, paint_lease_wait_ns, "Painter Lease 等待"), - ADMINIVE_FIELD_LABEL(T, paint_state_wait_ns, "Paint State 等待"), - ADMINIVE_FIELD_LABEL(T, publish_state_wait_ns, "Publish State 等待"), - ADMINIVE_FIELD_LABEL(T, ready_wait_ns, "Ready 等待"), + ADMINIVE_FIELD_LABEL(T, paint_lease_wait_ns, "绘制租约等待"), + ADMINIVE_FIELD_LABEL(T, paint_state_wait_ns, "绘制状态等待"), + ADMINIVE_FIELD_LABEL(T, publish_state_wait_ns, "发布状态等待"), + ADMINIVE_FIELD_LABEL(T, ready_wait_ns, "就绪等待"), ADMINIVE_FIELD_LABEL(T, frame_age_at_render_ns, "开始渲染时帧龄"), - ADMINIVE_FIELD_LABEL(T, render_lease_wait_ns, "Render Lease 等待"), - ADMINIVE_FIELD_LABEL(T, render_state_wait_ns, "Render State 等待"), - ADMINIVE_FIELD_LABEL(T, render_finish_state_wait_ns, "Render Finish 等待"), + ADMINIVE_FIELD_LABEL(T, render_lease_wait_ns, "渲染租约等待"), + ADMINIVE_FIELD_LABEL(T, render_state_wait_ns, "渲染状态等待"), + ADMINIVE_FIELD_LABEL(T, render_finish_state_wait_ns, "渲染完成等待"), ADMINIVE_FIELD_LABEL(T, queue_wait_ns, "回放队列等待"), ADMINIVE_FIELD_LABEL(T, end_to_end_ns, "端到端延迟"), ADMINIVE_FIELD_LABEL(T, task_execution_state, "任务执行状态") @@ -88,7 +149,7 @@ struct Type_Descriptor { .enum_label("执行中") .enum_label("已完成") .enum_label("失败"), - ADMINIVE_FIELD_LABEL(T, task_worker_count, "Taskflow 工作线程"), + ADMINIVE_FIELD_LABEL(T, task_worker_count, "任务工作线程"), ADMINIVE_FIELD_LABEL(T, task_graph_node_count, "任务图节点"), ADMINIVE_FIELD_LABEL(T, task_completed_node_count, "已完成节点"), ADMINIVE_FIELD_LABEL(T, task_running_node_count, "运行中节点"), @@ -118,7 +179,123 @@ struct Type_Descriptor { } }; +template <> +struct Type_Descriptor { + static auto get() { + using T = renderive::web::Gallery_Render_Performance; + return object("render_performance", + ADMINIVE_FIELD_LABEL(T, render_attempt_count, "渲染尝试"), + ADMINIVE_FIELD_LABEL(T, successful_render_count, "渲染成功"), + ADMINIVE_FIELD_LABEL(T, failed_render_count, "渲染失败"), + ADMINIVE_FIELD_LABEL(T, measured_fps, "最近渲染帧率"), + ADMINIVE_FIELD_LABEL(T, lifetime_average_fps, "平均渲染帧率"), + ADMINIVE_FIELD_LABEL(T, last_render_ms, "最近渲染耗时"), + ADMINIVE_FIELD_LABEL(T, average_render_ms, "平均渲染耗时"), + ADMINIVE_FIELD_LABEL(T, maximum_render_ms, "最大渲染耗时"), + ADMINIVE_FIELD_LABEL(T, render_deviation_ms, "渲染标准差"), + ADMINIVE_FIELD_LABEL(T, render_p50_ms, "渲染 P50"), + ADMINIVE_FIELD_LABEL(T, render_p95_ms, "渲染 P95"), + ADMINIVE_FIELD_LABEL(T, render_p99_ms, "渲染 P99"), + ADMINIVE_FIELD_LABEL(T, render_sample_count, "渲染窗口样本"), + ADMINIVE_FIELD_LABEL(T, pixel_response_fps, "像素响应帧率"), + ADMINIVE_FIELD_LABEL(T, last_pixel_snapshot_ms, "最近像素快照耗时"), + ADMINIVE_FIELD_LABEL(T, last_pixel_encode_ms, "最近像素编码耗时"), + ADMINIVE_FIELD_LABEL(T, average_pixel_encode_ms, "平均像素编码耗时"), + ADMINIVE_FIELD_LABEL(T, maximum_pixel_encode_ms, "最大像素编码耗时"), + ADMINIVE_FIELD_LABEL(T, pixel_encode_deviation_ms, "像素编码标准差"), + ADMINIVE_FIELD_LABEL(T, pixel_encode_p50_ms, "像素编码 P50"), + ADMINIVE_FIELD_LABEL(T, pixel_encode_p95_ms, "像素编码 P95"), + ADMINIVE_FIELD_LABEL(T, pixel_encode_p99_ms, "像素编码 P99"), + ADMINIVE_FIELD_LABEL(T, pixel_encode_sample_count, "像素编码窗口样本"), + ADMINIVE_FIELD_LABEL(T, last_pixel_request_ms, "最近像素请求耗时"), + ADMINIVE_FIELD_LABEL(T, average_pixel_request_ms, "像素请求滑动平均"), + ADMINIVE_FIELD_LABEL(T, pixel_request_deviation_ms, "像素请求标准差"), + ADMINIVE_FIELD_LABEL(T, pixel_request_p95_ms, "像素请求 P95"), + ADMINIVE_FIELD_LABEL(T, pixel_request_p99_ms, "像素请求 P99"), + ADMINIVE_FIELD_LABEL(T, last_pixel_bytes, "最近像素负载"), + ADMINIVE_FIELD_LABEL(T, pixel_payload_megabytes_per_second, "像素负载吞吐"), + ADMINIVE_FIELD_LABEL(T, automatic_low_latency_scheduler, "自动低延迟调度")) + .label("渲染性能"); + } +}; + +template <> +struct Type_Descriptor { + static auto get() { + using T = renderive::web::Gallery_Client_Performance; + return object("client_performance", + ADMINIVE_FIELD_LABEL(T, transport_fps, "像素响应帧率"), + ADMINIVE_FIELD_LABEL(T, presentation_fps, "浏览器呈现帧率"), + ADMINIVE_FIELD_LABEL(T, websocket_buffered_bytes, "WebSocket 缓冲"), + ADMINIVE_FIELD_LABEL(T, changed_pixel_frames, "变化像素帧"), + ADMINIVE_FIELD_LABEL(T, duplicate_pixel_frames, "重复像素帧"), + ADMINIVE_FIELD_LABEL(T, frame_request_timeout_count, "像素请求超时"), + ADMINIVE_FIELD_LABEL(T, frame_round_trip_ms, "WebSocket 往返耗时"), + ADMINIVE_FIELD_LABEL(T, frame_round_trip_average_ms, "WebSocket 往返滑动平均"), + ADMINIVE_FIELD_LABEL(T, frame_round_trip_deviation_ms, "WebSocket 往返标准差"), + ADMINIVE_FIELD_LABEL(T, frame_round_trip_p95_ms, "WebSocket 往返 P95"), + ADMINIVE_FIELD_LABEL(T, frame_round_trip_p99_ms, "WebSocket 往返 P99"), + ADMINIVE_FIELD_LABEL(T, display_interval_ms, "呈现中位周期"), + ADMINIVE_FIELD_LABEL(T, display_interval_average_ms, "呈现滑动平均周期"), + ADMINIVE_FIELD_LABEL(T, display_interval_latest_ms, "最近呈现周期"), + ADMINIVE_FIELD_LABEL(T, display_interval_p95_ms, "呈现 P95 周期"), + ADMINIVE_FIELD_LABEL(T, display_interval_p99_ms, "呈现 P99 周期"), + ADMINIVE_FIELD_LABEL(T, display_interval_deviation_ms, "呈现周期标准差"), + ADMINIVE_FIELD_LABEL(T, overwritten_pixel_frames, "未呈现覆盖帧"), + ADMINIVE_FIELD_LABEL(T, last_pixel_receive_age_ms, "最近像素龄"), + ADMINIVE_FIELD_LABEL(T, last_pixel_change_age_ms, "最近变化龄")) + .label("浏览器性能"); + } +}; + +template <> +struct Type_Descriptor { + static auto get() { + using T = renderive::Renderable_Observation; + return object("renderable_observer", + ADMINIVE_FIELD_LABEL(T, event, "最近状态事件") + .enum_label("尚无事件") + .enum_label("缓存状态已更新") + .enum_label("渲染状态已发布") + .enum_label("绘制已开始") + .enum_label("绘制已完成") + .enum_label("绘制失败"), + ADMINIVE_FIELD_LABEL(T, event_time_ns, "事件时间"), + ADMINIVE_FIELD_LABEL(T, cache_update_count, "缓存更新次数"), + ADMINIVE_FIELD_LABEL(T, publish_count, "状态发布次数"), + ADMINIVE_FIELD_LABEL(T, successful_render_count, "绘制完成次数"), + ADMINIVE_FIELD_LABEL(T, failed_render_count, "绘制失败次数"), + ADMINIVE_FIELD_LABEL(T, last_render_sequence, "最近绘制序号"), + ADMINIVE_FIELD_LABEL(T, last_render_duration_ns, "最近绘制墙钟耗时"), + ADMINIVE_FIELD_LABEL(T, total_render_duration_ns, "累计绘制墙钟耗时"), + ADMINIVE_FIELD_LABEL(T, maximum_render_duration_ns, "最大绘制墙钟耗时"), + ADMINIVE_FIELD_LABEL(T, render_duration_sample_count, "绘制统计样本"), + ADMINIVE_FIELD_LABEL(T, render_duration_average_ns, "绘制滑动平均"), + ADMINIVE_FIELD_LABEL(T, render_duration_deviation_ns, "绘制标准差"), + ADMINIVE_FIELD_LABEL(T, render_duration_p50_ns, "绘制 P50"), + ADMINIVE_FIELD_LABEL(T, render_duration_p95_ns, "绘制 P95"), + ADMINIVE_FIELD_LABEL(T, render_duration_p99_ns, "绘制 P99"), + ADMINIVE_FIELD_LABEL(T, task_execution_count, "任务执行次数"), + ADMINIVE_FIELD_LABEL(T, failed_task_count, "任务失败次数"), + ADMINIVE_FIELD_LABEL(T, last_task_duration_ns, "最近任务耗时"), + ADMINIVE_FIELD_LABEL(T, total_task_duration_ns, "累计任务耗时"), + ADMINIVE_FIELD_LABEL(T, maximum_task_duration_ns, "最大任务耗时"), + ADMINIVE_FIELD_LABEL(T, task_duration_sample_count, "任务统计样本"), + ADMINIVE_FIELD_LABEL(T, task_duration_average_ns, "任务滑动平均"), + ADMINIVE_FIELD_LABEL(T, task_duration_deviation_ns, "任务标准差"), + ADMINIVE_FIELD_LABEL(T, task_duration_p50_ns, "任务 P50"), + ADMINIVE_FIELD_LABEL(T, task_duration_p95_ns, "任务 P95"), + ADMINIVE_FIELD_LABEL(T, task_duration_p99_ns, "任务 P99"), + ADMINIVE_FIELD_LABEL(T, last_task_count, "最近任务数"), + ADMINIVE_FIELD_LABEL(T, peak_parallelism, "峰值并行度")) + .label("渲染对象观察器"); + } +}; + static_assert(Described_Type); static_assert(Described_Type); +static_assert(Described_Type); +static_assert(Described_Type); +static_assert(Described_Type); } // namespace adminive diff --git a/web_server/app/Gallery_Plot_Session.cpp b/web_server/app/Gallery_Plot_Session.cpp index 8eccf75..26e970d 100644 --- a/web_server/app/Gallery_Plot_Session.cpp +++ b/web_server/app/Gallery_Plot_Session.cpp @@ -7,6 +7,7 @@ #include "Pixel_Frame.h" #include "Web_Performance_Log.h" #include "render_2D/export.h" +#include #include #include #include @@ -94,9 +95,10 @@ public: plot_.init(); plot_.set_viewport_size({560, 320}); root_ = plot_.root_renderable(); - axes_node_ = plot_.create_renderable_node(root_, "Gallery_Axes"); - data_node_ = plot_.create_renderable_node(root_, "Gallery_Data"); - overlay_node_ = plot_.create_renderable_node(root_, "Gallery_Overlay"); + root_->set_object_name("画布根节点"); + axes_node_ = plot_.create_renderable_node(root_, "坐标轴层"); + data_node_ = plot_.create_renderable_node(root_, "数据绘制层"); + overlay_node_ = plot_.create_renderable_node(root_, "交互覆盖层"); axes_node_->set_cache_mode(Renderable_Cache_Mode::Local_Pixel); attach_performance_overlay(plot_); build_axes(); @@ -121,11 +123,37 @@ public: return case_id_; } [[nodiscard]] nlohmann::json controls() const { - return controls_.resources(); + return { + {"resources", controls_.resources()}, + {"observers", controls_.observers(root_->scene())}, + {"task_graph", controls_.task_graph(root_->scene())} + }; } [[nodiscard]] Gallery_Frame_Mode frame_mode() const noexcept { return frame_mode_; } + void reset_monitoring() { + root_->scene().reset_renderable_task_observations(); + performance_started_ = std::chrono::steady_clock::now(); + last_performance_log_ = performance_started_; + render_attempt_count_ = 0; + successful_render_count_ = 0; + last_render_ms_ = 0.0; + render_duration_statistics_.clear(); + render_history_.clear(); + pixel_frame_count_ = 0; + last_pixel_snapshot_ms_ = 0.0; + last_pixel_encode_ms_ = 0.0; + last_pixel_request_ms_ = 0.0; + last_pixel_bytes_ = 0; + pixel_encode_statistics_.clear(); + pixel_request_statistics_.clear(); + pixel_history_.clear(); + client_performance_ = {}; + consumer_pixel_interval_ns_ = 0; + consumer_presentation_interval_ns_ = 0; + apply_consumer_feedback(); + } [[nodiscard]] bool can_render_automatically() const noexcept { return automatic_low_latency_ && frame_mode_ == Gallery_Frame_Mode::Low_Latency && plot_.view_active(); @@ -133,33 +161,26 @@ public: [[nodiscard]] std::uint64_t kernel_refresh_interval_ns() const { return plot_.refresh_feedback_snapshot().next_refresh_interval_ns; } - void set_client_metrics(double transport_fps, double presentation_fps, - std::uint64_t buffered_bytes, - std::uint64_t changed_pixel_frames, - std::uint64_t duplicate_pixel_frames, - std::uint64_t frame_request_timeout_count, - double frame_round_trip_ms, - double display_interval_ms, - double display_interval_latest_ms, - double display_interval_p95_ms, - double display_jitter_ms, - std::uint64_t overwritten_pixel_frames, - double last_pixel_receive_age_ms, - double last_pixel_change_age_ms) noexcept { - client_transport_fps_ = std::isfinite(transport_fps) ? std::clamp(transport_fps, 0.0, 100000.0) : 0.0; - client_presentation_fps_ = std::isfinite(presentation_fps) ? std::clamp(presentation_fps, 0.0, 100000.0) : 0.0; - client_buffered_bytes_ = buffered_bytes; - client_changed_pixel_frames_ = changed_pixel_frames; - client_duplicate_pixel_frames_ = duplicate_pixel_frames; - client_frame_request_timeout_count_ = frame_request_timeout_count; - client_frame_round_trip_ms_ = std::isfinite(frame_round_trip_ms) ? std::max(0.0, frame_round_trip_ms) : 0.0; - client_display_interval_ms_ = std::isfinite(display_interval_ms) ? std::max(0.0, display_interval_ms) : 0.0; - client_display_interval_latest_ms_ = std::isfinite(display_interval_latest_ms) ? std::max(0.0, display_interval_latest_ms) : 0.0; - client_display_interval_p95_ms_ = std::isfinite(display_interval_p95_ms) ? std::max(0.0, display_interval_p95_ms) : 0.0; - client_display_jitter_ms_ = std::isfinite(display_jitter_ms) ? std::max(0.0, display_jitter_ms) : 0.0; - client_overwritten_pixel_frames_ = overwritten_pixel_frames; - client_last_pixel_receive_age_ms_ = std::isfinite(last_pixel_receive_age_ms) ? std::max(0.0, last_pixel_receive_age_ms) : 0.0; - client_last_pixel_change_age_ms_ = std::isfinite(last_pixel_change_age_ms) ? std::max(0.0, last_pixel_change_age_ms) : 0.0; + void set_client_metrics(Gallery_Client_Performance metrics) noexcept { + const auto finite = [](double value, double maximum = 100000.0) { + return std::isfinite(value) ? std::clamp(value, 0.0, maximum) : 0.0; + }; + metrics.transport_fps = finite(metrics.transport_fps); + metrics.presentation_fps = finite(metrics.presentation_fps); + metrics.frame_round_trip_ms = finite(metrics.frame_round_trip_ms); + metrics.frame_round_trip_average_ms = finite(metrics.frame_round_trip_average_ms); + metrics.frame_round_trip_deviation_ms = finite(metrics.frame_round_trip_deviation_ms); + metrics.frame_round_trip_p95_ms = finite(metrics.frame_round_trip_p95_ms); + metrics.frame_round_trip_p99_ms = finite(metrics.frame_round_trip_p99_ms); + metrics.display_interval_ms = finite(metrics.display_interval_ms); + metrics.display_interval_average_ms = finite(metrics.display_interval_average_ms); + metrics.display_interval_latest_ms = finite(metrics.display_interval_latest_ms); + metrics.display_interval_p95_ms = finite(metrics.display_interval_p95_ms); + metrics.display_interval_p99_ms = finite(metrics.display_interval_p99_ms); + metrics.display_interval_deviation_ms = finite(metrics.display_interval_deviation_ms); + metrics.last_pixel_receive_age_ms = finite(metrics.last_pixel_receive_age_ms); + metrics.last_pixel_change_age_ms = finite(metrics.last_pixel_change_age_ms); + client_performance_ = metrics; apply_consumer_feedback(); } [[nodiscard]] adminive::Update_Result apply_patch(std::string_view target, @@ -421,6 +442,9 @@ public: const double pixel_fps = recent_pixel_rate(pixel_history_, telemetry_now); const double pixel_megabytes_per_second = recent_pixel_megabytes_per_second( pixel_history_, telemetry_now); + const auto render_window = render_duration_statistics_.snapshot(); + const auto pixel_encode_window = pixel_encode_statistics_.snapshot(); + const auto pixel_request_window = pixel_request_statistics_.snapshot(); const bool low_latency = frame_mode_ == Gallery_Frame_Mode::Low_Latency; const Gallery_Consumer_Feedback_Snapshot consumer_feedback{ low_latency && feedback_policy_.enabled, @@ -432,10 +456,51 @@ public: consumer_pixel_interval_ns_, consumer_presentation_interval_ns_, consumer_manual_interval_ns_}; + Gallery_Render_Performance render_performance; + render_performance.render_attempt_count = render_attempt_count_; + render_performance.successful_render_count = successful_render_count_; + render_performance.failed_render_count = + render_attempt_count_ - successful_render_count_; + render_performance.measured_fps = render_fps; + render_performance.lifetime_average_fps = + static_cast(successful_render_count_) / elapsed_seconds; + render_performance.last_render_ms = last_render_ms_; + render_performance.average_render_ms = render_window.average; + render_performance.maximum_render_ms = render_window.maximum; + render_performance.render_deviation_ms = render_window.deviation; + render_performance.render_p50_ms = render_window.p50; + render_performance.render_p95_ms = render_window.p95; + render_performance.render_p99_ms = render_window.p99; + render_performance.render_sample_count = render_window.sample_count; + render_performance.pixel_response_fps = pixel_fps; + render_performance.last_pixel_snapshot_ms = last_pixel_snapshot_ms_; + render_performance.last_pixel_encode_ms = last_pixel_encode_ms_; + render_performance.average_pixel_encode_ms = pixel_encode_window.average; + render_performance.maximum_pixel_encode_ms = pixel_encode_window.maximum; + render_performance.pixel_encode_deviation_ms = pixel_encode_window.deviation; + render_performance.pixel_encode_p50_ms = pixel_encode_window.p50; + render_performance.pixel_encode_p95_ms = pixel_encode_window.p95; + render_performance.pixel_encode_p99_ms = pixel_encode_window.p99; + render_performance.pixel_encode_sample_count = pixel_encode_window.sample_count; + render_performance.last_pixel_request_ms = last_pixel_request_ms_; + render_performance.average_pixel_request_ms = pixel_request_window.average; + render_performance.pixel_request_deviation_ms = pixel_request_window.deviation; + render_performance.pixel_request_p95_ms = pixel_request_window.p95; + render_performance.pixel_request_p99_ms = pixel_request_window.p99; + render_performance.last_pixel_bytes = last_pixel_bytes_; + render_performance.pixel_payload_megabytes_per_second = + pixel_megabytes_per_second; + render_performance.automatic_low_latency_scheduler = + automatic_low_latency_ && frame_mode_ == Gallery_Frame_Mode::Low_Latency; + const Gallery_Client_Performance client_performance = client_performance_; nlohmann::json observer_data = adminive::to_frontend_json(observer); nlohmann::json consumer_feedback_data = adminive::to_frontend_json(consumer_feedback); + nlohmann::json render_performance_data = + adminive::to_frontend_json(render_performance); + nlohmann::json client_performance_data = + adminive::to_frontend_json(client_performance); nlohmann::json telemetry{ {"case", case_id_}, {"frame_mode", gallery_enum_id(frame_mode_)}, @@ -448,50 +513,11 @@ public: }, {"view_active", plot_.view_active()}, {"kernel_frame_count", plot_.diagnostics().refresh.frame_count}, - { - "performance", { - {"render_attempt_count", render_attempt_count_}, - {"successful_render_count", successful_render_count_}, - {"failed_render_count", render_attempt_count_ - successful_render_count_}, - {"measured_fps", render_fps}, - {"lifetime_average_fps", static_cast(successful_render_count_) / elapsed_seconds}, - {"last_render_ms", last_render_ms_}, - {"average_render_ms", successful_render_count_ == 0 ? 0.0 : total_render_ms_ / static_cast(successful_render_count_)}, - {"maximum_render_ms", maximum_render_ms_}, - {"pixel_response_fps", pixel_fps}, - {"last_pixel_snapshot_ms", last_pixel_snapshot_ms_}, - {"last_pixel_encode_ms", last_pixel_encode_ms_}, - {"average_pixel_encode_ms", pixel_frame_count_ == 0 ? 0.0 : total_pixel_encode_ms_ / static_cast(pixel_frame_count_)}, - {"maximum_pixel_encode_ms", maximum_pixel_encode_ms_}, - {"last_pixel_request_ms", last_pixel_request_ms_}, - {"last_pixel_bytes", last_pixel_bytes_}, - {"pixel_payload_megabytes_per_second", pixel_megabytes_per_second}, - { - "automatic_low_latency_scheduler", - automatic_low_latency_ && frame_mode_ == Gallery_Frame_Mode::Low_Latency - } - } - }, + {"performance", std::move(render_performance_data)}, {"kernel_observer", std::move(observer_data)}, {"consumer_feedback", std::move(consumer_feedback_data)}, - { - "client_performance", { - {"transport_fps", client_transport_fps_}, - {"presentation_fps", client_presentation_fps_}, - {"websocket_buffered_bytes", client_buffered_bytes_}, - {"changed_pixel_frames", client_changed_pixel_frames_}, - {"duplicate_pixel_frames", client_duplicate_pixel_frames_}, - {"frame_request_timeout_count", client_frame_request_timeout_count_}, - {"frame_round_trip_ms", client_frame_round_trip_ms_}, - {"display_interval_ms", client_display_interval_ms_}, - {"display_interval_latest_ms", client_display_interval_latest_ms_}, - {"display_interval_p95_ms", client_display_interval_p95_ms_}, - {"display_jitter_ms", client_display_jitter_ms_}, - {"overwritten_pixel_frames", client_overwritten_pixel_frames_}, - {"last_pixel_receive_age_ms", client_last_pixel_receive_age_ms_}, - {"last_pixel_change_age_ms", client_last_pixel_change_age_ms_} - } - }, + {"client_performance", std::move(client_performance_data)}, + {"renderable_observers", controls_.observers(root_->scene())}, {"last_action_result", last_action_result_} }; if (primary_) { @@ -703,8 +729,9 @@ private: void apply_consumer_feedback() { if (frame_mode_ != Gallery_Frame_Mode::Low_Latency) return; - consumer_pixel_interval_ns_ = frequency_to_ns(client_transport_fps_); - consumer_presentation_interval_ns_ = milliseconds_to_ns(client_display_interval_ms_); + consumer_pixel_interval_ns_ = frequency_to_ns(client_performance_.transport_fps); + consumer_presentation_interval_ns_ = + milliseconds_to_ns(client_performance_.display_interval_ms); consumer_manual_interval_ns_ = frequency_to_ns(feedback_policy_.manual_fps); consumer_feedback_source_ = "none"; if (!feedback_policy_.enabled) { @@ -746,10 +773,9 @@ private: const double duration_ms = std::chrono::duration(finished - started).count(); last_render_ms_ = duration_ms; - maximum_render_ms_ = std::max(maximum_render_ms_, duration_ms); if (rendered) { ++successful_render_count_; - total_render_ms_ += duration_ms; + render_duration_statistics_.add(duration_ms); record_timestamp(render_history_, finished); } maybe_log_performance(finished); @@ -764,8 +790,8 @@ private: std::chrono::duration(encode_finished - encode_started).count(); last_pixel_request_ms_ = std::chrono::duration(encode_finished - request_started).count(); - maximum_pixel_encode_ms_ = std::max(maximum_pixel_encode_ms_, last_pixel_encode_ms_); - total_pixel_encode_ms_ += last_pixel_encode_ms_; + pixel_encode_statistics_.add(last_pixel_encode_ms_); + pixel_request_statistics_.add(last_pixel_request_ms_); last_pixel_bytes_ = pixel_bytes; ++pixel_frame_count_; record_pixel_sample(pixel_history_, encode_finished, pixel_bytes); @@ -802,26 +828,27 @@ private: {"consumer_feedback_interval_ns", low_latency ? observer.consumer_interval_ns : 0}, {"backend_render_fps", recent_rate(render_history_, now)}, {"pixel_response_fps", pixel_fps}, - {"client_transport_fps", client_transport_fps_}, - {"client_presentation_fps", client_presentation_fps_}, - {"client_changed_pixel_frames", client_changed_pixel_frames_}, - {"client_duplicate_pixel_frames", client_duplicate_pixel_frames_}, - {"client_frame_request_timeout_count", client_frame_request_timeout_count_}, - {"client_frame_round_trip_ms", client_frame_round_trip_ms_}, - {"client_display_interval_ms", client_display_interval_ms_}, - {"client_display_interval_latest_ms", client_display_interval_latest_ms_}, - {"client_display_interval_p95_ms", client_display_interval_p95_ms_}, - {"client_display_jitter_ms", client_display_jitter_ms_}, - {"client_overwritten_pixel_frames", client_overwritten_pixel_frames_}, - {"client_last_pixel_receive_age_ms", client_last_pixel_receive_age_ms_}, - {"client_last_pixel_change_age_ms", client_last_pixel_change_age_ms_}, + {"client_transport_fps", client_performance_.transport_fps}, + {"client_presentation_fps", client_performance_.presentation_fps}, + {"client_changed_pixel_frames", client_performance_.changed_pixel_frames}, + {"client_duplicate_pixel_frames", client_performance_.duplicate_pixel_frames}, + {"client_frame_request_timeout_count", client_performance_.frame_request_timeout_count}, + {"client_frame_round_trip_ms", client_performance_.frame_round_trip_ms}, + {"client_display_interval_ms", client_performance_.display_interval_ms}, + {"client_display_interval_latest_ms", client_performance_.display_interval_latest_ms}, + {"client_display_interval_p95_ms", client_performance_.display_interval_p95_ms}, + {"client_display_interval_p99_ms", client_performance_.display_interval_p99_ms}, + {"client_display_interval_deviation_ms", client_performance_.display_interval_deviation_ms}, + {"client_overwritten_pixel_frames", client_performance_.overwritten_pixel_frames}, + {"client_last_pixel_receive_age_ms", client_performance_.last_pixel_receive_age_ms}, + {"client_last_pixel_change_age_ms", client_performance_.last_pixel_change_age_ms}, {"last_render_ms", last_render_ms_}, {"last_pixel_snapshot_ms", last_pixel_snapshot_ms_}, {"last_pixel_encode_ms", last_pixel_encode_ms_}, {"last_pixel_request_ms", last_pixel_request_ms_}, {"pixel_payload_bytes", last_pixel_bytes_}, {"pixel_payload_megabytes_per_second", pixel_megabytes_per_second}, - {"websocket_buffered_bytes", client_buffered_bytes_}, + {"websocket_buffered_bytes", client_performance_.websocket_buffered_bytes}, { "automatic_low_latency_scheduler", automatic_low_latency_ && frame_mode_ == Gallery_Frame_Mode::Low_Latency @@ -1212,35 +1239,21 @@ private: std::uint64_t render_attempt_count_{}; std::uint64_t successful_render_count_{}; double last_render_ms_{}; - double total_render_ms_{}; - double maximum_render_ms_{}; + Rolling_Statistics render_duration_statistics_{256}; std::deque render_history_; std::uint64_t pixel_frame_count_{}; double last_pixel_snapshot_ms_{}; double last_pixel_encode_ms_{}; - double total_pixel_encode_ms_{}; - double maximum_pixel_encode_ms_{}; double last_pixel_request_ms_{}; + Rolling_Statistics pixel_encode_statistics_{256}; + Rolling_Statistics pixel_request_statistics_{256}; std::size_t last_pixel_bytes_{}; std::deque pixel_history_; - double client_transport_fps_{}; - double client_presentation_fps_{}; - std::uint64_t client_buffered_bytes_{}; - std::uint64_t client_changed_pixel_frames_{}; - std::uint64_t client_duplicate_pixel_frames_{}; - std::uint64_t client_frame_request_timeout_count_{}; - double client_frame_round_trip_ms_{}; - double client_display_interval_ms_{}; - double client_display_interval_latest_ms_{}; - double client_display_interval_p95_ms_{}; - double client_display_jitter_ms_{}; + Gallery_Client_Performance client_performance_; std::uint64_t consumer_pixel_interval_ns_{}; std::uint64_t consumer_presentation_interval_ns_{}; std::uint64_t consumer_manual_interval_ns_{}; std::string consumer_feedback_source_{"none"}; - std::uint64_t client_overwritten_pixel_frames_{}; - double client_last_pixel_receive_age_ms_{}; - double client_last_pixel_change_age_ms_{}; bool rendered_since_last_pixel_{}; std::string last_action_result_; }; @@ -1291,19 +1304,37 @@ struct Gallery_Plot_Session::Impl { const auto value = iterator->get(); return value > 0 ? static_cast(value) : std::uint64_t{}; }; - scene->set_client_metrics(finite_metric("transport_fps"), - finite_metric("presentation_fps"), buffered_bytes, - unsigned_metric("changed_pixel_frames"), - unsigned_metric("duplicate_pixel_frames"), - unsigned_metric("frame_request_timeout_count"), - finite_metric("frame_round_trip_ms"), - finite_metric("display_interval_ms"), - finite_metric("display_interval_latest_ms"), - finite_metric("display_interval_p95_ms"), - finite_metric("display_jitter_ms"), - unsigned_metric("overwritten_pixel_frames"), - finite_metric("last_pixel_receive_age_ms"), - finite_metric("last_pixel_change_age_ms")); + Gallery_Client_Performance performance; + performance.transport_fps = finite_metric("transport_fps"); + performance.presentation_fps = finite_metric("presentation_fps"); + performance.websocket_buffered_bytes = buffered_bytes; + performance.changed_pixel_frames = unsigned_metric("changed_pixel_frames"); + performance.duplicate_pixel_frames = unsigned_metric("duplicate_pixel_frames"); + performance.frame_request_timeout_count = + unsigned_metric("frame_request_timeout_count"); + performance.frame_round_trip_ms = finite_metric("frame_round_trip_ms"); + performance.frame_round_trip_average_ms = + finite_metric("frame_round_trip_average_ms"); + performance.frame_round_trip_deviation_ms = + finite_metric("frame_round_trip_deviation_ms"); + performance.frame_round_trip_p95_ms = finite_metric("frame_round_trip_p95_ms"); + performance.frame_round_trip_p99_ms = finite_metric("frame_round_trip_p99_ms"); + performance.display_interval_ms = finite_metric("display_interval_ms"); + performance.display_interval_average_ms = + finite_metric("display_interval_average_ms"); + performance.display_interval_latest_ms = + finite_metric("display_interval_latest_ms"); + performance.display_interval_p95_ms = finite_metric("display_interval_p95_ms"); + performance.display_interval_p99_ms = finite_metric("display_interval_p99_ms"); + performance.display_interval_deviation_ms = + finite_metric("display_interval_deviation_ms"); + performance.overwritten_pixel_frames = + unsigned_metric("overwritten_pixel_frames"); + performance.last_pixel_receive_age_ms = + finite_metric("last_pixel_receive_age_ms"); + performance.last_pixel_change_age_ms = + finite_metric("last_pixel_change_age_ms"); + scene->set_client_metrics(performance); return true; } [[nodiscard]] std::unique_lock acquire_foreground_lock() { @@ -1369,7 +1400,9 @@ struct Gallery_Plot_Session::Impl { } else if constexpr (std::is_same_v) { return value.kind != Gallery_Request_Kind::Catalog && - value.kind != Gallery_Request_Kind::Observe; + value.kind != Gallery_Request_Kind::Observe && + value.kind != Gallery_Request_Kind::Refresh && + value.kind != Gallery_Request_Kind::Reset_Monitoring; } else { return true; @@ -1406,7 +1439,7 @@ struct Gallery_Plot_Session::Impl { Gallery_Protocol::case_json_from_controls(scene->case_id(), scene->controls().dump(), scene->telemetry_json(), "render_2D/Kernel 独立画布已创建", - scene->frame_mode()) + scene->frame_mode(), false) }; } if (!scene) @@ -1425,6 +1458,29 @@ struct Gallery_Plot_Session::Impl { scene->case_id(), scene->frame_mode(), scene->telemetry_json()) }; } + if (request.kind == Gallery_Request_Kind::Refresh) { + if (update_client_metrics(request.message)) { + ++scheduler_revision; + scheduler_condition.notify_all(); + } + return Web_Response{ + Web_Response_Type::Json, + Gallery_Protocol::case_json_from_controls( + scene->case_id(), scene->controls().dump(), + scene->telemetry_json(), "观察数据已手动刷新", + scene->frame_mode(), true) + }; + } + if (request.kind == Gallery_Request_Kind::Reset_Monitoring) { + scene->reset_monitoring(); + return Web_Response{ + Web_Response_Type::Json, + Gallery_Protocol::case_json_from_controls( + scene->case_id(), scene->controls().dump(), + scene->telemetry_json(), "监测滑动窗口已重置", + scene->frame_mode(), true) + }; + } if (request.kind == Gallery_Request_Kind::Patch) { const auto patch = Gallery_Protocol::control_patch_request(request.message); if (!patch) @@ -1440,7 +1496,7 @@ struct Gallery_Plot_Session::Impl { Gallery_Protocol::case_json_from_controls(scene->case_id(), scene->controls().dump(), scene->telemetry_json(), "控件 API 已由后端应用", - scene->frame_mode()) + scene->frame_mode(), false) }; } const auto action = Gallery_Protocol::action_request(request.message); @@ -1466,7 +1522,7 @@ struct Gallery_Plot_Session::Impl { Web_Response_Type::Json, Gallery_Protocol::case_json_from_controls(id, scene->controls().dump(), scene->telemetry_json(), - "本图已恢复后端默认值", mode) + "本图已恢复后端默认值", mode, false) }; } bool recognized{}; @@ -1482,7 +1538,7 @@ struct Gallery_Plot_Session::Impl { Web_Response_Type::Json, Gallery_Protocol::case_json_from_controls(scene->case_id(), scene->controls().dump(), scene->telemetry_json(), notice, - scene->frame_mode()) + scene->frame_mode(), false) }; } std::optional handle_frame_request() { diff --git a/web_server/app/Gallery_Protocol.cpp b/web_server/app/Gallery_Protocol.cpp index 171f717..0efe3df 100644 --- a/web_server/app/Gallery_Protocol.cpp +++ b/web_server/app/Gallery_Protocol.cpp @@ -290,7 +290,7 @@ Json dashboard_contract() { {"presentation", "浏览器呈现"}, {"manual", "手动"} }}, {"limit_state", { - {"frequency_limited", "Kernel 频率受限"}, {"paint_limited", "PaintEvent 受限"}, + {"frequency_limited", "内核频率受限"}, {"paint_limited", "绘制事件受限"}, {"render_limited", "后台渲染受限"}, {"consumer_limited", "消费者反馈受限"}, {"unlimited", "无限制"}, {"not_applicable", "N/A"} }} @@ -303,7 +303,18 @@ Json dashboard_contract() { dashboard_field("WS 往返 ms", "client_performance.frame_round_trip_ms", "fixed", 2), dashboard_field("未呈现覆盖", "client_performance.overwritten_pixel_frames"), dashboard_field("Core 渲染 ms", "performance.last_render_ms", "fixed", 2), + dashboard_field("Core 滑动平均 ms", "performance.average_render_ms", "fixed", 2), + dashboard_field("Core P95 ms", "performance.render_p95_ms", "fixed", 2), + dashboard_field("Core P99 ms", "performance.render_p99_ms", "fixed", 2), dashboard_field("像素编码 ms", "performance.last_pixel_encode_ms", "fixed", 2), + dashboard_field("编码 P95 ms", "performance.pixel_encode_p95_ms", "fixed", 2), + dashboard_field("编码 P99 ms", "performance.pixel_encode_p99_ms", "fixed", 2), + dashboard_field("WS 平均 ms", "client_performance.frame_round_trip_average_ms", "fixed", 2), + dashboard_field("WS P95 ms", "client_performance.frame_round_trip_p95_ms", "fixed", 2), + dashboard_field("WS P99 ms", "client_performance.frame_round_trip_p99_ms", "fixed", 2), + dashboard_field("呈现平均 ms", "client_performance.display_interval_average_ms", "fixed", 2), + dashboard_field("呈现 P95 ms", "client_performance.display_interval_p95_ms", "fixed", 2), + dashboard_field("呈现 P99 ms", "client_performance.display_interval_p99_ms", "fixed", 2), dashboard_field("响应负载 MB/s", "performance.pixel_payload_megabytes_per_second", "fixed", 1), described_dashboard_field( "kernel_observer", "pending_frame_count"), @@ -331,8 +342,50 @@ Json dashboard_contract() { })} }}, {"menu_views", { - {"observer", {{"source", "kernel_observer"}}}, - {"performance", Json::object()} + {"observer", { + {"kernel", { + {"title", "内核帧观察器"}, + {"source", "kernel_observer"}, + {"descriptor", adminive::to_descriptor_json< + Json, renderive::Frame_Observer_Snapshot>()} + }}, + {"renderables_source", "renderable_observers"}, + {"renderable_fields", Json::array({ + "event", "event_time_ns", "cache_update_count", "publish_count" + })} + }}, + {"performance", { + {"renderables_source", "renderable_observers"}, + {"renderable_fields", Json::array({ + "successful_render_count", "failed_render_count", + "last_render_sequence", "last_render_duration_ns", + "total_render_duration_ns", "maximum_render_duration_ns", + "render_duration_sample_count", "render_duration_average_ns", + "render_duration_deviation_ns", "render_duration_p50_ns", + "render_duration_p95_ns", "render_duration_p99_ns", + "task_execution_count", "failed_task_count", + "last_task_duration_ns", "total_task_duration_ns", + "maximum_task_duration_ns", "task_duration_sample_count", + "task_duration_average_ns", "task_duration_deviation_ns", + "task_duration_p50_ns", "task_duration_p95_ns", + "task_duration_p99_ns", "last_task_count", + "peak_parallelism" + })}, + {"resources", Json::array({ + { + {"title", "渲染性能"}, + {"source", "performance"}, + {"descriptor", adminive::to_descriptor_json< + Json, Gallery_Render_Performance>()} + }, + { + {"title", "浏览器性能"}, + {"source", "client_performance"}, + {"descriptor", adminive::to_descriptor_json< + Json, Gallery_Client_Performance>()} + } + })} + }} }}, {"limits", { {"aria_label", "低延迟限速来源"}, {"title", "限速来源"}, @@ -341,15 +394,15 @@ Json dashboard_contract() { {"active_label", "当前瓶颈"}, {"inactive_label", "未受限"}, {"disabled_label", "已关闭"}, {"fields", Json::array({ - {{"label", "Kernel 用户频率"}, {"active_value", "frequency_limited"}, + {{"label", "内核用户频率"}, {"active_value", "frequency_limited"}, {"duration_source", described_source( "kernel_observer", "target_interval_ns")}, {"enabled_source", described_source( "kernel_observer", "frequency_limit_enabled")}}, - {{"label", "Kernel PaintEvent"}, {"active_value", "paint_limited"}, + {{"label", "内核绘制事件"}, {"active_value", "paint_limited"}, {"duration_source", described_source( "kernel_observer", "paint_duration_ns")}}, - {{"label", "Kernel 后台渲染"}, {"active_value", "render_limited"}, + {{"label", "内核后台渲染"}, {"active_value", "render_limited"}, {"duration_source", described_source( "kernel_observer", "render_duration_ns")}}, {{"label", "消费者反馈"}, {"active_value", "consumer_limited"}, @@ -360,9 +413,9 @@ Json dashboard_contract() { })} }}, {"observer", { - {"aria_label", "Kernel 低延迟全量统计"}, + {"aria_label", "内核低延迟全量统计"}, {"header", { - {"prefix", "KERNEL"}, {"suffix", "OBSERVER"}, {"event_label", "事件"}, + {"prefix", "内核"}, {"suffix", "观察器"}, {"event_label", "事件"}, {"mode", described_dashboard_field( "kernel_observer", "mode")}, {"limit", described_dashboard_field( @@ -379,9 +432,15 @@ Json dashboard_contract() { {"fields", Json::array({ dashboard_field("RAF 最新周期", "client_performance.display_interval_latest_ms", "milliseconds", 3), dashboard_field("RAF 中位周期", "client_performance.display_interval_ms", "milliseconds", 3), + dashboard_field("RAF 平均周期", "client_performance.display_interval_average_ms", "milliseconds", 3), dashboard_field("RAF P95 周期", "client_performance.display_interval_p95_ms", "milliseconds", 3), - dashboard_field("RAF P95-P50 抖动", "client_performance.display_jitter_ms", "milliseconds", 3), + dashboard_field("RAF P99 周期", "client_performance.display_interval_p99_ms", "milliseconds", 3), + dashboard_field("RAF 周期标准差", "client_performance.display_interval_deviation_ms", "milliseconds", 3), dashboard_field("WS 往返", "client_performance.frame_round_trip_ms", "milliseconds", 3), + dashboard_field("WS 往返平均", "client_performance.frame_round_trip_average_ms", "milliseconds", 3), + dashboard_field("WS 往返 P95", "client_performance.frame_round_trip_p95_ms", "milliseconds", 3), + dashboard_field("WS 往返 P99", "client_performance.frame_round_trip_p99_ms", "milliseconds", 3), + dashboard_field("WS 往返标准差", "client_performance.frame_round_trip_deviation_ms", "milliseconds", 3), dashboard_field("像素响应 FPS", "client_performance.transport_fps", "fps", 2), dashboard_field("浏览器呈现 FPS", "client_performance.presentation_fps", "fps", 2), dashboard_field("WS 缓冲", "client_performance.websocket_buffered_bytes", "bytes"), @@ -392,7 +451,7 @@ Json dashboard_contract() { dashboard_field("最近像素龄", "client_performance.last_pixel_receive_age_ms", "milliseconds", 3), dashboard_field("最近变化龄", "client_performance.last_pixel_change_age_ms", "milliseconds", 3) })}}, - {{"class_name", "latency-details"}, {"aria_label", "Kernel 各阶段等待耗时"}, + {{"class_name", "latency-details"}, {"aria_label", "内核各阶段等待耗时"}, {"fields", observer_fields(observer_detail_field)}} })} }} @@ -495,17 +554,20 @@ std::string Gallery_Protocol::case_json_from_controls( std::string_view controls_json, std::string_view telemetry_json, std::string_view notice, - Gallery_Frame_Mode frame_mode) { + Gallery_Frame_Mode frame_mode, + bool manual_refresh) { Json result = protocol_base(); - result["type"] = "case_state"; + result["type"] = manual_refresh ? "refresh_state" : "case_state"; result["case"] = case_contract(case_id); result["frame_mode"] = frame_mode_contract(frame_mode); try { - result["controls"] = { - {"resources", Json::parse(controls_json.begin(), controls_json.end())} - }; + result["controls"] = Json::parse(controls_json.begin(), controls_json.end()); } catch (const std::exception&) { - result["controls"] = {{"resources", Json::array()}}; + result["controls"] = { + {"resources", Json::array()}, + {"observers", Json::array()}, + {"task_graph", Json::object()} + }; } result["actions"] = { {"descriptor", adminive::to_descriptor_json()}, diff --git a/web_server/app/Gallery_Protocol.h b/web_server/app/Gallery_Protocol.h index 916da46..47da90f 100644 --- a/web_server/app/Gallery_Protocol.h +++ b/web_server/app/Gallery_Protocol.h @@ -33,7 +33,8 @@ public: std::string_view controls_json, std::string_view telemetry_json, std::string_view notice, - Gallery_Frame_Mode frame_mode); + Gallery_Frame_Mode frame_mode, + bool manual_refresh); [[nodiscard]] static std::optional open_request(std::string_view message); [[nodiscard]] static std::optional action_request(std::string_view message); [[nodiscard]] static std::optional control_patch_request( diff --git a/web_server/app/Web_Event.h b/web_server/app/Web_Event.h index a79d040..9941332 100644 --- a/web_server/app/Web_Event.h +++ b/web_server/app/Web_Event.h @@ -27,7 +27,15 @@ struct Set_Smoothing { bool enabled{}; }; struct Clear_Selection {}; -enum class Gallery_Request_Kind : std::uint8_t { Catalog, Open, Patch, Action, Observe }; +enum class Gallery_Request_Kind : std::uint8_t { + Catalog, + Open, + Patch, + Action, + Observe, + Refresh, + Reset_Monitoring +}; struct Gallery_Request { Gallery_Request_Kind kind = Gallery_Request_Kind::Catalog; std::string message; diff --git a/web_server/tests/Web_Bridge_Tests.cpp b/web_server/tests/Web_Bridge_Tests.cpp index 5715f66..9db8460 100644 --- a/web_server/tests/Web_Bridge_Tests.cpp +++ b/web_server/tests/Web_Bridge_Tests.cpp @@ -172,6 +172,17 @@ TEST(RenderiveWebBridge, DecodesOnlyEventMessages) { ASSERT_TRUE(observe.has_value()); EXPECT_EQ(std::get(*observe).kind, Gallery_Request_Kind::Observe); + const auto refresh = Web_Event_Adapter::decode( + R"({"category":"event","type":"gallery_refresh"})"); + ASSERT_TRUE(refresh.has_value()); + EXPECT_EQ(std::get(*refresh).kind, Gallery_Request_Kind::Refresh); + + const auto reset_monitoring = Web_Event_Adapter::decode( + R"({"category":"event","type":"gallery_reset_monitoring"})"); + ASSERT_TRUE(reset_monitoring.has_value()); + EXPECT_EQ(std::get(*reset_monitoring).kind, + Gallery_Request_Kind::Reset_Monitoring); + const auto bounded = Web_Event_Adapter::decode( R"({"category":"event","type":"resize","width":1e100,"height":-1e100})"); ASSERT_TRUE(bounded.has_value()); @@ -232,7 +243,7 @@ TEST(RenderiveWebGallery, AdminiveCatalogCoversEveryControlCaseAndThreeModes) { TEST(RenderiveWebGallery, CatalogOwnsDashboardFieldsAndDynamicBehavior) { const nlohmann::json catalog = parse_json(Gallery_Protocol::catalog_json()); const auto& dashboard = catalog.at("dashboard"); - EXPECT_EQ(dashboard.at("performance").at("fields").size(), 22U); + EXPECT_EQ(dashboard.at("performance").at("fields").size(), 33U); EXPECT_EQ(dashboard.at("limits").at("fields").size(), 4U); EXPECT_EQ(dashboard.at("observer").at("sections").size(), 4U); std::set client_sources; @@ -243,9 +254,12 @@ TEST(RenderiveWebGallery, CatalogOwnsDashboardFieldsAndDynamicBehavior) { client_sources.insert(source); } } - constexpr std::array client_fields{ - "display_interval_latest_ms", "display_interval_ms", "display_interval_p95_ms", - "display_jitter_ms", "frame_round_trip_ms", "transport_fps", "presentation_fps", + constexpr std::array client_fields{ + "display_interval_latest_ms", "display_interval_ms", "display_interval_average_ms", + "display_interval_p95_ms", "display_interval_p99_ms", "display_interval_deviation_ms", + "frame_round_trip_ms", "frame_round_trip_average_ms", "frame_round_trip_p95_ms", + "frame_round_trip_p99_ms", "frame_round_trip_deviation_ms", + "transport_fps", "presentation_fps", "websocket_buffered_bytes", "overwritten_pixel_frames", "changed_pixel_frames", "duplicate_pixel_frames", "frame_request_timeout_count", "last_pixel_receive_age_ms", "last_pixel_change_age_ms" @@ -393,6 +407,76 @@ TEST(RenderiveWebGallery, AdminiveResourcesPatchTheRealRenderableState) { EXPECT_EQ(foreign.at("type"), "error"); } +TEST(RenderiveWebGallery, ExposesEachRenderableObserverAndStableTaskTopology) { + Gallery_Plot_Session session; + const auto opened = response_json(session.handle(gallery_request( + Gallery_Request_Kind::Open, open_message("spectrum", "manual")))); + ASSERT_EQ(opened.at("type"), "case_state"); + + const auto& observers = opened.at("controls").at("observers"); + const auto& graph = opened.at("controls").at("task_graph"); + ASSERT_EQ(observers.size(), graph.at("nodes").size()); + EXPECT_GE(observers.size(), 7U); + for (const std::string_view target : {"frequency_axis", "value_axis", "spectrum"}) { + const auto found = std::find_if(observers.begin(), observers.end(), + [target](const auto& resource) { + return resource.at("target").template get() == target; + }); + ASSERT_NE(found, observers.end()) << target; + EXPECT_EQ(found->at("descriptor").at("label"), "渲染对象观察器"); + EXPECT_EQ(found->at("descriptor").at("fields").at(0) + .at("presentation").at("label"), + "最近状态事件"); + EXPECT_EQ(found->at("data").at("event"), "Render_Completed"); + EXPECT_GT(found->at("data").at("publish_count").get(), 0U); + EXPECT_GT(found->at("data").at("successful_render_count") + .get(), 0U); + EXPECT_GT(found->at("data").at("task_execution_count") + .get(), 0U); + } + + ASSERT_TRUE(graph.at("topology_id").is_string()); + EXPECT_FALSE(graph.at("topology_id").get().empty()); + EXPECT_FALSE(graph.at("nodes").empty()); + EXPECT_FALSE(graph.at("edges").empty()); + ASSERT_EQ(graph.at("renderables").size(), 3U); + const auto spectrum_graph = std::find_if( + graph.at("renderables").begin(), graph.at("renderables").end(), + [](const auto& resource) { return resource.at("target") == "spectrum"; }); + ASSERT_NE(spectrum_graph, graph.at("renderables").end()); + EXPECT_FALSE(spectrum_graph->at("graph").at("nodes").empty()); + + const auto observed = response_json(session.handle(gallery_request( + Gallery_Request_Kind::Observe, + R"({"category":"event","type":"gallery_observe"})"))); + ASSERT_EQ(observed.at("type"), "observer_state"); + EXPECT_EQ(observed.at("telemetry").at("renderable_observers").size(), + graph.at("nodes").size()); + EXPECT_FALSE(observed.at("telemetry").contains("task_graph")); + + const auto refreshed = response_json(session.handle(gallery_request( + Gallery_Request_Kind::Refresh, + R"({"category":"event","type":"gallery_refresh"})"))); + ASSERT_EQ(refreshed.at("type"), "refresh_state"); + EXPECT_EQ(refreshed.at("controls").at("observers").size(), + graph.at("nodes").size()); + EXPECT_EQ(refreshed.at("notice"), "观察数据已手动刷新"); + + const auto reset = response_json(session.handle(gallery_request( + Gallery_Request_Kind::Reset_Monitoring, + R"({"category":"event","type":"gallery_reset_monitoring"})"))); + ASSERT_EQ(reset.at("type"), "refresh_state"); + EXPECT_EQ(reset.at("notice"), "监测滑动窗口已重置"); + EXPECT_EQ(reset.at("telemetry").at("performance").at("render_sample_count"), 0); + EXPECT_EQ(reset.at("telemetry").at("performance").at("pixel_encode_sample_count"), 0); + for (const auto& observer : reset.at("telemetry").at("renderable_observers")) { + EXPECT_EQ(observer.at("data").at("successful_render_count"), 0); + EXPECT_EQ(observer.at("data").at("task_execution_count"), 0); + EXPECT_EQ(observer.at("data").at("render_duration_sample_count"), 0); + EXPECT_EQ(observer.at("data").at("task_duration_sample_count"), 0); + } +} + TEST(RenderiveWebGallery, PartitionControlRebuildsTheObservedTaskGraph) { Gallery_Plot_Session session; ASSERT_EQ(response_json(session.handle(gallery_request( @@ -406,10 +490,11 @@ TEST(RenderiveWebGallery, PartitionControlRebuildsTheObservedTaskGraph) { return invoke_action(session, "mode_render").at("telemetry").at("kernel_observer"); }; - ASSERT_EQ(patch_controls(session, - {{"partition_mode", "Fixed"}, {"partition_count", 1}}, - "spectrum").at("type"), - "case_state"); + const auto single_state = patch_controls( + session, {{"partition_mode", "Fixed"}, {"partition_count", 1}}, "spectrum"); + ASSERT_EQ(single_state.at("type"), "case_state"); + const std::string single_topology = single_state.at("controls").at("task_graph") + .at("topology_id"); const auto single = render(); EXPECT_EQ(single.at("task_execution_state"), "Completed"); EXPECT_EQ(single.at("task_completed_node_count"), @@ -417,8 +502,11 @@ TEST(RenderiveWebGallery, PartitionControlRebuildsTheObservedTaskGraph) { EXPECT_EQ(single.at("task_running_node_count"), 0); EXPECT_EQ(single.at("task_pending_node_count"), 0); - ASSERT_EQ(patch_controls(session, {{"partition_count", 4}}, "spectrum").at("type"), - "case_state"); + const auto partitioned_state = patch_controls( + session, {{"partition_count", 4}}, "spectrum"); + ASSERT_EQ(partitioned_state.at("type"), "case_state"); + EXPECT_NE(partitioned_state.at("controls").at("task_graph").at("topology_id"), + single_topology); const auto partitioned = render(); EXPECT_EQ(partitioned.at("task_graph_node_count").get(), single.at("task_graph_node_count").get() + 3U); @@ -1170,7 +1258,7 @@ TEST(RenderiveWebGallery, ConsumerFeedbackSourcesCanBeSelectedIndependentlyAndMa }).at("type"), "case_state"); auto observed = response_json(session.handle(gallery_request( Gallery_Request_Kind::Observe, - R"({"category":"event","type":"gallery_observe","client_metrics":{"transport_fps":25,"presentation_fps":60,"websocket_buffered_bytes":0,"changed_pixel_frames":1,"duplicate_pixel_frames":0,"frame_request_timeout_count":0,"frame_round_trip_ms":5,"display_interval_ms":16,"display_interval_latest_ms":16,"display_interval_p95_ms":17,"display_jitter_ms":1,"overwritten_pixel_frames":0,"last_pixel_receive_age_ms":1,"last_pixel_change_age_ms":1}})"))); + R"({"category":"event","type":"gallery_observe","client_metrics":{"transport_fps":25,"presentation_fps":60,"websocket_buffered_bytes":0,"changed_pixel_frames":1,"duplicate_pixel_frames":0,"frame_request_timeout_count":0,"frame_round_trip_ms":5,"frame_round_trip_average_ms":4.8,"frame_round_trip_deviation_ms":0.2,"frame_round_trip_p95_ms":5.1,"frame_round_trip_p99_ms":5.3,"display_interval_ms":16,"display_interval_average_ms":16.2,"display_interval_latest_ms":16,"display_interval_p95_ms":17,"display_interval_p99_ms":18,"display_interval_deviation_ms":0.5,"overwritten_pixel_frames":0,"last_pixel_receive_age_ms":1,"last_pixel_change_age_ms":1}})"))); auto observer = observed.at("telemetry").at("kernel_observer"); auto feedback = observed.at("telemetry").at("consumer_feedback"); EXPECT_EQ(feedback.at("source"), "pixel"); @@ -1289,7 +1377,7 @@ TEST(RenderiveWebGallery, ProductionLowLatencySessionRendersWithoutPixelPulls) { })); const auto observed = session.handle(gallery_request( Gallery_Request_Kind::Observe, - R"({"category":"event","type":"gallery_observe","client_metrics":{"transport_fps":120,"presentation_fps":60,"websocket_buffered_bytes":0,"changed_pixel_frames":17,"duplicate_pixel_frames":3,"frame_request_timeout_count":2,"frame_round_trip_ms":4.5,"display_interval_ms":16.7,"display_interval_latest_ms":16.9,"display_interval_p95_ms":17.4,"display_jitter_ms":0.7,"overwritten_pixel_frames":5,"last_pixel_receive_age_ms":8.5,"last_pixel_change_age_ms":12.5}})")); + R"({"category":"event","type":"gallery_observe","client_metrics":{"transport_fps":120,"presentation_fps":60,"websocket_buffered_bytes":0,"changed_pixel_frames":17,"duplicate_pixel_frames":3,"frame_request_timeout_count":2,"frame_round_trip_ms":4.5,"frame_round_trip_average_ms":4.2,"frame_round_trip_deviation_ms":0.3,"frame_round_trip_p95_ms":5.1,"frame_round_trip_p99_ms":5.4,"display_interval_ms":16.7,"display_interval_average_ms":16.8,"display_interval_latest_ms":16.9,"display_interval_p95_ms":17.4,"display_interval_p99_ms":18.2,"display_interval_deviation_ms":0.7,"overwritten_pixel_frames":5,"last_pixel_receive_age_ms":8.5,"last_pixel_change_age_ms":12.5}})")); ASSERT_TRUE(observed.has_value()); const auto telemetry = parse_json(observed->payload).at("telemetry"); const auto& performance = telemetry.at("performance"); @@ -1308,10 +1396,16 @@ TEST(RenderiveWebGallery, ProductionLowLatencySessionRendersWithoutPixelPulls) { EXPECT_EQ(telemetry.at("client_performance").at("duplicate_pixel_frames"), 3); EXPECT_EQ(telemetry.at("client_performance").at("frame_request_timeout_count"), 2); EXPECT_DOUBLE_EQ(telemetry.at("client_performance").at("frame_round_trip_ms"), 4.5); + EXPECT_DOUBLE_EQ(telemetry.at("client_performance").at("frame_round_trip_average_ms"), 4.2); + EXPECT_DOUBLE_EQ(telemetry.at("client_performance").at("frame_round_trip_deviation_ms"), 0.3); + EXPECT_DOUBLE_EQ(telemetry.at("client_performance").at("frame_round_trip_p95_ms"), 5.1); + EXPECT_DOUBLE_EQ(telemetry.at("client_performance").at("frame_round_trip_p99_ms"), 5.4); EXPECT_DOUBLE_EQ(telemetry.at("client_performance").at("display_interval_ms"), 16.7); + EXPECT_DOUBLE_EQ(telemetry.at("client_performance").at("display_interval_average_ms"), 16.8); EXPECT_DOUBLE_EQ(telemetry.at("client_performance").at("display_interval_latest_ms"), 16.9); EXPECT_DOUBLE_EQ(telemetry.at("client_performance").at("display_interval_p95_ms"), 17.4); - EXPECT_DOUBLE_EQ(telemetry.at("client_performance").at("display_jitter_ms"), 0.7); + EXPECT_DOUBLE_EQ(telemetry.at("client_performance").at("display_interval_p99_ms"), 18.2); + EXPECT_DOUBLE_EQ(telemetry.at("client_performance").at("display_interval_deviation_ms"), 0.7); EXPECT_EQ(telemetry.at("client_performance").at("overwritten_pixel_frames"), 5); EXPECT_DOUBLE_EQ(telemetry.at("client_performance").at("last_pixel_receive_age_ms"), 8.5); EXPECT_DOUBLE_EQ(telemetry.at("client_performance").at("last_pixel_change_age_ms"), 12.5); diff --git a/webapp_gallery/app.js b/webapp_gallery/app.js index 47dfd62..fca575b 100644 --- a/webapp_gallery/app.js +++ b/webapp_gallery/app.js @@ -9,7 +9,8 @@ const elements = { heroEyebrow: $("hero-eyebrow"), heroTitle: $("hero-title"), menu: $("context-menu"), menuTitle: $("menu-title"), menuComponent: $("menu-component"), menuDescription: $("menu-description"), menuBody: $("menu-body"), menuStatus: $("menu-status"), - menuClose: $("menu-close"), menuTabs: [...document.querySelectorAll(".menu-tabs button")], toast: $("toast") + menuClose: $("menu-close"), menuReset: $("menu-reset"), menuRefresh: $("menu-refresh"), + menuTabs: [...document.querySelectorAll(".menu-tabs button")], toast: $("toast") }; const query = new URLSearchParams(location.search); @@ -31,10 +32,6 @@ let activeTab = "controls"; let streamsPaused = false; let toastTimer = 0; let lastAnimationFrameAt = 0; -let displayIntervalMs = 0; -let displayIntervalLatestMs = 0; -let displayIntervalP95Ms = 0; -let displayJitterMs = 0; const displayIntervalSamples = []; const message = (type, payload = {}) => JSON.stringify({category: "event", type, ...payload}); @@ -103,11 +100,36 @@ function adminiveControls(resource) { function nestedPatch(path, value) { return [...path].reverse().reduce((result, key) => ({[key]: result}), value); } -function flatten(value, prefix = "", result = []) { - if (value !== null && typeof value === "object" && !Array.isArray(value)) { - for (const [key, child] of Object.entries(value)) flatten(child, prefix ? `${prefix}.${key}` : key, result); - } else result.push([prefix, typeof value === "object" ? JSON.stringify(value) : String(value)]); - return result; +function descriptorValue(field, value) { + const options = field.presentation?.options || []; + const option = options.find(item => String(item.value) === String(value)); + if (option) return option.label; + if (typeof value === "boolean") return value ? "启用" : "关闭"; + if (value === null || value === undefined) return "—"; + if (field.name?.endsWith("_ns")) return formatNanoseconds(value); + if (typeof value === "number") return value.toLocaleString(); + if (typeof value === "object") return JSON.stringify(value); + return String(value); +} + +function descriptorRows(descriptor, data, fieldNames = null) { + const selected = fieldNames ? new Set(fieldNames) : null; + const rows = []; + const visit = (fields, value, labels = []) => { + for (const field of fields || []) { + const presentation = field.presentation || {}; + if (!adminiveFieldVisible(presentation.visible_on, value)) continue; + const fieldLabels = [...labels, presentation.label || field.name]; + if (field.children?.length) { + visit(field.children, value?.[field.name], fieldLabels); + continue; + } + if (!selected || selected.has(field.name)) + rows.push({label: fieldLabels.join(" / "), value: descriptorValue(field, value?.[field.name])}); + } + }; + visit(descriptor?.fields, data || {}); + return rows; } function formatNanoseconds(value) { const nanoseconds = Math.max(0, Number(value) || 0); @@ -157,24 +179,30 @@ function formatDashboardField(field, telemetry) { } return {text: String(raw), title: String(raw)}; } +function rollingStatistics(values) { + if (!values.length) return {average: 0, deviation: 0, p50: 0, p95: 0, p99: 0}; + const sorted = [...values].sort((left, right) => left - right); + const average = sorted.reduce((sum, value) => sum + value, 0) / sorted.length; + const deviation = Math.sqrt(sorted.reduce((sum, value) => { + const difference = value - average; + return sum + difference * difference; + }, 0) / sorted.length); + const percentile = ratio => sorted[Math.max(0, Math.ceil(ratio * sorted.length) - 1)]; + return {average, deviation, p50: percentile(0.5), p95: percentile(0.95), p99: percentile(0.99)}; +} +function resetDisplayTiming() { + lastAnimationFrameAt = 0; + displayIntervalSamples.length = 0; +} function updateDisplayTiming(time) { if (lastAnimationFrameAt > 0) { - displayIntervalLatestMs = Math.max(0, time - lastAnimationFrameAt); - displayIntervalSamples.push({time, interval: displayIntervalLatestMs}); + displayIntervalSamples.push({ + time, + interval: Math.max(0, time - lastAnimationFrameAt) + }); } lastAnimationFrameAt = time; - while (displayIntervalSamples.length && displayIntervalSamples[0].time < time - 1000) displayIntervalSamples.shift(); - if (!displayIntervalSamples.length) { - displayIntervalMs = 0; - displayIntervalP95Ms = 0; - displayJitterMs = 0; - return; - } - const values = displayIntervalSamples.map(sample => sample.interval).sort((left, right) => left - right); - const percentile = ratio => values[Math.min(values.length - 1, Math.floor((values.length - 1) * ratio))]; - displayIntervalMs = percentile(0.5); - displayIntervalP95Ms = percentile(0.95); - displayJitterMs = Math.max(0, displayIntervalP95Ms - displayIntervalMs); + while (displayIntervalSamples.length && displayIntervalSamples[0].time < time - 10_000) displayIntervalSamples.shift(); } class GalleryCard { @@ -183,6 +211,10 @@ class GalleryCard { this.mode = mode; this.controls = []; this.actions = []; + this.observers = []; + this.taskGraph = null; + this.menuGroupState = new Map(); + this.menuRequestPending = false; this.telemetry = {}; this.frameCount = 0; this.framePending = false; @@ -191,8 +223,6 @@ class GalleryCard { this.latestPixelBuffer = null; this.transportTimes = []; this.presentationTimes = []; - this.transportFps = 0; - this.presentationFps = 0; this.lastPixelSignature = null; this.changedPixelFrames = 0; this.duplicatePixelFrames = 0; @@ -205,9 +235,9 @@ class GalleryCard { this.intersecting = false; this.backendActive = null; this.frameRequestStartedAt = 0; - this.frameRoundTripMs = 0; + this.frameRoundTripSamples = []; + this.lastTelemetryRequest = 0; this.overwrittenPixelFrames = 0; - this.lastObserveRequest = 0; this.node = elements.cardTemplate.content.firstElementChild.cloneNode(true); this.node.dataset.category = definition.category; this.node.dataset.mode = mode.id; @@ -350,6 +380,8 @@ class GalleryCard { this.ready = false; this.backendActive = null; this.framePending = false; + this.menuRequestPending = false; + if (activeCard === this) setMenuRequestState(false); clearTimeout(this.frameTimeout); this.frameTimeout = null; this.setSocketState("error", this.displayVisible() ? "连接关闭 · 自动重连" : "连接关闭 · 等待可见"); this.updateMotionStatus(); @@ -367,20 +399,26 @@ class GalleryCard { try { data = JSON.parse(raw); } catch { toast(`${this.definition.title} 返回无效 JSON`, true); return; } if (data.type === "error") { const detail = Object.values(data.field_errors || {})[0] || data.message; + this.menuRequestPending = false; + if (activeCard === this) setMenuRequestState(false); toast(detail || "后端拒绝操作", true); if (activeCard === this) elements.menuStatus.textContent = detail; return; } if (data.type === "observer_state") { this.telemetry = data.telemetry || {}; + this.observers = this.telemetry.renderable_observers || this.observers; this.updateDashboard(); - if (activeCard === this && ["observer", "performance"].includes(activeTab)) renderMenuBody(); return; } - if (data.type !== "case_state") return; + const manualRefresh = data.type === "refresh_state"; + if (data.type !== "case_state" && !manualRefresh) return; this.controls = adminiveControls(data.controls); this.actions = data.actions?.data || []; this.telemetry = data.telemetry || {}; + this.observers = this.telemetry.renderable_observers || data.controls?.observers || []; + const nextTaskGraph = data.controls?.task_graph || null; + this.taskGraph = nextTaskGraph; this.ready = true; this.backendActive = null; this.node.dataset.ready = "true"; @@ -390,7 +428,17 @@ class GalleryCard { this.setSocketState("ready", `${data.frame_mode?.strategy || "Core2"} 在线`); this.updateDashboard(); if (data.notice && !data.notice.includes("已创建")) toast(`${this.definition.title}:${data.notice}`); - if (activeCard === this) { elements.menuStatus.textContent = data.notice || "后端状态已回读"; renderMenuBody(); } + if (manualRefresh) { + this.menuRequestPending = false; + if (activeCard === this) { + rememberMenuGroups(); + renderMenuBody(); + setMenuRequestState(false); + elements.menuStatus.textContent = data.notice || "当前页已手动刷新"; + } + } else if (activeCard === this && data.notice && !data.notice.includes("已创建")) { + elements.menuStatus.textContent = `${data.notice};点击刷新读取当前页`; + } } updateDashboard() { for (const binding of this.dashboardBindings) { @@ -466,13 +514,16 @@ class GalleryCard { this.lastPixelSignature = signature; this.lastPixelReceivedAt = now; if (this.frameRequestStartedAt > 0) { - this.frameRoundTripMs = Math.max(0, now - this.frameRequestStartedAt); + this.frameRoundTripSamples.push({ + time: now, + value: Math.max(0, now - this.frameRequestStartedAt) + }); this.frameRequestStartedAt = 0; } if (this.latestPixelBuffer !== null) this.overwrittenPixelFrames++; this.latestPixelBuffer = buffer; this.setSocketState("ready", `${this.mode.strategy || "Core2"} 在线`); - this.transportFps = this.recordRate(this.transportTimes, now); + this.recordRate(this.transportTimes, now); this.frameCount++; this.frameLabel.textContent = this.frameCount.toLocaleString(); this.updateMotionStatus(now); @@ -495,7 +546,7 @@ class GalleryCard { for (let row = 0; row < height; row++) packed.set(source.subarray(row * stride, row * stride + width * 4), row * width * 4); this.context.putImageData(new ImageData(packed, width, height), 0, 0); } - this.presentationFps = this.recordRate(this.presentationTimes, time); + this.recordRate(this.presentationTimes, time); } currentRate(history, now) { while (history.length && history[0] < now - 1000) history.shift(); @@ -527,27 +578,71 @@ class GalleryCard { this.syncActivity(true); }, 1500); } - observe(time) { - if (!this.ready || !this.displayVisible() || this.socket?.readyState !== WebSocket.OPEN || time - this.lastObserveRequest < 650) return; - this.lastObserveRequest = time; - this.transportFps = this.currentRate(this.transportTimes, time); - this.presentationFps = this.currentRate(this.presentationTimes, time); - this.send("gallery_observe", {client_metrics: { - transport_fps: this.transportFps, - presentation_fps: this.presentationFps, + clientMetrics(time) { + while (this.frameRoundTripSamples.length && + this.frameRoundTripSamples[0].time < time - 10_000) + this.frameRoundTripSamples.shift(); + const roundTrip = rollingStatistics(this.frameRoundTripSamples.map(sample => sample.value)); + const display = rollingStatistics(displayIntervalSamples.map(sample => sample.interval)); + return { + transport_fps: this.currentRate(this.transportTimes, time), + presentation_fps: this.currentRate(this.presentationTimes, time), websocket_buffered_bytes: this.socket.bufferedAmount || 0, changed_pixel_frames: this.changedPixelFrames, duplicate_pixel_frames: this.duplicatePixelFrames, frame_request_timeout_count: this.frameTimeoutCount, - frame_round_trip_ms: this.frameRoundTripMs, - display_interval_ms: displayIntervalMs, - display_interval_latest_ms: displayIntervalLatestMs, - display_interval_p95_ms: displayIntervalP95Ms, - display_jitter_ms: displayJitterMs, + frame_round_trip_ms: this.frameRoundTripSamples.at(-1)?.value || 0, + frame_round_trip_average_ms: roundTrip.average, + frame_round_trip_deviation_ms: roundTrip.deviation, + frame_round_trip_p95_ms: roundTrip.p95, + frame_round_trip_p99_ms: roundTrip.p99, + display_interval_ms: display.p50, + display_interval_average_ms: display.average, + display_interval_latest_ms: displayIntervalSamples.at(-1)?.interval || 0, + display_interval_p95_ms: display.p95, + display_interval_p99_ms: display.p99, + display_interval_deviation_ms: display.deviation, overwritten_pixel_frames: this.overwrittenPixelFrames, last_pixel_receive_age_ms: this.lastPixelReceivedAt ? Math.max(0, time - this.lastPixelReceivedAt) : 0, last_pixel_change_age_ms: this.lastPixelChangeAt ? Math.max(0, time - this.lastPixelChangeAt) : 0 - }}); + }; + } + refreshMenu() { + if (!this.ready || this.menuRequestPending || + this.socket?.readyState !== WebSocket.OPEN) return; + this.menuRequestPending = true; + setMenuRequestState(true); + elements.menuStatus.textContent = "正在读取当前页数据"; + this.send("gallery_refresh", {client_metrics: this.clientMetrics(performance.now())}); + } + resetMonitoring() { + if (!this.ready || this.menuRequestPending || + this.socket?.readyState !== WebSocket.OPEN) return; + this.resetClientMonitoring(); + this.menuRequestPending = true; + setMenuRequestState(true); + elements.menuStatus.textContent = "正在重置监测滑动窗口"; + this.send("gallery_reset_monitoring"); + } + resetClientMonitoring() { + this.transportTimes.length = 0; + this.presentationTimes.length = 0; + this.changedPixelFrames = 0; + this.duplicatePixelFrames = 0; + this.frameTimeoutCount = 0; + this.frameRoundTripSamples.length = 0; + this.overwrittenPixelFrames = 0; + this.frameCount = 0; + this.frameLabel.textContent = "0"; + this.lastTelemetryRequest = 0; + resetDisplayTiming(); + } + observeTelemetry(time) { + if (!this.ready || !this.displayVisible() || + this.socket?.readyState !== WebSocket.OPEN || + time - this.lastTelemetryRequest < 650) return; + this.lastTelemetryRequest = time; + this.send("gallery_observe", {client_metrics: this.clientMetrics(time)}); } position(event) { const rect = this.canvas.getBoundingClientRect(); @@ -630,6 +725,7 @@ function openMenu(card, x, y) { elements.menuTitle.textContent = card.definition.title; elements.menuDescription.textContent = card.definition.description; elements.menuStatus.textContent = "菜单仅包含当前控件和当前帧策略可调用的 API"; + setMenuRequestState(card.menuRequestPending); elements.menuTabs.forEach(button => button.classList.toggle("active", button.dataset.tab === activeTab)); renderMenuBody(); elements.menu.hidden = false; @@ -637,7 +733,33 @@ function openMenu(card, x, y) { elements.menu.style.left = `${Math.max(gap, Math.min(x, innerWidth - rect.width - gap))}px`; elements.menu.style.top = `${Math.max(gap, Math.min(y, innerHeight - rect.height - gap))}px`; } -function closeMenu() { elements.menu.hidden = true; activeCard = null; } +function closeMenu() { + rememberMenuGroups(); + elements.menu.hidden = true; + activeCard = null; +} +function setMenuRequestState(pending) { + elements.menuReset.disabled = pending; + elements.menuRefresh.disabled = pending; + elements.menuReset.textContent = pending ? "处理中" : "↺ 重置监测"; + elements.menuRefresh.textContent = pending ? "读取中" : "↻ 刷新当前页"; +} +function groupStateKey(key) { return `${activeTab}:${key}`; } +function prepareGroup(section, key, defaultOpen = false) { + const stateKey = groupStateKey(key); + section.dataset.groupKey = stateKey; + section.open = activeCard?.menuGroupState.has(stateKey) + ? activeCard.menuGroupState.get(stateKey) + : defaultOpen; + section.addEventListener("toggle", () => { + activeCard?.menuGroupState.set(stateKey, section.open); + }); +} +function rememberMenuGroups() { + if (!activeCard) return; + for (const section of elements.menuBody.querySelectorAll("details[data-group-key]")) + activeCard.menuGroupState.set(section.dataset.groupKey, section.open); +} function renderControl(item) { const card = activeCard; const row = document.createElement("div"); row.className = "control-row"; @@ -716,26 +838,146 @@ function renderAction(item) { } function renderGroups(items, renderer) { const fragment = document.createDocumentFragment(); + let index = 0; for (const [name, children] of grouped(items)) { - const section = document.createElement("section"); section.className = "control-group"; - const title = document.createElement("h3"); title.textContent = name; + const section = document.createElement("details"); section.className = "control-group"; + prepareGroup(section, name, index++ === 0); + const title = document.createElement("summary"); + const label = document.createElement("span"); label.textContent = name; + const count = document.createElement("small"); count.textContent = `${children.length} 项`; + title.append(label, count); section.append(title, ...children.map(renderer)); fragment.append(section); } elements.menuBody.replaceChildren(fragment); } -function renderData(value) { + +function descriptorGroup(resource, fieldNames, key, open = false) { + const rows = descriptorRows(resource.descriptor, resource.data, fieldNames); + const section = document.createElement("details"); section.className = "control-group descriptor-group"; + prepareGroup(section, key, open); + const summary = document.createElement("summary"); + const title = document.createElement("span"); + title.textContent = resource.title || resource.descriptor?.label || "观察数据"; + const count = document.createElement("small"); count.textContent = `${rows.length} 项`; + summary.append(title, count); const list = document.createElement("dl"); list.className = "telemetry-grid"; - for (const [key, content] of flatten(value)) { const dt = document.createElement("dt"), dd = document.createElement("dd"); dt.textContent = key; dd.textContent = content; list.append(dt, dd); } - elements.menuBody.replaceChildren(list); + for (const row of rows) { + const dt = document.createElement("dt"), dd = document.createElement("dd"); + dt.textContent = row.label; dd.textContent = row.value; list.append(dt, dd); + } + section.append(summary, list); + return section; +} + +function renderObserverMenu() { + const view = dashboard.menu_views.observer; + const kernel = { + ...view.kernel, + data: valueAtPath(activeCard.telemetry, view.kernel.source) || {} + }; + const groups = [descriptorGroup(kernel, null, "kernel", true)]; + activeCard.observers.forEach(resource => groups.push(descriptorGroup( + resource, view.renderable_fields, `renderable:${resource.target}`))); + elements.menuBody.replaceChildren(...groups); +} + +function renderPerformanceMenu() { + const view = dashboard.menu_views.performance; + const groups = activeCard.observers.map((resource, index) => descriptorGroup( + resource, view.renderable_fields, `renderable:${resource.target}`, index === 0)); + const resources = view.resources.map(resource => ({ + ...resource, + data: valueAtPath(activeCard.telemetry, resource.source) || {} + })); + resources.forEach(resource => groups.push(descriptorGroup( + resource, null, `aggregate:${resource.source}`))); + elements.menuBody.replaceChildren(...groups); +} + +function taskGraphSvg(graph, sceneGraph = false) { + const namespace = "http://www.w3.org/2000/svg"; + const nodes = [...(graph.nodes || [])]; + const byId = new Map(nodes.map(node => [node.id, node])); + const ordered = sceneGraph && graph.paint_order?.length + ? graph.paint_order.map(id => byId.get(id)).filter(Boolean) + : nodes; + const positions = new Map(); + const nodeWidth = 196, nodeHeight = 44, rowGap = 22; + ordered.forEach((node, index) => { + const lane = sceneGraph && node.kind === "control" ? 1 : 0; + positions.set(node.id, {x: 18 + lane * 234, y: 18 + index * (nodeHeight + rowGap)}); + }); + const width = sceneGraph ? 468 : 232; + const height = Math.max(82, ordered.length * (nodeHeight + rowGap) + 18); + const svg = document.createElementNS(namespace, "svg"); + svg.classList.add("task-graph-svg"); + svg.setAttribute("viewBox", `0 0 ${width} ${height}`); + svg.setAttribute("role", "img"); + svg.setAttribute("aria-label", sceneGraph ? "场景任务图" : "控件内部任务图"); + for (const edge of graph.edges || []) { + const from = positions.get(edge.from), to = positions.get(edge.to); + if (!from || !to) continue; + const path = document.createElementNS(namespace, "path"); + const fromX = from.x + nodeWidth / 2, fromY = from.y + nodeHeight; + const toX = to.x + nodeWidth / 2, toY = to.y; + const bend = Math.max(14, (toY - fromY) * .45); + path.setAttribute("d", `M ${fromX} ${fromY} C ${fromX} ${fromY + bend}, ${toX} ${toY - bend}, ${toX} ${toY}`); + path.dataset.kind = edge.kind || "dependency"; + path.classList.add("task-edge"); + svg.append(path); + } + for (const node of ordered) { + const position = positions.get(node.id); + const group = document.createElementNS(namespace, "g"); + group.classList.add("task-node"); group.dataset.kind = node.kind || "task"; + const rect = document.createElementNS(namespace, "rect"); + rect.setAttribute("x", position.x); rect.setAttribute("y", position.y); + rect.setAttribute("width", nodeWidth); rect.setAttribute("height", nodeHeight); + const title = document.createElementNS(namespace, "title"); title.textContent = node.label; + const text = document.createElementNS(namespace, "text"); + text.setAttribute("x", position.x + 10); text.setAttribute("y", position.y + 27); + const order = node.paint_order ? `${node.paint_order}. ` : ""; + const label = `${order}${node.label}`; + text.textContent = label.length > 24 ? `${label.slice(0, 23)}…` : label; + group.append(rect, title, text); svg.append(group); + } + return svg; +} + +function graphGroup(title, graph, key, sceneGraph = false, open = false) { + const section = document.createElement("details"); section.className = "control-group graph-group"; + prepareGroup(section, key, open); + const summary = document.createElement("summary"); + const label = document.createElement("span"); label.textContent = title; + const count = document.createElement("small"); + count.textContent = `${graph.nodes?.length || 0} 节点 · ${graph.edges?.length || 0} 连线`; + summary.append(label, count); + const canvas = document.createElement("div"); canvas.className = "task-graph-canvas"; + canvas.append(taskGraphSvg(graph, sceneGraph)); + section.append(summary, canvas); + return section; +} + +function renderTaskGraphMenu() { + if (!activeCard.taskGraph) { + elements.menuBody.textContent = "当前场景尚未返回任务图"; + return; + } + const view = document.createElement("div"); view.className = "task-graph-view"; + view.append(graphGroup("场景依赖与绘制顺序", activeCard.taskGraph, + "scene", true, true)); + for (const resource of activeCard.taskGraph.renderables || []) + view.append(graphGroup(resource.title, resource.graph, + `renderable:${resource.target}`)); + elements.menuBody.replaceChildren(view); } function renderMenuBody() { if (!activeCard) return; if (activeTab === "actions") renderGroups(activeCard.actions, renderAction); else if (activeTab === "controls") renderGroups(activeCard.controls, renderControl); - else { - const view = dashboard.menu_views[activeTab]; - renderData(view.source ? valueAtPath(activeCard.telemetry, view.source) || {} : activeCard.telemetry); - } + else if (activeTab === "observer") renderObserverMenu(); + else if (activeTab === "performance") renderPerformanceMenu(); + else if (activeTab === "task_graph") renderTaskGraphMenu(); } function buildCatalog(data) { @@ -778,7 +1020,7 @@ function loop(time) { card.presentLatest(time); card.updateMotionStatus(time); if (card.mode.request_on_animation_frame) card.requestFrame(time); - card.observe(time); + card.observeTelemetry(time); } requestAnimationFrame(loop); } @@ -789,16 +1031,18 @@ elements.streamToggle.addEventListener("click", () => { syncCardActivity(); }); elements.menuClose.addEventListener("click", closeMenu); -elements.menuTabs.forEach(button => button.addEventListener("click", () => { activeTab = button.dataset.tab; elements.menuTabs.forEach(item => item.classList.toggle("active", item === button)); renderMenuBody(); })); +elements.menuReset.addEventListener("click", () => activeCard?.resetMonitoring()); +elements.menuRefresh.addEventListener("click", () => activeCard?.refreshMenu()); +elements.menuTabs.forEach(button => button.addEventListener("click", () => { + rememberMenuGroups(); + activeTab = button.dataset.tab; + elements.menuTabs.forEach(item => item.classList.toggle("active", item === button)); + renderMenuBody(); +})); document.addEventListener("keydown", event => { if (event.key === "Escape" && !elements.menu.hidden) closeMenu(); }); document.addEventListener("pointerdown", event => { if (!elements.menu.hidden && !elements.menu.contains(event.target) && !event.target.closest(".open-menu")) closeMenu(); }); document.addEventListener("visibilitychange", () => { - lastAnimationFrameAt = 0; - displayIntervalMs = 0; - displayIntervalLatestMs = 0; - displayIntervalP95Ms = 0; - displayJitterMs = 0; - displayIntervalSamples.length = 0; + resetDisplayTiming(); syncCardActivity(); }); window.addEventListener("beforeunload", () => { for (const page of pages.values()) for (const card of page.cards) { card.disposed = true; clearTimeout(card.frameTimeout); clearTimeout(card.reconnectTimer); card.send("hide"); card.socket?.close(); } }); diff --git a/webapp_gallery/index.html b/webapp_gallery/index.html index 0a96a4c..d52b7b9 100644 --- a/webapp_gallery/index.html +++ b/webapp_gallery/index.html @@ -48,9 +48,14 @@ + diff --git a/webapp_gallery/styles.css b/webapp_gallery/styles.css index b124991..82f24f1 100644 --- a/webapp_gallery/styles.css +++ b/webapp_gallery/styles.css @@ -31,7 +31,7 @@ h1 { margin: 0; font-size: clamp(17px,2vw,23px); } .motion-status[data-state="duplicate"] { color:var(--warning); }.motion-status[data-state="duplicate"] i { background:var(--warning); } .motion-status[data-state="stalled"] { color:var(--danger); }.motion-status[data-state="stalled"] i { background:var(--danger); } @keyframes motion-pulse { to { opacity:.35; transform:scale(.72); } } -.quiet-button, .open-menu, .frame-button, .icon-button, .menu-tabs button, .category-filter button, .mode-tabs button, .action-button { border: 1px solid var(--line); border-radius: 8px; background: var(--panel2); cursor: pointer; transition: .16s border-color,.16s background,.16s transform; } +.quiet-button, .open-menu, .frame-button, .icon-button, .menu-tabs button, .menu-refresh-bar button, .category-filter button, .mode-tabs button, .action-button { border: 1px solid var(--line); border-radius: 8px; background: var(--panel2); cursor: pointer; transition: .16s border-color,.16s background,.16s transform; } button:hover { border-color: var(--accent); } button:active { transform: translateY(1px); } .quiet-button { padding: 9px 13px; font-size: 12px; } .hero { display: grid; grid-template-columns: minmax(300px,1fr) minmax(430px,.9fr); align-items: end; gap: 40px; max-width: 1680px; margin: auto; padding: clamp(32px,5vw,64px) clamp(18px,4vw,56px) 28px; } @@ -73,12 +73,15 @@ button:hover { border-color: var(--accent); } button:active { transform: transla .card-footer { border-top:1px solid var(--line); background:#0000001e; }.card-footer code { color:var(--accent); font-size:10px; }.card-footer > div { display:flex; gap:7px; }.open-menu,.frame-button { padding:7px 9px; color:var(--muted); font-size:10px; }.frame-button { color:var(--text); } .context-menu { position:fixed; z-index:100; width:min(570px,calc(100vw - 24px)); max-height:min(840px,calc(100vh - 24px)); overflow:hidden; border:1px solid var(--strong); border-radius:12px; background:#0f131afa; box-shadow:0 30px 90px #0000009e; backdrop-filter:blur(22px); }.context-menu[hidden] { display:none; } .menu-header { display:flex; justify-content:space-between; gap:18px; padding:16px 18px 13px; border-bottom:1px solid var(--line); }.menu-header h2 { margin-bottom:6px; font-size:19px; }.menu-header p:last-child { margin:0; color:var(--muted); font-size:10px; line-height:1.5; }.icon-button { flex:0 0 auto; width:31px; height:31px; font-size:20px; } -.menu-tabs { display:grid; grid-template-columns:repeat(4,1fr); padding:8px; border-bottom:1px solid var(--line); }.menu-tabs button { padding:8px 4px; border-color:transparent; color:var(--muted); background:transparent; font-size:10px; }.menu-tabs button.active { color:var(--text); border-color:var(--strong); background:var(--panel2); } -.menu-body { max-height:calc(min(840px,100vh - 24px) - 190px); overflow:auto; padding:12px; overscroll-behavior:contain; }.control-group + .control-group { margin-top:15px; }.control-group h3 { position:sticky; z-index:1; top:-12px; margin:0 -2px 6px; padding:9px 4px 7px; color:var(--accent); background:#0f131af5; font:700 9px/1 ui-monospace,monospace; letter-spacing:.1em; } +.menu-tabs { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); padding:8px; border-bottom:1px solid var(--line); }.menu-tabs button { min-width:0; padding:8px 3px; border-color:transparent; color:var(--muted); background:transparent; font-size:9px; white-space:normal; }.menu-tabs button.active { color:var(--text); border-color:var(--strong); background:var(--panel2); } +.menu-refresh-bar { display:flex; justify-content:flex-end; gap:8px; padding:8px 12px 0; }.menu-refresh-bar button { min-width:112px; padding:7px 10px; color:var(--accent); font-size:10px; }.menu-refresh-bar button:disabled { cursor:wait; opacity:.55; } +.menu-body { max-height:calc(min(840px,100vh - 24px) - 232px); overflow:auto; padding:12px; overscroll-behavior:contain; }.control-group { border-bottom:1px solid var(--line); }.control-group + .control-group { margin-top:8px; }.control-group > summary { position:sticky; z-index:2; top:-12px; display:flex; align-items:center; justify-content:space-between; gap:12px; min-height:38px; padding:9px 8px; color:var(--accent); background:#111720f8; cursor:pointer; font:700 10px/1.35 ui-monospace,monospace; list-style-position:inside; }.control-group > summary span { min-width:0; overflow-wrap:anywhere; }.control-group > summary small { flex:0 0 auto; color:var(--muted); font-size:8px; font-weight:600; }.control-group[open] > summary { border-bottom:1px solid var(--line); color:var(--text); } .control-row { display:grid; grid-template-columns:minmax(145px,1fr) minmax(130px,.75fr); gap:13px; align-items:center; min-height:49px; padding:8px 9px; border-top:1px solid #ffffff0e; }.control-copy label,.action-copy strong { display:block; margin-bottom:4px; font-size:11px; }.control-copy code,.action-copy code { display:block; overflow:hidden; color:var(--muted); font-size:9px; text-overflow:ellipsis; white-space:nowrap; }.control-input { width:100%; min-width:0; padding:7px 8px; border:1px solid var(--strong); border-radius:6px; outline:none; color:var(--text); background:#090d13; font-size:10px; }.control-input:focus { border-color:var(--accent); }input[type="color"].control-input { height:34px; padding:3px; }input[type="checkbox"].control-input { justify-self:end; width:38px; height:20px; accent-color:var(--accent); } .action-row { display:flex; align-items:center; justify-content:space-between; gap:13px; padding:10px 9px; border-top:1px solid #ffffff0e; }.action-copy { min-width:0; }.action-controls { display:flex; gap:7px; align-items:center; }.action-controls input { width:105px; }.action-button { padding:7px 10px; color:#07110f; border-color:var(--accent); background:var(--accent); font-size:10px; font-weight:700; } .telemetry-grid { display:grid; grid-template-columns:minmax(165px,.7fr) 1fr; margin:0; border:1px solid var(--line); }.telemetry-grid dt,.telemetry-grid dd { margin:0; padding:8px 10px; border-bottom:1px solid var(--line); font:10px/1.4 ui-monospace,monospace; overflow-wrap:anywhere; }.telemetry-grid dt { color:var(--muted); background:#ffffff06; }.telemetry-grid dd { color:var(--accent); } +.descriptor-group .telemetry-grid { border:0; } +.task-graph-view { min-width:0; }.task-graph-canvas { overflow:auto; padding:8px; border-inline:1px solid var(--line); background:#090d13; }.task-graph-svg { display:block; width:100%; min-width:440px; height:auto; }.task-edge { fill:none; stroke:#6aa9ff; stroke-width:1.5; opacity:.72; }.task-edge[data-kind="display"] { stroke:#ffd166; stroke-dasharray:5 4; }.task-node rect { fill:#161d27; stroke:#455468; rx:5; }.task-node[data-kind="control"] rect { fill:#10251f; stroke:#45ddbe; }.task-node[data-kind="layer"] rect { fill:#211c11; stroke:#d8a94b; }.task-node text { fill:#e9eef5; font:10px/1 ui-monospace,monospace; letter-spacing:0; pointer-events:none; }.graph-group > summary small { white-space:nowrap; } .menu-footer { display:flex; justify-content:space-between; gap:14px; padding:10px 14px; border-top:1px solid var(--line); color:var(--muted); font-size:9px; }.menu-footer code { color:var(--accent); } .toast { position:fixed; z-index:140; left:50%; bottom:22px; max-width:min(560px,calc(100vw - 30px)); padding:11px 15px; border:1px solid var(--strong); border-radius:8px; background:var(--panel2); box-shadow:0 18px 50px #00000073; transform:translateX(-50%); font-size:11px; }.toast[data-error="true"] { border-color:var(--danger); } @media (max-width:980px) { .hero { grid-template-columns:1fr; }.gallery { grid-template-columns:1fr; }.mode-tabs button { flex-direction:column; align-items:flex-start; }.page-header { align-items:flex-start; flex-direction:column; }.page-description { text-align:left; } } -@media (max-width:650px) { .topbar { align-items:flex-start; }.topbar-actions { align-items:flex-end; flex-direction:column; }.connection-status span { display:none; }.metrics { grid-template-columns:repeat(2,1fr); }.mode-tabs { grid-template-columns:1fr; }.mode-tabs button { flex-direction:row; }.card-meta { grid-template-columns:1fr; }.performance-strip { grid-template-columns:repeat(auto-fit,minmax(92px,1fr)); }.limit-flags { grid-template-columns:1fr; }.observer-counters { grid-template-columns:repeat(auto-fit,minmax(80px,1fr)); }.latency-summary,.client-summary,.latency-details { grid-template-columns:repeat(auto-fit,minmax(100px,1fr)); }.context-menu { inset:auto 6px 6px!important; width:auto; max-height:calc(100vh - 12px); } } +@media (max-width:650px) { .topbar { align-items:flex-start; }.topbar-actions { align-items:flex-end; flex-direction:column; }.connection-status span { display:none; }.metrics { grid-template-columns:repeat(2,1fr); }.mode-tabs { grid-template-columns:1fr; }.mode-tabs button { flex-direction:row; }.card-meta { grid-template-columns:1fr; }.performance-strip { grid-template-columns:repeat(auto-fit,minmax(92px,1fr)); }.limit-flags { grid-template-columns:1fr; }.observer-counters { grid-template-columns:repeat(auto-fit,minmax(80px,1fr)); }.latency-summary,.client-summary,.latency-details { grid-template-columns:repeat(auto-fit,minmax(100px,1fr)); }.context-menu { inset:auto 6px 6px!important; width:auto; max-height:calc(100vh - 12px); }.telemetry-grid { grid-template-columns:minmax(120px,.8fr) 1fr; }.menu-tabs button { font-size:8px; } }