diff --git a/.gitignore b/.gitignore index 1d77899..babc63e 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ /third_party/Adminive/CMakeFiles/ /third_party/datoviz/ /third_party/ +/webapp_gallery/old/ +/web_server/old/ diff --git a/kernel/src/kernel/double_buffer/Dependency_Graph_Storage.hpp b/kernel/src/kernel/double_buffer/Dependency_Graph_Storage.hpp index c06c4ee..38fa598 100644 --- a/kernel/src/kernel/double_buffer/Dependency_Graph_Storage.hpp +++ b/kernel/src/kernel/double_buffer/Dependency_Graph_Storage.hpp @@ -375,5 +375,11 @@ struct Root::Builder { std::expected validate() const { return dependency_graph_storage.validate(); } +protected: + /* 派生 Builder 在完成 build 后绑定协作对象时,按 Tag 访问其 Private 层;不公开内部指针。 */ + template + [[nodiscard]] static Private_Access private_access(Target* target) noexcept { + return Private_Access{static_cast(*target->d)}; + } }; } diff --git a/kernel/src/kernel/event.hpp b/kernel/src/kernel/event.hpp index 72bddf7..08b3506 100644 --- a/kernel/src/kernel/event.hpp +++ b/kernel/src/kernel/event.hpp @@ -17,14 +17,10 @@ enum class Event_Type : std::uint8_t { }; /* 所有输入事件的公共业务基类。 */ struct Event { - explicit Event(Event_Type value) : type(value) {} - virtual ~Event() = default; - void accept() const noexcept { - accepted = true; - } - [[nodiscard]] bool is_accepted() const noexcept { - return accepted; - } + explicit Event(Event_Type value); + virtual ~Event(); + void accept() const noexcept; + [[nodiscard]] bool is_accepted() const noexcept; Event_Type type; /* 事件种类;构造后保持不变。 */ private: mutable bool accepted{}; /* 处理链是否已经消费事件。 */ @@ -50,10 +46,7 @@ enum class Keyboard_Modifier : std::uint8_t { alt = 1 << 2, meta = 1 << 3 }; -[[nodiscard]] constexpr Keyboard_Modifier operator|(Keyboard_Modifier left, - Keyboard_Modifier right) noexcept { - return static_cast(static_cast(left) | static_cast(right)); -} +[[nodiscard]] constexpr Keyboard_Modifier operator|(Keyboard_Modifier left, Keyboard_Modifier right) noexcept; /* 不依赖具体坐标类型的指针事件查询能力。 */ struct Pointer_Event_Capability { virtual ~Pointer_Event_Capability() = default; @@ -73,34 +66,34 @@ struct Wheel_Event_Capability { /* 带局部位置、全局位置、按键和修饰键的指针事件。 */ template struct Basic_Pointer_Event : Event, Pointer_Event_Capability { - explicit Basic_Pointer_Event(Event_Type value = Event_Type::pointer_move) : Event(value) {} + explicit Basic_Pointer_Event(Event_Type value = Event_Type::pointer_move); Point position{}; /* 事件接收对象局部坐标。 */ Point global_position{}; /* 全局窗口坐标。 */ Mouse_Button button{Mouse_Button::none}; /* 本次按下或释放的按键。 */ Mouse_Button_Mask buttons{}; /* 事件发生时保持按下的按键集合。 */ Keyboard_Modifier modifiers{Keyboard_Modifier::none}; /* 事件发生时的键盘修饰键集合。 */ - [[nodiscard]] double position_x() const noexcept override { return static_cast(position.x); } - [[nodiscard]] double position_y() const noexcept override { return static_cast(position.y); } - [[nodiscard]] Mouse_Button pointer_button() const noexcept override { return button; } - [[nodiscard]] Keyboard_Modifier keyboard_modifiers() const noexcept override { return modifiers; } + [[nodiscard]] double position_x() const noexcept override; + [[nodiscard]] double position_y() const noexcept override; + [[nodiscard]] Mouse_Button pointer_button() const noexcept override; + [[nodiscard]] Keyboard_Modifier keyboard_modifiers() const noexcept override; }; /* 带角度增量和像素增量的滚轮事件。 */ template struct Basic_Wheel_Event : Basic_Pointer_Event, Wheel_Event_Capability { - Basic_Wheel_Event() : Basic_Pointer_Event(Event_Type::wheel) {} + Basic_Wheel_Event(); double angle_delta_x{}; /* 水平方向滚轮角度增量。 */ double angle_delta_y{}; /* 垂直方向滚轮角度增量。 */ double pixel_delta_x{}; /* 水平方向高精度像素增量。 */ double pixel_delta_y{}; /* 垂直方向高精度像素增量。 */ - [[nodiscard]] double pixel_delta_x_value() const noexcept override { return pixel_delta_x; } - [[nodiscard]] double pixel_delta_y_value() const noexcept override { return pixel_delta_y; } - [[nodiscard]] double angle_delta_x_value() const noexcept override { return angle_delta_x; } - [[nodiscard]] double angle_delta_y_value() const noexcept override { return angle_delta_y; } + [[nodiscard]] double pixel_delta_x_value() const noexcept override; + [[nodiscard]] double pixel_delta_y_value() const noexcept override; + [[nodiscard]] double angle_delta_x_value() const noexcept override; + [[nodiscard]] double angle_delta_y_value() const noexcept override; }; /* 带旧尺寸和新尺寸的调整事件。 */ template struct Basic_Resize_Event : Event { - Basic_Resize_Event() : Event(Event_Type::resize) {} + Basic_Resize_Event(); Size old_size{}; /* 调整前尺寸。 */ Size new_size{}; /* 调整后尺寸。 */ }; @@ -118,10 +111,11 @@ enum class Key : std::uint16_t { }; /* 键盘按下或释放事件。 */ struct Key_Event : Event { - explicit Key_Event(Event_Type value) : Event(value) {} + explicit Key_Event(Event_Type value); Key key{Key::unknown}; /* 标准化按键。 */ std::uint32_t native_key{}; /* 平台原生按键编码。 */ Keyboard_Modifier modifiers{Keyboard_Modifier::none}; /* 事件发生时的修饰键集合。 */ bool auto_repeat{}; /* 是否由系统自动重复产生。 */ }; } +#include "event.ipp" diff --git a/kernel/src/kernel/visual_common.hpp b/kernel/src/kernel/visual_common.hpp new file mode 100644 index 0000000..07e13bf --- /dev/null +++ b/kernel/src/kernel/visual_common.hpp @@ -0,0 +1,28 @@ +#pragma once +#include +namespace aethera { +/* 与绘制维度和后端无关的非预乘 RGBA 颜色。 */ +struct Color { + std::uint8_t red{}; /* 红色通道,范围为 0 到 255。 */ + std::uint8_t green{}; /* 绿色通道,范围为 0 到 255。 */ + std::uint8_t blue{}; /* 蓝色通道,范围为 0 到 255。 */ + std::uint8_t alpha{255}; /* 不透明度通道,范围为 0 到 255。 */ + [[nodiscard]] static constexpr Color transparent() noexcept; + [[nodiscard]] static constexpr Color black() noexcept; + [[nodiscard]] static constexpr Color white() noexcept; + [[nodiscard]] static constexpr Color red_color() noexcept; + [[nodiscard]] static constexpr Color green_color() noexcept; + [[nodiscard]] static constexpr Color yellow() noexcept; + bool operator==(const Color&) const = default; +}; +using Duration_Milliseconds = std::uint64_t; +/* 返回进程内单调时钟的毫秒刻度,只用于计算持续时间。 */ +[[nodiscard]] Duration_Milliseconds monotonic_milliseconds() noexcept; +/* 一天内的墙钟时间;不携带时区和日期。 */ +struct Time_Of_Day { + std::int64_t milliseconds{-1}; /* 自当天零点起的毫秒数;负值表示无效。 */ + [[nodiscard]] bool valid() const noexcept; + bool operator==(const Time_Of_Day&) const = default; +}; +} +#include "visual_common.ipp" diff --git a/kernel/src/kernel/visual_common.ipp b/kernel/src/kernel/visual_common.ipp new file mode 100644 index 0000000..d8c8828 --- /dev/null +++ b/kernel/src/kernel/visual_common.ipp @@ -0,0 +1,12 @@ +#pragma once +#include +namespace aethera { +constexpr Color Color::transparent() noexcept { return {0, 0, 0, 0}; } +constexpr Color Color::black() noexcept { return {0, 0, 0, 255}; } +constexpr Color Color::white() noexcept { return {255, 255, 255, 255}; } +constexpr Color Color::red_color() noexcept { return {255, 0, 0, 255}; } +constexpr Color Color::green_color() noexcept { return {0, 255, 0, 255}; } +constexpr Color Color::yellow() noexcept { return {255, 255, 0, 255}; } +inline Duration_Milliseconds monotonic_milliseconds() noexcept { return static_cast(std::chrono::duration_cast(std::chrono::steady_clock::now().time_since_epoch()).count()); } +inline bool Time_Of_Day::valid() const noexcept { return milliseconds >= 0 && milliseconds < 24LL * 60LL * 60LL * 1000LL; } +} diff --git a/render_2D/render_2D/base/Types.hpp b/render_2D/render_2D/base/Types.hpp index 7d8a0c9..7a8559f 100644 --- a/render_2D/render_2D/base/Types.hpp +++ b/render_2D/render_2D/base/Types.hpp @@ -1,4 +1,5 @@ #pragma once +#include #include #include #include @@ -77,20 +78,6 @@ struct Rect_F { } bool operator==(const Rect_F&) const = default; }; -/* 非预乘 RGBA 颜色。 */ -struct Color { - std::uint8_t red{}; /* 红色通道。 */ - std::uint8_t green{}; /* 绿色通道。 */ - std::uint8_t blue{}; /* 蓝色通道。 */ - std::uint8_t alpha{255}; /* 不透明度通道。 */ - [[nodiscard]] static constexpr Color transparent() noexcept { return {0, 0, 0, 0}; } - [[nodiscard]] static constexpr Color black() noexcept { return {0, 0, 0, 255}; } - [[nodiscard]] static constexpr Color white() noexcept { return {255, 255, 255, 255}; } - [[nodiscard]] static constexpr Color red_color() noexcept { return {255, 0, 0, 255}; } - [[nodiscard]] static constexpr Color green_color() noexcept { return {0, 255, 0, 255}; } - [[nodiscard]] static constexpr Color yellow() noexcept { return {255, 255, 0, 255}; } - bool operator==(const Color&) const = default; -}; using Pixel = std::uint32_t; [[nodiscard]] constexpr Pixel premultiply(Color color) noexcept { const auto red = static_cast((color.red * color.alpha + 127) / 255); @@ -141,14 +128,6 @@ struct Number_Locale { char decimal_point{'.'}; /* 替换默认小数点的字符。 */ bool operator==(const Number_Locale&) const = default; }; -/* 一天内的时间值。 */ -struct Time_Of_Day { - std::int64_t milliseconds{-1}; /* 自当天零点起的毫秒数;负值表示无效。 */ - [[nodiscard]] bool valid() const noexcept { - return milliseconds >= 0 && milliseconds < 24LL * 60LL * 60LL * 1000LL; - } - bool operator==(const Time_Of_Day&) const = default; -}; enum class Image_Interpolation_Mode : std::uint8_t { nearest, bilinear, bicubic }; enum class Line_Interpolation_Mode : std::uint8_t { nearest_sample, diff --git a/render_2D/render_2D/plottable/Constellation_Diagram.ipp b/render_2D/render_2D/plottable/Constellation_Diagram.ipp index d085154..f185fc0 100644 --- a/render_2D/render_2D/plottable/Constellation_Diagram.ipp +++ b/render_2D/render_2D/plottable/Constellation_Diagram.ipp @@ -1,7 +1,6 @@ #pragma once #include "common/Curve_Plot.hpp" #include -#include #include namespace aethera::render_2d { struct Constellation_Diagram::Private : Prev_Private { @@ -33,19 +32,17 @@ struct Constellation_Diagram::Private : Prev_Private { /* CRTP 覆盖:本类状态写入后标记 Prepare 数据失效。 */ template void after_prop_set(Object* object, Member Owner::* member, Prop_Access states); template void before_advance(Object* object, Prop_Type* pending_prop, State_Access pending_states, const Prop_Type* current_prop, State_Access current_states); - [[nodiscard]] static Plot_Duration_Milliseconds now_ms(); }; template Constellation_Diagram::Builder::Builder(Scene_Object* scene_value, Axis_Object* i_axis_value, Axis_Object* q_axis_value) : Base(), scene(scene_value), i_axis(i_axis_value), q_axis(q_axis_value) {} template std::expected, Dependency_Graph_Error> Constellation_Diagram::Builder::build() { auto result = Base::build(); if (!result) return std::unexpected(result.error()); auto plot = std::move(result).value(); static_cast(*plot->d).bind_sources(scene, i_axis, q_axis); auto graph_result = scene->template edit_dependency_graph([&](auto& prepare, auto& paint) { prepare.add(i_axis); prepare.add(q_axis); prepare.add(plot.get()); prepare.template add_prop_dependency(plot.get(), scene); prepare.template add_prop_dependency(plot.get(), i_axis); prepare.template add_prop_dependency(plot.get(), i_axis); prepare.template add_prop_dependency(plot.get(), q_axis); prepare.template add_prop_dependency(plot.get(), q_axis); paint.add(i_axis); paint.add(q_axis); paint.add(plot.get()); }); if (!graph_result) return std::unexpected(graph_result.error()); return std::move(plot); } -inline Plot_Duration_Milliseconds Constellation_Diagram::Private::now_ms() { return static_cast(std::chrono::duration_cast(std::chrono::steady_clock::now().time_since_epoch()).count()); } template -void Constellation_Diagram::Private::prepare_data(Object* object) { const auto& state = object->template read_prop(); const auto& i_layout = i_axis->template read_prop(); const auto& q_layout = q_axis->template read_prop(); prepared = {}; prepared.canvas = scene->template read_prop().viewport; if (prepared.canvas.empty() || i_layout.orientation == q_layout.orientation) return; const auto current = now_ms(); for (const auto& value : state.points) if (current - value.submitted_at_ms <= state.point_lifetime_ms) prepared.points.push_back(detail::map_plot_point(i_axis, value.point.x, q_axis, value.point.y, i_layout.orientation)); const int count = static_cast(state.type); const Plot_Coordinate center_i = state.i_range.center(); const Plot_Coordinate center_q = state.q_range.center(); const Plot_Coordinate radius = std::min(state.i_range.size(), state.q_range.size()) * 0.4; for (int index = 0; index < count; ++index) { const Plot_Ratio angle = state.phase_offset_radians + 2.0 * std::numbers::pi * index / count; prepared.anchors.push_back(detail::map_plot_point(i_axis, center_i + std::cos(angle) * radius, q_axis, center_q + std::sin(angle) * radius, i_layout.orientation)); } prepared.valid = true; object->template mark_dirty(); } +void Constellation_Diagram::Private::prepare_data(Object* object) { const auto& state = object->template read_prop(); const auto& i_layout = i_axis->template read_prop(); const auto& q_layout = q_axis->template read_prop(); prepared = {}; prepared.canvas = scene->template read_prop().viewport; if (prepared.canvas.empty() || i_layout.orientation == q_layout.orientation) return; const auto current = monotonic_milliseconds(); for (const auto& value : state.points) if (current - value.submitted_at_ms <= state.point_lifetime_ms) prepared.points.push_back(detail::map_plot_point(i_axis, value.point.x, q_axis, value.point.y, i_layout.orientation)); const int count = static_cast(state.type); const Plot_Coordinate center_i = state.i_range.center(); const Plot_Coordinate center_q = state.q_range.center(); const Plot_Coordinate radius = std::min(state.i_range.size(), state.q_range.size()) * 0.4; for (int index = 0; index < count; ++index) { const Plot_Ratio angle = state.phase_offset_radians + 2.0 * std::numbers::pi * index / count; prepared.anchors.push_back(detail::map_plot_point(i_axis, center_i + std::cos(angle) * radius, q_axis, center_q + std::sin(angle) * radius, i_layout.orientation)); } prepared.valid = true; object->template mark_dirty(); } template void Constellation_Diagram::Private::paint(Object* object) { const auto& state = object->template read_prop(); auto& cache = object->template pending_buffer(); cache.ensure_size(prepared.canvas); cache.clear(); if (!prepared.valid) return; detail::Painter painter(cache, prepared.canvas); for (const auto& anchor : prepared.anchors) painter.circle(anchor, 4.0, Pen{state.anchor_color}, Brush{state.anchor_color, Brush_Style::solid}); for (const auto& point : prepared.points) painter.circle(point, 2.0, Pen{state.point_color}, Brush{state.point_color, Brush_Style::solid}); } template void Constellation_Diagram::Private::after_prop_set(Object* object, Member Owner::*, Prop_Access) { if constexpr (std::same_as) object->template mark_dirty(); } template void Constellation_Diagram::Private::before_advance(Object*, Prop_Type*, State_Access pending_states, const Prop_Type* current_prop, State_Access) { pending_states.template get().point_count = static_cast(*current_prop).points.size(); } template -const Constellation_Diagram::Private::Dispatch& Constellation_Diagram::Private::dispatch_for() { static const Dispatch value{[](Root* root, Point_F point) { auto* object = static_cast(root); const auto submitted = Private::now_ms(); object->template update_prop<&Prop::points>([=](Prop_Access props) { auto& state = props.template get(); state.points.erase(std::remove_if(state.points.begin(), state.points.end(), [=](const auto& value) { return submitted - value.submitted_at_ms > state.point_lifetime_ms; }), state.points.end()); state.points.push_back({point, submitted}); }); }, [](const Root* root) { return static_cast(root)->template read_prop().points.size(); }, [](Root* root) { auto* object = static_cast(root); auto& data = static_cast(*object->d); const auto& state = object->template read_prop(); const Plot_Coordinate size = std::max(state.i_range.size(), state.q_range.size()); const Plot_Coordinate i_center = state.i_range.center(); const Plot_Coordinate q_center = state.q_range.center(); data.i_axis->template set<&Numeric_Axis::Prop::coordinate_range>(Axis_Range{i_center - size * 0.5, i_center + size * 0.5}); data.q_axis->template set<&Numeric_Axis::Prop::coordinate_range>(Axis_Range{q_center - size * 0.5, q_center + size * 0.5}); }}; return value; } +const Constellation_Diagram::Private::Dispatch& Constellation_Diagram::Private::dispatch_for() { static const Dispatch value{[](Root* root, Point_F point) { auto* object = static_cast(root); const auto submitted = monotonic_milliseconds(); object->template update_prop<&Prop::points>([=](Prop_Access props) { auto& state = props.template get(); state.points.erase(std::remove_if(state.points.begin(), state.points.end(), [=](const auto& value) { return submitted - value.submitted_at_ms > state.point_lifetime_ms; }), state.points.end()); state.points.push_back({point, submitted}); }); }, [](const Root* root) { return static_cast(root)->template read_prop().points.size(); }, [](Root* root) { auto* object = static_cast(root); auto& data = static_cast(*object->d); const auto& state = object->template read_prop(); const Plot_Coordinate size = std::max(state.i_range.size(), state.q_range.size()); const Plot_Coordinate i_center = state.i_range.center(); const Plot_Coordinate q_center = state.q_range.center(); data.i_axis->template set<&Numeric_Axis::Prop::coordinate_range>(Axis_Range{i_center - size * 0.5, i_center + size * 0.5}); data.q_axis->template set<&Numeric_Axis::Prop::coordinate_range>(Axis_Range{q_center - size * 0.5, q_center + size * 0.5}); }}; return value; } template void Constellation_Diagram::Private::bind_private_crtp(Object* object) { Prev_Private::bind_private_crtp(object); dispatch = &dispatch_for(); } inline void Constellation_Diagram::Private::bind_sources(Scene_Object* scene_value, Axis_Object* i_axis_value, Axis_Object* q_axis_value) { scene = scene_value; i_axis = i_axis_value; q_axis = q_axis_value; } } diff --git a/render_2D/render_2D/plottable/Plot_Types.hpp b/render_2D/render_2D/plottable/Plot_Types.hpp index c8ecf6c..07163c3 100644 --- a/render_2D/render_2D/plottable/Plot_Types.hpp +++ b/render_2D/render_2D/plottable/Plot_Types.hpp @@ -8,7 +8,7 @@ using Plot_Coordinate = double; using Plot_Value = double; using Plot_Ratio = double; using Plot_Time_Tick = int; -using Plot_Duration_Milliseconds = std::uint64_t; +using Plot_Duration_Milliseconds = Duration_Milliseconds; using Plot_Partition_Count = std::size_t; using Plot_Index = std::ptrdiff_t; enum class Plot_Partition_Mode : std::uint8_t { diff --git a/render_3D/main.cmake b/render_3D/main.cmake index f685057..bbfbf97 100644 --- a/render_3D/main.cmake +++ b/render_3D/main.cmake @@ -65,6 +65,15 @@ if (WIN32) endif () set_property(GLOBAL PROPERTY RENDERIVE_RENDER_3D_RUNTIME_FILES "${Aethera_render_3D_shaderc_runtime}") + function(renderive_stage_render_3D_runtime target) + get_property(Aethera_render_3D_runtime_files GLOBAL PROPERTY RENDERIVE_RENDER_3D_RUNTIME_FILES) + foreach(Aethera_render_3D_runtime_file IN LISTS Aethera_render_3D_runtime_files) + add_custom_command(TARGET "${target}" POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E copy_if_different + "${Aethera_render_3D_runtime_file}" "$" + VERBATIM) + endforeach() + endfunction() endif () if (RENDERIVE_BUILD_TESTS) set(Aethera_render_3D_test_targets) @@ -86,7 +95,7 @@ target_include_directories(Aethera_render_3D PUBLIC ) target_compile_features(Aethera_render_3D PUBLIC cxx_std_20) target_link_libraries(Aethera_render_3D PUBLIC Aethera_Kernel) -foreach (Aethera_render_3D_datoviz_target IN LISTS Aethera_render_3D_datoviz_targets) +foreach (Aethera_render_3D_datoviz_target IN LISTS Renderive_render_3D_datoviz_targets) target_link_libraries(Aethera_render_3D PRIVATE "$") endforeach () diff --git a/render_3D/render_3D/Render_3D.hpp b/render_3D/render_3D/Render_3D.hpp new file mode 100644 index 0000000..1e414cf --- /dev/null +++ b/render_3D/render_3D/Render_3D.hpp @@ -0,0 +1,3 @@ +#pragma once +#include "scene/Render_Scene_3D.hpp" +#include "visual/Visuals.hpp" diff --git a/render_3D/render_3D/base/Types.hpp b/render_3D/render_3D/base/Types.hpp new file mode 100644 index 0000000..a34d92c --- /dev/null +++ b/render_3D/render_3D/base/Types.hpp @@ -0,0 +1,85 @@ +#pragma once +#include +#include +#include +#include +#include +#include +namespace aethera::render_3d { +using Coordinate_3D = float; +using Pixel_Distance = float; +struct Vec2 { + Coordinate_3D x{}; /* 水平分量。 */ + Coordinate_3D y{}; /* 垂直分量。 */ + bool operator==(const Vec2&) const = default; +}; +struct Vec3 { + Coordinate_3D x{}; /* X 轴分量。 */ + Coordinate_3D y{}; /* Y 轴分量。 */ + Coordinate_3D z{}; /* Z 轴分量。 */ + bool operator==(const Vec3&) const = default; +}; +struct Vec4 { + Coordinate_3D x{}; /* 第一分量。 */ + Coordinate_3D y{}; /* 第二分量。 */ + Coordinate_3D z{}; /* 第三分量。 */ + Coordinate_3D w{}; /* 第四分量。 */ + bool operator==(const Vec4&) const = default; +}; +struct Matrix4 { + std::array values{1.0F, 0.0F, 0.0F, 0.0F, 0.0F, 1.0F, 0.0F, 0.0F, 0.0F, 0.0F, 1.0F, 0.0F, 0.0F, 0.0F, 0.0F, 1.0F}; /* 按行存储的四阶变换矩阵。 */ + bool operator==(const Matrix4&) const = default; +}; +struct Extent { + std::uint32_t width{}; /* 帧宽度,单位为像素。 */ + std::uint32_t height{}; /* 帧高度,单位为像素。 */ + [[nodiscard]] bool empty() const noexcept; + bool operator==(const Extent&) const = default; +}; +struct Linear_Color { + Coordinate_3D red{0.035F}; /* 线性红色通道,范围为 0 到 1。 */ + Coordinate_3D green{0.045F}; /* 线性绿色通道,范围为 0 到 1。 */ + Coordinate_3D blue{0.07F}; /* 线性蓝色通道,范围为 0 到 1。 */ + Coordinate_3D alpha{1.0F}; /* 线性不透明度通道,范围为 0 到 1。 */ + bool operator==(const Linear_Color&) const = default; +}; +enum class Visual_Family : std::uint8_t { point, splat, pixel, marker, sphere, segment, vector, primitive, mesh, path, image, labels, glyph, text, volume }; +enum class Primitive_Topology : std::uint8_t { point_list, line_list, line_strip, triangle_list, triangle_strip }; +enum class Marker_Shape : std::uint8_t { disc, square, triangle, diamond, cross }; +enum class Point_Aspect : std::uint8_t { filled, stroke, outline }; +struct Visual_Settings { bool operator==(const Visual_Settings&) const = default; }; +struct Primitive_Settings { + Primitive_Topology topology{Primitive_Topology::triangle_list}; /* 顶点装配拓扑。 */ + bool operator==(const Primitive_Settings&) const = default; +}; +struct Texture_Field_Settings { + std::uint32_t field_width{}; /* 纹理字段宽度;零值表示尚未提供纹理。 */ + std::uint32_t field_height{}; /* 纹理字段高度;必须与宽度同时为零或非零。 */ + bool operator==(const Texture_Field_Settings&) const = default; +}; +struct Volume_Field_Settings : Texture_Field_Settings { + std::uint32_t field_depth{}; /* 体数据深度;三个维度必须同时为零或非零。 */ + bool operator==(const Volume_Field_Settings&) const = default; +}; +struct Point_Style { + Color edge_color{Color::black()}; /* 点边缘颜色。 */ + Pixel_Distance stroke_width_px{}; /* 点边缘宽度,单位为像素。 */ + Point_Aspect aspect{Point_Aspect::filled}; /* 点内部和边缘的组合方式。 */ + bool operator==(const Point_Style&) const = default; +}; +struct Pixel_Frame { + Extent extent{}; /* 像素帧尺寸。 */ + std::uint64_t sequence{}; /* Scene 分配的单调帧序号。 */ + std::vector rgba8{}; /* 按 RGBA8 连续存储的只读发布内容。 */ + bool operator==(const Pixel_Frame&) const = default; +}; +namespace detail { +[[nodiscard]] bool finite(Coordinate_3D value) noexcept; +[[nodiscard]] bool finite(Vec2 value) noexcept; +[[nodiscard]] bool finite(Vec3 value) noexcept; +[[nodiscard]] bool finite(Vec4 value) noexcept; +[[nodiscard]] bool finite(const Matrix4& value) noexcept; +[[nodiscard]] bool valid(Linear_Color value) noexcept; +} +} +#include "Types.ipp" diff --git a/render_3D/render_3D/base/Types.ipp b/render_3D/render_3D/base/Types.ipp new file mode 100644 index 0000000..0b5587a --- /dev/null +++ b/render_3D/render_3D/base/Types.ipp @@ -0,0 +1,14 @@ +#pragma once +#include +#include +namespace aethera::render_3d { +inline bool Extent::empty() const noexcept { return width == 0 || height == 0; } +namespace detail { +inline bool finite(Coordinate_3D value) noexcept { return std::isfinite(value); } +inline bool finite(Vec2 value) noexcept { return finite(value.x) && finite(value.y); } +inline bool finite(Vec3 value) noexcept { return finite(value.x) && finite(value.y) && finite(value.z); } +inline bool finite(Vec4 value) noexcept { return finite(value.x) && finite(value.y) && finite(value.z) && finite(value.w); } +inline bool finite(const Matrix4& value) noexcept { return std::ranges::all_of(value.values, [](Coordinate_3D component) { return finite(component); }); } +inline bool valid(Linear_Color value) noexcept { return finite(value.red) && finite(value.green) && finite(value.blue) && finite(value.alpha) && value.red >= 0.0F && value.red <= 1.0F && value.green >= 0.0F && value.green <= 1.0F && value.blue >= 0.0F && value.blue <= 1.0F && value.alpha >= 0.0F && value.alpha <= 1.0F; } +} +} diff --git a/render_3D/render_3D/detail/Async_Render_Backend.cpp b/render_3D/render_3D/detail/Async_Render_Backend.cpp new file mode 100644 index 0000000..d338933 --- /dev/null +++ b/render_3D/render_3D/detail/Async_Render_Backend.cpp @@ -0,0 +1,38 @@ +#include "Async_Render_Backend.hpp" +#include "Datoviz_Visual_Backend.hpp" +#include "Gpu_Completion_Service.hpp" +#include "Render_Domain.hpp" +#include +#include +#include +namespace aethera::render_3d::detail { +namespace { +float wheel_step(double pixel, double angle) { return static_cast(pixel != 0.0 ? pixel : angle / 120.0); } +} +struct Async_Render_Backend::Implementation : std::enable_shared_from_this { + struct Pending { std::optional frame{}; }; + std::shared_ptr render_domain; /* GPU 索引对应的单线程 Datoviz 域。 */ + std::unique_ptr backend; /* 只允许 render_domain 线程访问。 */ + mutable std::mutex frame_mutex; /* 保护异步发布的最新像素帧。 */ + std::shared_ptr latest_frame{}; /* 最近完成读回的像素帧。 */ + mutable std::mutex failure_mutex; /* 保护最后一次 Unknown Failure。 */ + std::exception_ptr failure{}; /* 后端隔离边界捕获的最后一次 Unknown Failure。 */ + std::atomic_uint64_t next_sequence{1}; /* 下一次接受请求的帧序号。 */ + std::atomic_uint64_t submitted{}; /* 已提交帧计数。 */ + std::atomic_uint64_t completed{}; /* 已完成帧计数。 */ + std::atomic_uint64_t dropped{}; /* 已丢弃帧请求计数。 */ + std::atomic_bool frame_in_flight{}; /* 限制后端唯一 Frame_Target 同时只有一帧。 */ + std::atomic_bool available{}; /* 后端是否可接受事件和帧请求。 */ + Implementation(std::uint32_t gpu_index, bool validation_enabled, Visual_Family visual_family, Scene_3D_Parameters initial) : render_domain(Render_Domain::acquire(gpu_index)) { const auto initialized = render_domain->invoke([this, gpu_index, validation_enabled, visual_family, initial] { backend = std::make_unique(gpu_index, validation_enabled, visual_family, initial); }); if (!initialized) throw std::runtime_error("render domain stopped during 3D backend initialization"); available.store(true, std::memory_order_release); } + ~Implementation() { available.store(false, std::memory_order_release); if (!backend) return; const auto destroyed = render_domain->invoke([this] { backend.reset(); }); if (!destroyed) backend.release(); } + void fail(std::exception_ptr value) noexcept { { std::lock_guard lock(failure_mutex); failure = std::move(value); } available.store(false, std::memory_order_release); frame_in_flight.store(false, std::memory_order_release); } + void finish(std::shared_ptr pending, Gpu_Completion_Service::Result completion) noexcept { auto self = shared_from_this(); try { const auto result = render_domain->post([self, pending = std::move(pending), completion] { if (!pending->frame) { self->frame_in_flight.store(false, std::memory_order_release); return; } if (completion.error == Gpu_Completion_Service::Completion_Error::none) { auto finished = self->backend->collect(std::move(*pending->frame)); { std::lock_guard lock(self->frame_mutex); self->latest_frame = std::move(finished.frame); } self->completed.fetch_add(1, std::memory_order_relaxed); } else { self->backend->discard(std::move(*pending->frame)); self->dropped.fetch_add(1, std::memory_order_relaxed); } self->frame_in_flight.store(false, std::memory_order_release); }, [self](std::exception_ptr value) { self->fail(std::move(value)); }); if (result != Render_Domain::Admission_Result::none) fail(std::make_exception_ptr(std::runtime_error("render domain stopped before GPU completion collection"))); } catch (...) { fail(std::current_exception()); } } + void enqueue(const Prepared_Visual& visual, Scene_3D_Parameters parameters) { if (!available.load(std::memory_order_acquire)) { dropped.fetch_add(1, std::memory_order_relaxed); return; } bool expected = false; if (!frame_in_flight.compare_exchange_strong(expected, true, std::memory_order_acq_rel)) { dropped.fetch_add(1, std::memory_order_relaxed); return; } const auto sequence = next_sequence.fetch_add(1, std::memory_order_relaxed); auto self = shared_from_this(); auto prepared = std::make_shared(visual); auto pending = std::make_shared(); const auto queued = render_domain->try_post([self, parameters, sequence, prepared, pending] { auto reservation = Gpu_Completion_Service::instance().prepare([self, pending](Gpu_Completion_Service::Result result) { self->finish(pending, std::move(result)); }, [self](std::exception_ptr value) { self->fail(std::move(value)); }, false); if (!reservation) { self->frame_in_flight.store(false, std::memory_order_release); self->dropped.fetch_add(1, std::memory_order_relaxed); return; } pending->frame = self->backend->submit(parameters, *prepared, sequence, false); if (!pending->frame) { self->frame_in_flight.store(false, std::memory_order_release); return; } self->submitted.fetch_add(1, std::memory_order_relaxed); reservation.reservation.watch(pending->frame->device, pending->frame->fence); }, [self](std::exception_ptr value) { self->fail(std::move(value)); }); if (queued != Render_Domain::Try_Post_Result::queued) { frame_in_flight.store(false, std::memory_order_release); dropped.fetch_add(1, std::memory_order_relaxed); } } +}; +Async_Render_Backend::Async_Render_Backend(std::uint32_t gpu_index, bool validation_enabled, Visual_Family visual_family, Scene_3D_Parameters parameters) : implementation_(std::make_shared(gpu_index, validation_enabled, visual_family, parameters)) {} +Async_Render_Backend::~Async_Render_Backend() = default; +void Async_Render_Backend::enqueue(const Prepared_Visual& visual, Scene_3D_Parameters parameters) { implementation_->enqueue(visual, parameters); } +std::shared_ptr Async_Render_Backend::latest_frame() const { std::lock_guard lock(implementation_->frame_mutex); return implementation_->latest_frame; } +Async_Render_Backend::Statistics Async_Render_Backend::statistics() const noexcept { return {implementation_->submitted.load(std::memory_order_relaxed), implementation_->completed.load(std::memory_order_relaxed), implementation_->dropped.load(std::memory_order_relaxed), implementation_->frame_in_flight.load(std::memory_order_acquire), implementation_->available.load(std::memory_order_acquire)}; } +Async_Render_Backend::Dispatch_Event_Result Async_Render_Backend::dispatch_event(const Event& event, Extent viewport) { auto& data = *implementation_; if (!data.available.load(std::memory_order_acquire)) return Dispatch_Event_Result::backend_unavailable; if (const auto* wheel = dynamic_cast(&event)) { const auto* pointer = dynamic_cast(&event); if (!pointer || !std::isfinite(pointer->position_x()) || !std::isfinite(pointer->position_y())) return Dispatch_Event_Result::invalid_event; const auto result = data.render_domain->invoke([&data, pointer, wheel, viewport] { data.backend->dispatch_wheel(static_cast(pointer->position_x()), static_cast(pointer->position_y()), wheel_step(wheel->pixel_delta_x_value(), wheel->angle_delta_x_value()), wheel_step(wheel->pixel_delta_y_value(), wheel->angle_delta_y_value()), pointer->keyboard_modifiers(), viewport); }); return result ? Dispatch_Event_Result::dispatched : Dispatch_Event_Result::backend_unavailable; } if (const auto* pointer = dynamic_cast(&event)) { if (!std::isfinite(pointer->position_x()) || !std::isfinite(pointer->position_y()) || (event.type != Event_Type::pointer_move && event.type != Event_Type::pointer_press && event.type != Event_Type::pointer_release)) return Dispatch_Event_Result::invalid_event; const auto result = data.render_domain->invoke([&data, pointer, &event, viewport] { data.backend->dispatch_pointer(event.type, static_cast(pointer->position_x()), static_cast(pointer->position_y()), pointer->pointer_button(), pointer->keyboard_modifiers(), viewport); }); return result ? Dispatch_Event_Result::dispatched : Dispatch_Event_Result::backend_unavailable; } if (const auto* key = dynamic_cast(&event)) { const auto result = data.render_domain->invoke([&data, key] { data.backend->dispatch_key(*key); }); return result ? Dispatch_Event_Result::dispatched : Dispatch_Event_Result::backend_unavailable; } return Dispatch_Event_Result::ignored; } +} diff --git a/render_3D/render_3D/detail/Async_Render_Backend.hpp b/render_3D/render_3D/detail/Async_Render_Backend.hpp new file mode 100644 index 0000000..bbcda7f --- /dev/null +++ b/render_3D/render_3D/detail/Async_Render_Backend.hpp @@ -0,0 +1,28 @@ +#pragma once +#include "Backend_Types.hpp" +#include +namespace aethera::render_3d::detail { +class Async_Render_Backend final { +public: + struct Statistics { + std::uint64_t submitted{}; /* 已提交给 Vulkan 的帧数。 */ + std::uint64_t completed{}; /* 已完成读回并发布的帧数。 */ + std::uint64_t dropped{}; /* 因已有在途帧或队列已满而丢弃的请求数。 */ + bool frame_in_flight{}; /* 是否存在尚未完成的 GPU 帧。 */ + bool available{}; /* Datoviz 后端是否完成初始化且未隔离。 */ + }; + Async_Render_Backend(std::uint32_t gpu_index, bool validation_enabled, Visual_Family visual_family, Scene_3D_Parameters parameters); + ~Async_Render_Backend(); + Async_Render_Backend(const Async_Render_Backend&) = delete; + Async_Render_Backend& operator=(const Async_Render_Backend&) = delete; + /* 无等待地把 Prepared_Visual 放入渲染域;队列忙时直接记录丢帧。 */ + void enqueue(const Prepared_Visual& visual, Scene_3D_Parameters parameters); + [[nodiscard]] std::shared_ptr latest_frame() const; + [[nodiscard]] Statistics statistics() const noexcept; + enum class Dispatch_Event_Result : std::uint8_t { dispatched, ignored, invalid_event, backend_unavailable }; + [[nodiscard]] Dispatch_Event_Result dispatch_event(const Event& event, Extent viewport); +private: + struct Implementation; + std::shared_ptr implementation_; /* 隔离实现并覆盖所有已入队异步工作的生命周期。 */ +}; +} diff --git a/render_3D/render_3D/detail/Backend_Types.hpp b/render_3D/render_3D/detail/Backend_Types.hpp new file mode 100644 index 0000000..03c3cce --- /dev/null +++ b/render_3D/render_3D/detail/Backend_Types.hpp @@ -0,0 +1,11 @@ +#pragma once +#include "../visual/Prepared_Visual.hpp" +#include +namespace aethera::render_3d::detail { +struct Scene_3D_Parameters { + Extent viewport{560, 320}; /* 离屏渲染目标尺寸,单位为像素。 */ + Linear_Color clear_color{}; /* 每帧开始时写入的线性背景色。 */ + bool operator==(const Scene_3D_Parameters&) const = default; +}; +} + diff --git a/render_3D/render_3D/detail/Datoviz_Frame_Trace.hpp b/render_3D/render_3D/detail/Datoviz_Frame_Trace.hpp new file mode 100644 index 0000000..91c9250 --- /dev/null +++ b/render_3D/render_3D/detail/Datoviz_Frame_Trace.hpp @@ -0,0 +1,33 @@ +#pragma once +#include +#include +#include +namespace aethera::render_3d::detail { +struct Datoviz_Gpu_Timing { + std::uint64_t render_ns{}; /* GPU 绘制阶段耗时,单位为纳秒。 */ + std::uint64_t transition_ns{}; /* GPU 布局转换耗时,单位为纳秒。 */ + std::uint64_t copy_ns{}; /* GPU 回读复制耗时,单位为纳秒。 */ + std::uint64_t total_ns{}; /* GPU 整帧总耗时,单位为纳秒。 */ +}; +struct Datoviz_Frame_Trace { + std::uint64_t render_sequence{}; /* Scene 分配的帧序号。 */ + bool observed{}; /* 本帧是否采集诊断数据。 */ + std::uint64_t render_domain_queue_wait_ns{}; /* Render Domain 排队耗时,单位为纳秒。 */ + std::uint64_t apply_ns{}; /* 应用 Visual 数据耗时,单位为纳秒。 */ + std::uint64_t emit_ns{}; /* 生成帧计划耗时,单位为纳秒。 */ + std::uint64_t execute_ns{}; /* 执行后端命令耗时,单位为纳秒。 */ + std::uint64_t submit_ns{}; /* Vulkan 提交耗时,单位为纳秒。 */ + std::uint64_t gpu_fence_wait_ns{}; /* GPU fence 等待耗时,单位为纳秒。 */ + std::uint64_t readback_ns{}; /* 像素读回耗时,单位为纳秒。 */ + std::optional gpu; /* 后端支持时间戳时的 GPU 分段数据。 */ + std::uint64_t artifact_resource_version{}; /* Datoviz 帧计划资源版本。 */ + std::uint64_t artifact_frame_index{}; /* Datoviz 帧计划内部帧下标。 */ + std::uint32_t artifact_status{}; /* Datoviz 帧计划状态码。 */ + std::uint32_t validation_code{}; /* 帧计划验证结果码。 */ + std::uint64_t validation_command_index{}; /* 首个无效命令下标。 */ + bool validation_ok{}; /* 帧计划验证是否成功。 */ + std::string artifact_json; /* 可选帧计划诊断 JSON。 */ +}; +} // namespace aethera::render_3d::detail + + diff --git a/render_3D/render_3D/detail/Datoviz_Visual_Backend.cpp b/render_3D/render_3D/detail/Datoviz_Visual_Backend.cpp new file mode 100644 index 0000000..28c134a --- /dev/null +++ b/render_3D/render_3D/detail/Datoviz_Visual_Backend.cpp @@ -0,0 +1,1087 @@ +#include "Datoviz_Visual_Backend.hpp" +#include "Exception.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +namespace aethera::render_3d::detail { +namespace { +constexpr std::uint64_t color_target_id = 0x5256504f494e54ULL; +std::uint64_t trace_now_ns() noexcept { + return static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); +} +template +Resource* allocate_wrapper(Allocate allocate, const char* message) { + Resource* resource = allocate(); + if (resource == nullptr) throw std::runtime_error(message); + return resource; +} +int modifiers(::aethera::Keyboard_Modifier value) { + const auto bits = static_cast(value); + int result = DVZ_KEY_MODIFIER_NONE; + if ((bits & static_cast(::aethera::Keyboard_Modifier::shift)) != 0) result |= DVZ_KEY_MODIFIER_SHIFT; + if ((bits & static_cast(::aethera::Keyboard_Modifier::control)) != 0) result |= DVZ_KEY_MODIFIER_CONTROL; + if ((bits & static_cast(::aethera::Keyboard_Modifier::alt)) != 0) result |= DVZ_KEY_MODIFIER_ALT; + if ((bits & static_cast(::aethera::Keyboard_Modifier::meta)) != 0) result |= DVZ_KEY_MODIFIER_SUPER; + return result; +} +DvzPointerButton button(::aethera::Mouse_Button value) { + switch (value) { + case ::aethera::Mouse_Button::left: return DVZ_POINTER_BUTTON_LEFT; + case ::aethera::Mouse_Button::middle: return DVZ_POINTER_BUTTON_MIDDLE; + case ::aethera::Mouse_Button::right: return DVZ_POINTER_BUTTON_RIGHT; + case ::aethera::Mouse_Button::none: return DVZ_POINTER_BUTTON_NONE; + } + return DVZ_POINTER_BUTTON_NONE; +} +DvzKeyCode key_code(::aethera::Key key, std::uint32_t native_key) { + switch (key) { + case ::aethera::Key::escape: return DVZ_KEY_ESCAPE; + case ::aethera::Key::enter: return DVZ_KEY_ENTER; + case ::aethera::Key::space: return DVZ_KEY_SPACE; + case ::aethera::Key::delete_key: return DVZ_KEY_DELETE; + case ::aethera::Key::backspace: return DVZ_KEY_BACKSPACE; + case ::aethera::Key::left: return DVZ_KEY_LEFT; + case ::aethera::Key::right: return DVZ_KEY_RIGHT; + case ::aethera::Key::up: return DVZ_KEY_UP; + case ::aethera::Key::down: return DVZ_KEY_DOWN; + case ::aethera::Key::unknown: break; + } + return native_key <= static_cast(DVZ_KEY_LAST) + ? static_cast(native_key) + : DVZ_KEY_UNKNOWN; +} +DvzShapeAspect aspect(Point_Aspect value) { + switch (value) { + case Point_Aspect::filled: return DVZ_SHAPE_ASPECT_FILLED; + case Point_Aspect::stroke: return DVZ_SHAPE_ASPECT_STROKE; + case Point_Aspect::outline: return DVZ_SHAPE_ASPECT_OUTLINE; + } + return DVZ_SHAPE_ASPECT_FILLED; +} +void configure_glyph_text(DvzScene* scene, DvzVisual* visual, const char* text) { + auto font_descriptor = dvz_font_desc(); + font_descriptor.family = "Roboto"; + font_descriptor.style = "Regular"; + auto* font = dvz_font(scene, &font_descriptor); + auto atlas_specification = + dvz_text_atlas_spec(DVZ_TEXT_RENDERER_MSDF_ATLAS, 64.0F); + if (font == nullptr || + !dvz_font_atlas_ensure_string(font, &atlas_specification, text)) + throw std::runtime_error("failed to create Datoviz text atlas"); + const auto* atlas = dvz_font_atlas(font, &atlas_specification); + if (atlas == nullptr || dvz_glyph_set_atlas(visual, atlas) != DVZ_OK) throw std::runtime_error("failed to bind Datoviz text atlas"); + float line_width = 0.0F; + for (const auto* character = text; *character != '\0'; ++character) { + const auto* glyph = + dvz_text_atlas_glyph(atlas, static_cast(*character)); + if (glyph != nullptr) line_width += glyph->advance; + } + std::vector> positions; + std::vector> bounds; + std::vector> texture_coordinates; + std::vector> colors; + std::vector angles; + const auto atlas_info = dvz_text_atlas_info(atlas); + float cursor_x = 0.0F; + std::size_t glyph_index = 0; + for (const auto* character = text; *character != '\0'; ++character) { + const auto* glyph = + dvz_text_atlas_glyph(atlas, static_cast(*character)); + if (glyph == nullptr) continue; + const float x0 = cursor_x + glyph->xoff - 0.5F * line_width; + const float y0 = 0.5F * atlas_info.ascent + glyph->yoff; + const std::array glyph_bounds{ + x0, y0, x0 + glyph->width, y0 + glyph->height + }; + const std::array glyph_texture{ + glyph->uv[0], glyph->uv[1], glyph->uv[2], glyph->uv[3] + }; + const std::array glyph_color = + glyph_index % 2 == 0 + ? std::array{40, 235, 205, 255} + : std::array{255, 190, 80, 255}; + for (std::uint32_t vertex = 0; vertex < 6; ++vertex) { + positions.push_back({0.0F, 0.0F, 0.0F}); + bounds.push_back(glyph_bounds); + texture_coordinates.push_back(glyph_texture); + colors.push_back(glyph_color); + angles.push_back(0.0F); + } + cursor_x += glyph->advance; + ++glyph_index; + } + if (positions.empty()) throw std::runtime_error("Datoviz text atlas contains no visible glyphs"); + const auto count = static_cast(positions.size()); + const std::array updates{ + { + {"position", positions.data(), count}, {"bounds", bounds.data(), count}, + {"texcoords", texture_coordinates.data(), count}, + {"color", colors.data(), count}, {"angle", angles.data(), count} + } + }; + if (dvz_visual_set_data_many(visual, updates.data(), + static_cast(updates.size())) != DVZ_OK || + dvz_visual_set_depth_test(visual, false) != DVZ_OK) + throw std::runtime_error("failed to upload Datoviz text geometry"); +} +} // namespace +class Datoviz_Visual_Backend::Frame_Target final { +public: + struct Collection { + std::vector pixels; + std::optional gpu_timing; + }; + Frame_Target(DvzGpuCtx* gpu_context, Extent extent, std::uint64_t generation) : gpu_context_(gpu_context), extent_(extent), generation_(generation) { + if (gpu_context == nullptr || extent.empty()) throw std::invalid_argument("invalid Datoviz point frame target"); + const std::uint64_t byte_size = static_cast(extent.width) * + extent.height * 4ULL; + if (byte_size > std::numeric_limits::max()) throw std::length_error("Datoviz point frame target is too large"); + byte_size_ = static_cast(byte_size); + DvzDevice* device = dvz_gpu_ctx_device(gpu_context_); + DvzVma* allocator = dvz_gpu_ctx_alloc(gpu_context_); + DvzQueue* queue = dvz_gpu_ctx_queue(gpu_context_, DVZ_QUEUE_MAIN); + if (device == nullptr || allocator == nullptr || queue == nullptr) throw std::runtime_error("Datoviz GPU context is incomplete"); + try { + image_ = allocate_wrapper(dvz_images_create_wrapper, + "failed to allocate Datoviz image"); + dvz_images(device, allocator, VK_IMAGE_TYPE_2D, 1, image_); + dvz_images_format(image_, VK_FORMAT_R8G8B8A8_UNORM); + dvz_images_size(image_, extent.width, extent.height, 1); + dvz_images_tiling(image_, VK_IMAGE_TILING_OPTIMAL); + dvz_images_usage(image_, VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | + VK_IMAGE_USAGE_TRANSFER_SRC_BIT); + dvz_images_alloc_flags(image_, DVZ_ALLOC_FLAGS_NONE); + if (dvz_images_create(image_) != 0) throw std::runtime_error("failed to create Datoviz point image"); + view_ = allocate_wrapper(dvz_image_views_create_wrapper, + "failed to allocate Datoviz image view"); + dvz_image_views(image_, view_); + dvz_image_views_type(view_, VK_IMAGE_VIEW_TYPE_2D); + dvz_image_views_aspect(view_, VK_IMAGE_ASPECT_COLOR_BIT); + dvz_image_views_mip(view_, 0, 1); + dvz_image_views_layers(view_, 0, 1); + if (dvz_image_views_create(view_) != 0) throw std::runtime_error("failed to create Datoviz point image view"); + commands_ = allocate_wrapper(dvz_commands_create_wrapper, + "failed to allocate Datoviz commands"); + dvz_commands(device, queue, 1, commands_); + if (dvz_commands_handle(commands_) == VK_NULL_HANDLE) throw std::runtime_error("failed to create Datoviz command buffer"); + fence_ = allocate_wrapper(dvz_fence_create_wrapper, + "failed to allocate Datoviz fence"); + dvz_fence(device, true, fence_); + if (dvz_fence_handle(fence_) == VK_NULL_HANDLE) throw std::runtime_error("failed to create Datoviz fence"); + submit_ = allocate_wrapper(dvz_submit_create_wrapper, + "failed to allocate Datoviz submit"); + readback_ = allocate_wrapper(dvz_buffer_create_wrapper, + "failed to allocate Datoviz readback"); + dvz_buffer(device, allocator, readback_); + dvz_buffer_size(readback_, byte_size_); + dvz_buffer_flags(readback_, DVZ_ALLOC_HOST_ACCESS_RANDOM | DVZ_ALLOC_MAPPED); + dvz_buffer_usage(readback_, VK_BUFFER_USAGE_TRANSFER_DST_BIT); + if (dvz_buffer_create(readback_) != 0) throw std::runtime_error("failed to create Datoviz readback buffer"); + } + catch (...) { + destroy(); + raise_context("creating Datoviz frame target", std::current_exception()); + } + } + ~Frame_Target() noexcept(false) { + destroy(); + } + void begin(bool observe) { + if (in_flight_) throw std::logic_error("Datoviz frame target is still in flight"); + observing_ = observe; + if (observing_ && !timestamps_initialized_) { + initialize_timestamps( + dvz_gpu_ctx_device(gpu_context_), + dvz_gpu_ctx_queue(gpu_context_, DVZ_QUEUE_MAIN)); + } + dvz_cmd_reset(commands_); + if (dvz_cmd_begin_result(commands_) != 0) throw std::runtime_error("failed to begin Datoviz command buffer"); + DvzBarriers barriers{}; + dvz_barriers(&barriers); + auto* image_barrier = dvz_barriers_image(&barriers, dvz_image_handle(image_, 0)); + if (completed_layout_ == VK_IMAGE_LAYOUT_UNDEFINED) { + dvz_barrier_image_stage(image_barrier, VK_PIPELINE_STAGE_2_TOP_OF_PIPE_BIT, + VK_PIPELINE_STAGE_2_COLOR_ATTACHMENT_OUTPUT_BIT); + dvz_barrier_image_access( + image_barrier, 0, + VK_ACCESS_2_COLOR_ATTACHMENT_READ_BIT | + VK_ACCESS_2_COLOR_ATTACHMENT_WRITE_BIT); + } + else { + dvz_barrier_image_stage(image_barrier, VK_PIPELINE_STAGE_2_TRANSFER_BIT, + VK_PIPELINE_STAGE_2_COLOR_ATTACHMENT_OUTPUT_BIT); + dvz_barrier_image_access( + image_barrier, VK_ACCESS_2_TRANSFER_READ_BIT, + VK_ACCESS_2_COLOR_ATTACHMENT_READ_BIT | + VK_ACCESS_2_COLOR_ATTACHMENT_WRITE_BIT); + } + dvz_barrier_image_layout(image_barrier, completed_layout_, + VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL); + dvz_barrier_image_aspect(image_barrier, VK_IMAGE_ASPECT_COLOR_BIT); + dvz_barrier_image_mip(image_barrier, 0, 1); + dvz_barrier_image_layers(image_barrier, 0, 1); + dvz_cmd_barriers(commands_, &barriers); + if (observing_ && timestamps_supported_) { + const VkCommandBuffer command_buffer = + dvz_commands_handle(commands_); + vkCmdResetQueryPool(command_buffer, query_pool_, 0, 4); + vkCmdWriteTimestamp( + command_buffer, + VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, + query_pool_, 0); + } + recording_ = true; + } + [[nodiscard]] DvzStreamFrame stream_frame() const { + DvzStreamFrame frame{}; + frame.image = dvz_image_handle(image_, 0); + frame.command_buffer = dvz_commands_handle(commands_); + frame.image_view = dvz_image_views_handle(view_, 0); + frame.extent = {extent_.width, extent_.height}; + frame.color_format = VK_FORMAT_R8G8B8A8_UNORM; + frame.image_layout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; + frame.usage = DVZ_STREAM_FRAME_USAGE_RENDER_TARGET | DVZ_STREAM_FRAME_USAGE_COPY_SRC; + frame.command_buffer_recording = recording_; + frame.image_borrowed = true; + frame.image_view_borrowed = true; + frame.command_buffer_borrowed = true; + frame.handles_dirty = true; + frame.resource_generation = generation_; + frame.image_valid = true; + frame.memory_fd = -1; + frame.wait_semaphore_fd = -1; + return frame; + } + void submit() { + if (!recording_) throw std::logic_error("Datoviz frame target is not recording"); + const VkCommandBuffer command_buffer = dvz_commands_handle(commands_); + if (observing_ && timestamps_supported_) { + vkCmdWriteTimestamp( + command_buffer, + VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, + query_pool_, 1); + } + DvzBarriers image_barriers{}; + dvz_barriers(&image_barriers); + auto* image_barrier = + dvz_barriers_image(&image_barriers, dvz_image_handle(image_, 0)); + dvz_barrier_image_stage(image_barrier, + VK_PIPELINE_STAGE_2_COLOR_ATTACHMENT_OUTPUT_BIT, + VK_PIPELINE_STAGE_2_TRANSFER_BIT); + dvz_barrier_image_access(image_barrier, + VK_ACCESS_2_COLOR_ATTACHMENT_WRITE_BIT, + VK_ACCESS_2_TRANSFER_READ_BIT); + dvz_barrier_image_layout(image_barrier, + VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, + VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL); + dvz_barrier_image_aspect(image_barrier, VK_IMAGE_ASPECT_COLOR_BIT); + dvz_barrier_image_mip(image_barrier, 0, 1); + dvz_barrier_image_layers(image_barrier, 0, 1); + dvz_cmd_barriers(commands_, &image_barriers); + if (observing_ && timestamps_supported_) { + vkCmdWriteTimestamp(command_buffer, VK_PIPELINE_STAGE_TRANSFER_BIT, + query_pool_, 2); + } + DvzImageRegion region{}; + dvz_image_region(®ion); + dvz_image_region_extent(®ion, extent_.width, extent_.height, 1); + dvz_cmd_copy_image_to_buffer( + commands_, dvz_image_handle(image_, 0), + VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, ®ion, + dvz_buffer_handle(readback_), 0); + DvzBarriers buffer_barriers{}; + dvz_barriers(&buffer_barriers); + auto* buffer_barrier = dvz_barriers_buffer( + &buffer_barriers, dvz_buffer_handle(readback_), 0, byte_size_); + dvz_barrier_buffer_stage(buffer_barrier, VK_PIPELINE_STAGE_2_TRANSFER_BIT, + VK_PIPELINE_STAGE_2_HOST_BIT); + dvz_barrier_buffer_access(buffer_barrier, VK_ACCESS_2_TRANSFER_WRITE_BIT, + VK_ACCESS_2_HOST_READ_BIT); + dvz_cmd_barriers(commands_, &buffer_barriers); + if (observing_ && timestamps_supported_) { + vkCmdWriteTimestamp(command_buffer, + VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, + query_pool_, 3); + } + if (dvz_cmd_end_result(commands_) != 0) throw std::runtime_error("failed to end Datoviz command buffer"); + recording_ = false; + dvz_fence_reset(fence_); + dvz_submit(submit_); + dvz_submit_command(submit_, dvz_commands_handle(commands_)); + DvzQueue* queue = dvz_gpu_ctx_queue(gpu_context_, DVZ_QUEUE_MAIN); + if (dvz_submit_send(submit_, dvz_queue_handle(queue), + dvz_fence_handle(fence_)) != VK_SUCCESS) + throw std::runtime_error("failed to submit Datoviz point frame"); + in_flight_ = true; + completed_layout_ = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL; + } + [[nodiscard]] Collection collect() { + if (!in_flight_) throw std::logic_error("Datoviz frame target has no pending frame"); + Collection result; + try { + result.pixels.resize(static_cast(byte_size_)); + dvz_buffer_download(readback_, 0, byte_size_, result.pixels.data()); + result.gpu_timing = collect_gpu_timing(); + } + catch (...) { + in_flight_ = false; + observing_ = false; + raise_context("submitting Datoviz frame target", std::current_exception()); + } + in_flight_ = false; + observing_ = false; + return result; + } + void discard_after_completion() { + if (!in_flight_) throw std::logic_error("Datoviz frame target has no pending frame"); + in_flight_ = false; + observing_ = false; + } + [[nodiscard]] VkDevice device() const { + return dvz_device_handle(dvz_gpu_ctx_device(gpu_context_)); + } + [[nodiscard]] VkFence fence() const { + return dvz_fence_handle(fence_); + } + [[nodiscard]] std::uint64_t generation() const noexcept { + return generation_; + } + void abort() noexcept { + if (recording_ && commands_ != nullptr) dvz_cmd_reset(commands_); + recording_ = false; + observing_ = false; + } +private: + void initialize_timestamps(DvzDevice* device, DvzQueue* queue) noexcept { + timestamps_initialized_ = true; + if (device == nullptr || queue == nullptr || + vkGetPhysicalDeviceQueueFamilyProperties == nullptr || + vkGetPhysicalDeviceProperties == nullptr || + vkCreateQueryPool == nullptr || + vkCmdResetQueryPool == nullptr || + vkCmdWriteTimestamp == nullptr || + vkGetQueryPoolResults == nullptr) + return; + const VkPhysicalDevice physical = + dvz_device_physical_device(device); + const VkDevice logical = dvz_device_handle(device); + if (physical == VK_NULL_HANDLE || logical == VK_NULL_HANDLE) return; + std::uint32_t family_count{}; + vkGetPhysicalDeviceQueueFamilyProperties( + physical, &family_count, nullptr); + if (family_count == 0) return; + std::vector families(family_count); + vkGetPhysicalDeviceQueueFamilyProperties( + physical, &family_count, families.data()); + const std::uint32_t family = dvz_queue_family(queue); + if (family >= family_count || + families[family].timestampValidBits == 0) + return; + VkPhysicalDeviceProperties properties{}; + vkGetPhysicalDeviceProperties(physical, &properties); + VkQueryPoolCreateInfo configuration{ + VK_STRUCTURE_TYPE_QUERY_POOL_CREATE_INFO + }; + configuration.queryType = VK_QUERY_TYPE_TIMESTAMP; + configuration.queryCount = 4; + if (vkCreateQueryPool(logical, &configuration, nullptr, + &query_pool_) != VK_SUCCESS) { + query_pool_ = VK_NULL_HANDLE; + return; + } + timestamp_period_ns_ = properties.limits.timestampPeriod; + timestamp_valid_bits_ = families[family].timestampValidBits; + timestamps_supported_ = timestamp_period_ns_ > 0.0F; + } + [[nodiscard]] std::optional collect_gpu_timing() const noexcept { + if (!observing_ || !timestamps_supported_ || + query_pool_ == VK_NULL_HANDLE) + return std::nullopt; + const VkDevice device = + dvz_device_handle(dvz_gpu_ctx_device(gpu_context_)); + std::array timestamps{}; + if (vkGetQueryPoolResults( + device, query_pool_, 0, + static_cast(timestamps.size()), + sizeof(timestamps), timestamps.data(), sizeof(std::uint64_t), + VK_QUERY_RESULT_64_BIT) != VK_SUCCESS) + return std::nullopt; + const auto elapsed = [this](std::uint64_t begin, + std::uint64_t end) noexcept { + std::uint64_t ticks = end - begin; + if (timestamp_valid_bits_ < 64) { + const std::uint64_t mask = + (std::uint64_t{1} << timestamp_valid_bits_) - 1; + ticks &= mask; + } + const long double nanoseconds = + static_cast(ticks) * timestamp_period_ns_; + return nanoseconds >= + static_cast( + std::numeric_limits::max()) + ? std::numeric_limits::max() + : static_cast(nanoseconds); + }; + return Datoviz_Gpu_Timing{ + elapsed(timestamps[0], timestamps[1]), + elapsed(timestamps[1], timestamps[2]), + elapsed(timestamps[2], timestamps[3]), + elapsed(timestamps[0], timestamps[3]) + }; + } + void destroy() { + if (in_flight_) + throw std::logic_error( + "Datoviz frame target is still in flight during destruction"); + if (query_pool_ != VK_NULL_HANDLE && gpu_context_ != nullptr) { + vkDestroyQueryPool( + dvz_device_handle(dvz_gpu_ctx_device(gpu_context_)), + query_pool_, nullptr); + query_pool_ = VK_NULL_HANDLE; + } + if (readback_ != nullptr) { + dvz_buffer_destroy(readback_); + dvz_buffer_free(readback_); + readback_ = nullptr; + } + if (fence_ != nullptr) { + dvz_fence_destroy(fence_); + dvz_fence_free(fence_); + fence_ = nullptr; + } + if (submit_ != nullptr) { + dvz_submit_free(submit_); + submit_ = nullptr; + } + if (commands_ != nullptr) { + dvz_commands_destroy(commands_); + dvz_commands_free(commands_); + commands_ = nullptr; + } + if (view_ != nullptr) { + dvz_image_views_destroy(view_); + dvz_image_views_free(view_); + view_ = nullptr; + } + if (image_ != nullptr) { + dvz_images_destroy(image_); + dvz_images_free(image_); + image_ = nullptr; + } + } + DvzGpuCtx* gpu_context_{}; + Extent extent_{}; + std::uint64_t generation_{}; + DvzSize byte_size_{}; + DvzImages* image_{}; + DvzImageViews* view_{}; + DvzCommands* commands_{}; + DvzFence* fence_{}; + DvzSubmit* submit_{}; + DvzBuffer* readback_{}; + VkQueryPool query_pool_{VK_NULL_HANDLE}; + VkImageLayout completed_layout_{VK_IMAGE_LAYOUT_UNDEFINED}; + float timestamp_period_ns_{}; + std::uint32_t timestamp_valid_bits_{}; + bool recording_{}; + bool in_flight_{}; + bool observing_{}; + bool timestamps_initialized_{}; + bool timestamps_supported_{}; +}; +Datoviz_Visual_Backend::Datoviz_Visual_Backend( + std::uint32_t gpu_index, bool validation_enabled, + Visual_Family visual_family, const Scene_3D_Parameters& initial_scene) : domain_thread_(std::this_thread::get_id()) { + try { + DvzGpuCtxConfig configuration = dvz_gpu_ctx_config(); + dvz_gpu_ctx_config_validation(&configuration, validation_enabled); + dvz_gpu_ctx_config_gpu(&configuration, gpu_index); + dvz_gpu_ctx_config_enable_canvas_extensions(&configuration, false); + gpu_context_ = dvz_gpu_ctx(&configuration); + if (gpu_context_ == nullptr) throw std::runtime_error("failed to create Datoviz GPU context"); + DvzDrp2RuntimeConfig runtime_configuration = dvz_drp2_runtime_vklite_config( + dvz_gpu_ctx_device(gpu_context_), dvz_gpu_ctx_alloc(gpu_context_)); + runtime_ = dvz_drp2_runtime_vklite(&runtime_configuration); + if (runtime_ == nullptr) throw std::runtime_error("failed to create Datoviz DRP2 runtime"); + create_scene(visual_family, initial_scene); + } + catch (...) { + destroy(); + raise_context("creating Datoviz backend", std::current_exception()); + } +} +Datoviz_Visual_Backend::~Datoviz_Visual_Backend() noexcept(false) { + destroy(); +} +void Datoviz_Visual_Backend::require_domain() const { + if (std::this_thread::get_id() != domain_thread_) throw std::logic_error("Datoviz objects may only be used on the render domain"); +} +void Datoviz_Visual_Backend::create_scene(Visual_Family family, const Scene_3D_Parameters& initial_scene) { + scene_ = dvz_scene(); + if (scene_ == nullptr) throw std::runtime_error("failed to create Datoviz scene"); + const auto capabilities = dvz_capability_snapshot(); + if (dvz_scene_set_capabilities(scene_, &capabilities) != DVZ_OK) throw std::runtime_error("failed to configure Datoviz scene capabilities"); + figure_ = dvz_figure(scene_, initial_scene.viewport.width, + initial_scene.viewport.height, 0); + panel_ = figure_ != nullptr ? dvz_panel_full(figure_) : nullptr; + if (panel_ != nullptr) { + switch (family) { + case Visual_Family::point: visual_ = dvz_point(scene_, 0); + break; + case Visual_Family::splat: visual_ = dvz_splat(scene_, 0); + break; + case Visual_Family::pixel: visual_ = dvz_pixel(scene_, 0); + break; + case Visual_Family::marker: visual_ = dvz_marker(scene_, 0); + break; + case Visual_Family::sphere: visual_ = dvz_sphere(scene_, 0); + break; + case Visual_Family::segment: visual_ = dvz_segment(scene_, 0); + break; + case Visual_Family::vector: visual_ = dvz_vector(scene_, 0); + break; + case Visual_Family::primitive: visual_ = dvz_primitive( + scene_, DVZ_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST, 0); + break; + case Visual_Family::mesh: visual_ = dvz_mesh(scene_, 0); + break; + case Visual_Family::path: visual_ = dvz_path(scene_, 0); + break; + case Visual_Family::image: visual_ = dvz_image(scene_, 0); + break; + case Visual_Family::labels: visual_ = dvz_labels(scene_, 0); + break; + case Visual_Family::glyph: + case Visual_Family::text: visual_ = dvz_glyph(scene_, 0); + break; + case Visual_Family::volume: visual_ = dvz_volume(scene_, 0); + break; + } + } + if (visual_ != nullptr && + dvz_visual_set_alpha_mode(visual_, DVZ_ALPHA_OPAQUE) != DVZ_OK) + throw std::runtime_error("failed to configure Datoviz visual alpha mode"); + if (visual_ != nullptr && family == Visual_Family::glyph) configure_glyph_text(scene_, visual_, "GLYPH"); + if (visual_ != nullptr && family == Visual_Family::text) configure_glyph_text(scene_, visual_, "TEXT"); + if (visual_ != nullptr && + (family == Visual_Family::image || family == Visual_Family::glyph || + family == Visual_Family::text)) { + constexpr std::uint32_t width = 32; + constexpr std::uint32_t height = 32; + std::vector> pixels(width * height); + for (std::uint32_t y = 0; y < height; ++y) { + for (std::uint32_t x = 0; x < width; ++x) { + const bool stroke = family == Visual_Family::text + ? (y < 6 || (x >= 13 && x <= 18)) + : ((x / 8 + y / 8) % 2 == 0); + pixels[y * width + x] = stroke + ? std::array{40, 235, 205, 255} + : std::array{18, 42, 72, 255}; + } + } + auto descriptor = dvz_sampled_field_desc(); + descriptor.dim = DVZ_FIELD_DIM_2D; + descriptor.format = DVZ_FIELD_FORMAT_RGBA8_UNORM; + descriptor.semantic = DVZ_FIELD_SEMANTIC_COLOR; + descriptor.color_role = DVZ_COLOR_ROLE_SRGB_COLOR; + descriptor.width = width; + descriptor.height = height; + descriptor.depth = 1; + auto* field = dvz_sampled_field(scene_, &descriptor); + auto view = dvz_field_data_view(); + view.data = pixels.data(); + view.bytes_per_row = width * sizeof(pixels.front()); + view.rows_per_image = height; + if (field == nullptr || dvz_sampled_field_set_data(field, &view) != DVZ_OK || + dvz_visual_set_field(visual_, "field", field) != DVZ_OK) + throw std::runtime_error("failed to create Datoviz 2D sampled field"); + } + if (visual_ != nullptr && family == Visual_Family::labels) { + constexpr std::uint32_t width = 8; + constexpr std::uint32_t height = 8; + std::array labels{}; + for (std::uint32_t y = 0; y < height; ++y) + for (std::uint32_t x = 0; x < width; ++x) labels[y * width + x] = static_cast((x / 4) + 2 * (y / 4)); + auto descriptor = dvz_sampled_field_desc(); + descriptor.dim = DVZ_FIELD_DIM_2D; + descriptor.format = DVZ_FIELD_FORMAT_R32_SINT; + descriptor.semantic = DVZ_FIELD_SEMANTIC_LABEL; + descriptor.color_role = DVZ_COLOR_ROLE_DATA; + descriptor.width = width; + descriptor.height = height; + descriptor.depth = 1; + auto* field = dvz_sampled_field(scene_, &descriptor); + auto view = dvz_field_data_view(); + view.data = labels.data(); + view.bytes_per_row = width * sizeof(labels.front()); + view.rows_per_image = height; + auto scale_descriptor = dvz_scale_desc(); + scale_descriptor.kind = DVZ_SCALE_CATEGORICAL; + auto* scale = dvz_scale(scene_, &scale_descriptor); + const std::array categories{ + { + {.category_id = 0, .order = 0, .label = "north west", .color = {235, 70, 70, 255}}, + {.category_id = 1, .order = 1, .label = "north east", .color = {70, 220, 100, 255}}, + {.category_id = 2, .order = 2, .label = "south west", .color = {70, 120, 245, 255}}, + {.category_id = 3, .order = 3, .label = "south east", .color = {245, 210, 55, 255}}, + } + }; + if (field == nullptr || scale == nullptr || + dvz_sampled_field_set_data(field, &view) != DVZ_OK || + dvz_visual_set_field(visual_, "field", field) != DVZ_OK || + dvz_scale_set_categories(scale, categories.data(), + static_cast(categories.size())) != DVZ_OK || + dvz_visual_set_scale(visual_, "labels", scale) != DVZ_OK) + throw std::runtime_error("failed to create Datoviz label field"); + } + if (visual_ != nullptr && family == Visual_Family::volume) { + constexpr std::uint32_t side = 16; + std::vector voxels(side * side * side); + for (std::uint32_t z = 0; z < side; ++z) { + for (std::uint32_t y = 0; y < side; ++y) { + for (std::uint32_t x = 0; x < side; ++x) { + const float dx = static_cast(x) - 7.5F; + const float dy = static_cast(y) - 7.5F; + const float dz = static_cast(z) - 7.5F; + const float distance = std::sqrt(dx * dx + dy * dy + dz * dz); + voxels[(z * side + y) * side + x] = + distance < 6.5F ? static_cast(255 - distance * 22) : 0; + } + } + } + auto descriptor = dvz_sampled_field_desc(); + descriptor.dim = DVZ_FIELD_DIM_3D; + descriptor.format = DVZ_FIELD_FORMAT_R8_UNORM; + descriptor.semantic = DVZ_FIELD_SEMANTIC_SCALAR; + descriptor.color_role = DVZ_COLOR_ROLE_DATA; + descriptor.width = side; + descriptor.height = side; + descriptor.depth = side; + auto* field = dvz_sampled_field(scene_, &descriptor); + auto view = dvz_field_data_view(); + view.data = voxels.data(); + view.bytes_per_row = side; + view.rows_per_image = side; + if (field == nullptr || dvz_sampled_field_set_data(field, &view) != DVZ_OK || + dvz_visual_set_field(visual_, "field", field) != DVZ_OK || + dvz_volume_set_render_mode(visual_, DVZ_VOLUME_RENDER_MIP) != DVZ_OK || + dvz_volume_set_step_count(visual_, 48) != DVZ_OK) + throw std::runtime_error("failed to create Datoviz volume field"); + } + if (figure_ == nullptr || panel_ == nullptr || visual_ == nullptr || + dvz_panel_add_visual(panel_, visual_, nullptr) != DVZ_OK) + throw std::runtime_error("failed to create Datoviz visual family"); + DvzCameraDesc camera = dvz_camera_desc(); + camera.view.eye[0] = 0.0F; + camera.view.eye[1] = 0.0F; + camera.view.eye[2] = 4.0F; + camera.view.target[0] = 0.0F; + camera.view.target[1] = 0.0F; + camera.view.target[2] = 0.0F; + camera.projection.near_clip = 0.01F; + camera.projection.far_clip = 100.0F; + if (dvz_panel_set_camera_desc(panel_, &camera) != DVZ_OK) throw std::runtime_error("failed to create Datoviz point camera"); + DvzController* controller = dvz_arcball(scene_, nullptr); + if (controller == nullptr || + dvz_panel_bind_controller(panel_, controller, DVZ_DIM_MASK_XYZ) != DVZ_OK) + throw std::runtime_error("failed to bind Datoviz arcball controller"); + input_router_ = dvz_input_router(); + gesture_handler_ = input_router_ != nullptr + ? dvz_pointer_gesture_handler(input_router_) + : nullptr; + if (input_router_ == nullptr || gesture_handler_ == nullptr || + dvz_panel_connect_input(panel_, input_router_) != DVZ_OK) + throw std::runtime_error("failed to connect Datoviz point input"); + DvzInputResizeEvent resize{ + initial_scene.viewport.width, initial_scene.viewport.height, + initial_scene.viewport.width, initial_scene.viewport.height, 1.0F, 1.0F + }; + dvz_input_emit_resize(input_router_, &resize); +} +void Datoviz_Visual_Backend::apply(const Scene_3D_Parameters& scene, const Prepared_Visual& point) { + require_domain(); + if (target_extent_ != scene.viewport) { + if (dvz_figure_resize(figure_, scene.viewport.width, + scene.viewport.height) != DVZ_OK) + throw std::runtime_error("failed to resize Datoviz point figure"); + DvzInputResizeEvent resize{ + scene.viewport.width, scene.viewport.height, + scene.viewport.width, scene.viewport.height, 1.0F, 1.0F + }; + dvz_input_emit_resize(input_router_, &resize); + } + if (point.revision == applied_visual_revision_) return; + { + mat4 transform{}; + for (std::size_t row = 0; row < 4; ++row) + for (std::size_t column = 0; column < 4; ++column) transform[row][column] = point.transform.values[row * 4 + column]; + if (point.family == Visual_Family::point) { + DvzPointStyleDesc style = dvz_point_style_desc(); + style.edge_color.r = point.point_style.edge_color.red; + style.edge_color.g = point.point_style.edge_color.green; + style.edge_color.b = point.point_style.edge_color.blue; + style.edge_color.a = point.point_style.edge_color.alpha; + style.stroke_width_px = point.point_style.stroke_width_px; + style.aspect = aspect(point.point_style.aspect); + if (dvz_point_set_style(visual_, &style) != DVZ_OK) throw std::runtime_error("failed to apply Datoviz point style"); + } + if (dvz_visual_set_transform(visual_, transform) != DVZ_OK || + dvz_visual_set_depth_test(visual_, point.depth_test) != DVZ_OK || + dvz_visual_set_visible( + visual_, point.visible && !point.positions.empty()) != DVZ_OK) + throw std::runtime_error("failed to apply Datoviz visual state"); + } + if (point.positions.empty()) { + if (dvz_visual_set_visible(visual_, false) != DVZ_OK) throw std::runtime_error("failed to hide empty Datoviz point visual"); + applied_visual_revision_ = point.revision; + return; + } + const auto count = static_cast(point.positions.size()); + DvzResult result = DVZ_OK; + switch (point.family) { + case Visual_Family::point: { + const std::array updates{ + { + {"position", point.positions.data(), count}, + {"color", point.colors.data(), count}, + {"diameter_px", point.sizes.data(), count} + } + }; + result = dvz_visual_set_data_many(visual_, updates.data(), 3); + break; + } + case Visual_Family::splat: { + const std::array updates{ + { + {"position", point.positions.data(), count}, + {"color", point.colors.data(), count}, + {"sigma", point.sigma.data(), count}, + {"angle", point.angles.data(), count} + } + }; + result = dvz_visual_set_data_many(visual_, updates.data(), 4); + break; + } + case Visual_Family::pixel: { + const std::array updates{ + { + {"position", point.positions.data(), count}, + {"color", point.colors.data(), count}, + {"pixel_size_px", point.sizes.data(), count} + } + }; + result = dvz_visual_set_data_many(visual_, updates.data(), 3); + break; + } + case Visual_Family::marker: { + const std::array updates{ + { + {"position", point.positions.data(), count}, + {"color", point.colors.data(), count}, + {"diameter_px", point.sizes.data(), count}, + {"angle", point.angles.data(), count}, + {"shape", point.shapes.data(), count} + } + }; + result = dvz_visual_set_data_many(visual_, updates.data(), 5); + break; + } + case Visual_Family::sphere: { + const std::array updates{ + { + {"position", point.positions.data(), count}, + {"color", point.colors.data(), count}, + {"radius", point.sizes.data(), count} + } + }; + result = dvz_visual_set_data_many(visual_, updates.data(), 3); + break; + } + case Visual_Family::segment: { + const std::array updates{ + { + {"position_start", point.positions.data(), count}, + {"position_end", point.secondary_positions.data(), count}, + {"color", point.colors.data(), count}, + {"stroke_width_px", point.sizes.data(), count} + } + }; + result = dvz_visual_set_data_many(visual_, updates.data(), 4); + break; + } + case Visual_Family::vector: { + const std::array updates{ + { + {"position", point.positions.data(), count}, + {"vector", point.secondary_positions.data(), count}, + {"color", point.colors.data(), count}, + {"stroke_width_px", point.sizes.data(), count} + } + }; + result = dvz_visual_set_data_many(visual_, updates.data(), 4); + break; + } + case Visual_Family::primitive: + case Visual_Family::mesh: { + const std::array updates{ + { + {"position", point.positions.data(), count}, + {"color", point.colors.data(), count}, + {"normal", point.normals.data(), count} + } + }; + result = dvz_visual_set_data_many(visual_, updates.data(), 3); + break; + } + case Visual_Family::path: { + const std::array updates{ + { + {"position", point.positions.data(), count}, + {"color", point.colors.data(), count}, + {"stroke_width_px", point.sizes.data(), count} + } + }; + result = dvz_visual_set_data_many(visual_, updates.data(), 3); + break; + } + case Visual_Family::image: + case Visual_Family::labels: { + const std::array updates{ + { + {"position", point.positions.data(), count}, + {"extent", point.extents.data(), count} + } + }; + result = dvz_visual_set_data_many(visual_, updates.data(), 2); + break; + } + case Visual_Family::glyph: + case Visual_Family::text: + case Visual_Family::volume: break; + } + if (result != DVZ_OK || + dvz_visual_set_visible(visual_, point.visible) != DVZ_OK) + throw std::runtime_error("failed to upload Datoviz visual payload"); + applied_visual_revision_ = point.revision; +} +void Datoviz_Visual_Backend::dispatch_pointer( + ::aethera::Event_Type event, float x, float y, + ::aethera::Mouse_Button mouse_button, + ::aethera::Keyboard_Modifier keyboard_modifiers, Extent viewport) { + require_domain(); + const float width = static_cast(viewport.width); + const float height = static_cast(viewport.height); + const DvzPointerEventType type = + event == ::aethera::Event_Type::pointer_move + ? DVZ_POINTER_EVENT_MOVE + : event == ::aethera::Event_Type::pointer_press + ? DVZ_POINTER_EVENT_PRESS + : DVZ_POINTER_EVENT_RELEASE; + dvz_pointer_emit_position(input_router_, type, x, y, width, height, + button(mouse_button), + modifiers(keyboard_modifiers), 1.0F, + dvz_input_timestamp_ns(), nullptr); +} +void Datoviz_Visual_Backend::dispatch_wheel( + float x, float y, float delta_x, float delta_y, + ::aethera::Keyboard_Modifier keyboard_modifiers, Extent viewport) { + require_domain(); + dvz_pointer_emit_wheel( + input_router_, x, y, static_cast(viewport.width), + static_cast(viewport.height), delta_x, delta_y, + modifiers(keyboard_modifiers), 1.0F, dvz_input_timestamp_ns(), nullptr); +} +void Datoviz_Visual_Backend::dispatch_key( + const ::aethera::Key_Event& event) { + require_domain(); + const DvzKeyboardEventType type = + event.type == ::aethera::Event_Type::key_release + ? DVZ_KEYBOARD_EVENT_RELEASE + : event.auto_repeat + ? DVZ_KEYBOARD_EVENT_REPEAT + : DVZ_KEYBOARD_EVENT_PRESS; + dvz_keyboard_emit(input_router_, type, + key_code(event.key, event.native_key), + modifiers(event.modifiers), nullptr); +} +DvzSceneFrameArtifact* Datoviz_Visual_Backend::emit( + const Scene_3D_Parameters& scene) { + DvzFramePlanEmitConfig configuration = dvz_frame_plan_emit_config(); + configuration.shader_format = DVZ_SCENE_SHADER_FORMAT_GLSL; + configuration.external_color_target = true; + configuration.color_target_id = color_target_id; + configuration.color_target_format = DVZ_FORMAT_R8G8B8A8_UNORM; + configuration.target_width = scene.viewport.width; + configuration.target_height = scene.viewport.height; + configuration.clear_color[0] = scene.clear_color.red; + configuration.clear_color[1] = scene.clear_color.green; + configuration.clear_color[2] = scene.clear_color.blue; + configuration.clear_color[3] = scene.clear_color.alpha; + const auto capabilities = dvz_capability_snapshot(); + DvzDiagnosticReport report{}; + dvz_diagnostic_report_init(&report); + auto* artifact = + dvz_figure_emit_frame(figure_, &capabilities, &report, &configuration); + if (artifact == nullptr) { + std::string message = "failed to emit Datoviz visual frame"; + const auto count = dvz_diagnostic_report_count(&report); + if (count != 0) { + if (const char* diagnostic = dvz_diagnostic_report_get(&report, 0)) message += ": " + std::string(diagnostic); + } + throw std::runtime_error(message); + } + return artifact; +} +std::optional Datoviz_Visual_Backend::submit( + const Scene_3D_Parameters& scene, const Prepared_Visual& point, + std::uint64_t frame_sequence, bool observe) { + require_domain(); + if (scene.viewport.empty()) return std::nullopt; + Datoviz_Frame_Trace trace; + trace.render_sequence = frame_sequence; + trace.observed = observe; + std::uint64_t phase_started = observe ? trace_now_ns() : 0; + apply(scene, point); + if (observe) trace.apply_ns = trace_now_ns() - phase_started; + if (target_ == nullptr || target_extent_ != scene.viewport) { + target_.reset(); + target_extent_ = scene.viewport; + target_ = std::make_unique(gpu_context_, target_extent_, + ++target_generation_); + } + target_->begin(observe); + DvzSceneFrameArtifact* artifact{}; + try { + if (observe) phase_started = trace_now_ns(); + artifact = emit(scene); + if (observe) trace.emit_ns = trace_now_ns() - phase_started; + } + catch (...) { + target_->abort(); + raise_context("preparing Datoviz frame", std::current_exception()); + } + if (observe) { + trace.artifact_status = static_cast( + dvz_scene_frame_artifact_status(artifact)); + trace.artifact_resource_version = + dvz_scene_frame_artifact_resource_version(artifact); + trace.artifact_frame_index = + dvz_scene_frame_artifact_frame_index(artifact); + } + const DvzDrp2CommandStream* stream = dvz_scene_frame_artifact_stream(artifact); + const DvzStreamFrame target_frame = target_->stream_frame(); + if (observe) phase_started = trace_now_ns(); + const bool attached = stream != nullptr && + dvz_drp2_runtime_attach_frame_target( + runtime_, color_target_id, &target_frame); + const DvzDrp2ValidationResult result = + attached + ? dvz_drp2_runtime_execute(runtime_, stream) + : DvzDrp2ValidationResult{}; + if (observe) trace.execute_ns = trace_now_ns() - phase_started; + trace.validation_ok = attached && result.ok; + trace.validation_code = static_cast(result.code); + trace.validation_command_index = result.command_index; + if (observe || !trace.validation_ok) { + if (char* json = dvz_scene_frame_artifact_json( + artifact, "renderive_frame")) { + trace.artifact_json = json; + dvz_drp2_stream_json_destroy(json); + } + } + dvz_scene_frame_artifact_destroy(artifact); + if (!attached) { + target_->abort(); + throw std::runtime_error("failed to attach the Datoviz point frame target"); + } + if (!result.ok) { + target_->abort(); + std::string message = + "failed to execute Datoviz point frame: validation code " + + std::to_string(static_cast(result.code)) + + ", command " + std::to_string(result.command_index); + if (!trace.artifact_json.empty()) { + message += ", artifact " + + trace.artifact_json.substr( + 0, std::min(trace.artifact_json.size(), 2048)); + } + throw std::runtime_error(std::move(message)); + } + if (observe) phase_started = trace_now_ns(); + target_->submit(); + if (observe) trace.submit_ns = trace_now_ns() - phase_started; + return Pending_Frame{ + target_->device(), target_->fence(), scene.viewport, + frame_sequence, target_->generation(), + std::move(trace) + }; +} +Datoviz_Visual_Backend::Completed_Frame Datoviz_Visual_Backend::collect( + Pending_Frame pending) { + require_domain(); + if (target_ == nullptr || + target_->generation() != pending.target_generation) + throw std::logic_error("Datoviz pending frame target no longer exists"); + const std::uint64_t readback_started = pending.trace.observed + ? trace_now_ns() + : 0; + auto collection = target_->collect(); + if (pending.trace.observed) pending.trace.readback_ns = trace_now_ns() - readback_started; + pending.trace.gpu = std::move(collection.gpu_timing); + auto output = std::make_shared(); + output->extent = pending.extent; + output->sequence = pending.sequence; + output->rgba8 = std::move(collection.pixels); + return {std::move(output), std::move(pending.trace)}; +} +void Datoviz_Visual_Backend::discard(Pending_Frame pending) { + require_domain(); + if (target_ == nullptr || + target_->generation() != pending.target_generation) + throw std::logic_error("Datoviz pending frame target no longer exists"); + target_->discard_after_completion(); +} +void Datoviz_Visual_Backend::destroy() { + if (std::this_thread::get_id() != domain_thread_) + throw std::logic_error( + "Datoviz backend may only be destroyed on the render domain"); + if (runtime_ != nullptr) { + dvz_drp2_runtime_destroy(runtime_); + runtime_ = nullptr; + } + target_.reset(); + if (panel_ != nullptr && input_router_ != nullptr) (void)dvz_panel_connect_input(panel_, nullptr); + if (gesture_handler_ != nullptr) { + dvz_pointer_gesture_handler_destroy(gesture_handler_); + gesture_handler_ = nullptr; + } + if (input_router_ != nullptr) { + dvz_input_router_destroy(input_router_); + input_router_ = nullptr; + } + visual_ = nullptr; + panel_ = nullptr; + figure_ = nullptr; + if (scene_ != nullptr) { + dvz_scene_destroy(scene_); + scene_ = nullptr; + } + if (gpu_context_ != nullptr) { + dvz_gpu_ctx_destroy(gpu_context_); + gpu_context_ = nullptr; + } +} +} // namespace aethera::render_3d::detail + + diff --git a/render_3D/render_3D/detail/Datoviz_Visual_Backend.hpp b/render_3D/render_3D/detail/Datoviz_Visual_Backend.hpp new file mode 100644 index 0000000..ec80735 --- /dev/null +++ b/render_3D/render_3D/detail/Datoviz_Visual_Backend.hpp @@ -0,0 +1,72 @@ +#pragma once +#include "Datoviz_Frame_Trace.hpp" +#include "Backend_Types.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +namespace aethera::render_3d::detail { +class Datoviz_Visual_Backend final { +public: + struct Pending_Frame { + VkDevice device{VK_NULL_HANDLE}; /* 提交所属 Vulkan Device。 */ + VkFence fence{VK_NULL_HANDLE}; /* 标记本帧 GPU 完成的 fence。 */ + Extent extent{}; /* 本帧离屏目标尺寸。 */ + std::uint64_t sequence{}; /* Scene 分配的帧序号。 */ + std::uint64_t target_generation{}; /* 防止复用过期目标的资源代次。 */ + Datoviz_Frame_Trace trace; /* 本帧可选诊断数据。 */ + }; + struct Completed_Frame { + std::shared_ptr frame; /* 已完成并可跨线程发布的像素帧。 */ + Datoviz_Frame_Trace trace; /* 已补全 GPU 和读回阶段的诊断数据。 */ + }; + Datoviz_Visual_Backend(std::uint32_t gpu_index, bool validation_enabled, + Visual_Family visual_family, const Scene_3D_Parameters& initial_scene); + ~Datoviz_Visual_Backend() noexcept(false); + Datoviz_Visual_Backend(const Datoviz_Visual_Backend&) = delete; + Datoviz_Visual_Backend& operator=(const Datoviz_Visual_Backend&) = delete; + [[nodiscard]] std::optional submit( + const Scene_3D_Parameters& scene, const Prepared_Visual& visual, + std::uint64_t frame_sequence, bool observe); + [[nodiscard]] Completed_Frame collect(Pending_Frame pending); + void discard(Pending_Frame pending); + void dispatch_pointer(Event_Type type, float x, float y, + Mouse_Button button, + Keyboard_Modifier modifiers, + Extent viewport); + void dispatch_wheel(float x, float y, float delta_x, float delta_y, + Keyboard_Modifier modifiers, + Extent viewport); + void dispatch_key(const Key_Event& event); +private: + class Frame_Target; + void require_domain() const; + void create_scene(Visual_Family visual_family, const Scene_3D_Parameters& initial_scene); + void apply(const Scene_3D_Parameters& scene, const Prepared_Visual& visual); + [[nodiscard]] DvzSceneFrameArtifact* emit(const Scene_3D_Parameters& scene); + void destroy(); + std::thread::id domain_thread_; /* 唯一允许访问 Datoviz 对象的线程。 */ + DvzGpuCtx* gpu_context_{}; /* Datoviz GPU 上下文;由本类拥有。 */ + DvzDrp2Runtime* runtime_{}; /* DRP2 Vulkan 运行时;由本类拥有。 */ + DvzScene* scene_{}; /* Datoviz Scene;由本类拥有。 */ + DvzFigure* figure_{}; /* 当前离屏 Figure。 */ + DvzPanel* panel_{}; /* 承载唯一 Visual 的全屏 Panel。 */ + DvzVisual* visual_{}; /* Builder 指定 family 的唯一 Visual。 */ + DvzInputRouter* input_router_{}; /* Scene 输入事件路由器。 */ + DvzPointerGestureHandler* gesture_handler_{}; /* 指针手势解析器。 */ + std::unique_ptr target_; /* 当前尺寸对应的离屏提交和读回目标。 */ + Extent target_extent_{}; /* target_ 当前适配的像素尺寸。 */ + std::uint64_t target_generation_{}; /* 每次重建 target_ 时递增的资源代次。 */ + std::uint64_t applied_visual_revision_{}; /* 已上传到 Datoviz 的 Prepared 版本。 */ +}; +} // namespace aethera::render_3d::detail + + diff --git a/render_3D/render_3D/detail/Exception.cpp b/render_3D/render_3D/detail/Exception.cpp new file mode 100644 index 0000000..6620598 --- /dev/null +++ b/render_3D/render_3D/detail/Exception.cpp @@ -0,0 +1,8 @@ +#include "Exception.hpp" +#include +#include +namespace aethera::render_3d::detail { +std::exception_ptr contextual_exception(std::string_view context, std::exception_ptr cause) noexcept { try { try { if (cause) std::rethrow_exception(cause); } catch (...) { std::throw_with_nested(std::runtime_error(std::string(context))); } throw std::runtime_error(std::string(context)); } catch (...) { return std::current_exception(); } } +void raise_context(std::string_view context, std::exception_ptr cause) { std::rethrow_exception(contextual_exception(context, std::move(cause))); } +} + diff --git a/render_3D/render_3D/detail/Exception.hpp b/render_3D/render_3D/detail/Exception.hpp new file mode 100644 index 0000000..cf4da58 --- /dev/null +++ b/render_3D/render_3D/detail/Exception.hpp @@ -0,0 +1,8 @@ +#pragma once +#include +#include +namespace aethera::render_3d::detail { +[[nodiscard]] std::exception_ptr contextual_exception(std::string_view context, std::exception_ptr cause) noexcept; +[[noreturn]] void raise_context(std::string_view context, std::exception_ptr cause); +} + diff --git a/render_3D/render_3D/detail/Gpu_Completion_Service.cpp b/render_3D/render_3D/detail/Gpu_Completion_Service.cpp new file mode 100644 index 0000000..efba1bc --- /dev/null +++ b/render_3D/render_3D/detail/Gpu_Completion_Service.cpp @@ -0,0 +1,305 @@ +#include "Gpu_Completion_Service.hpp" +#include "Exception.hpp" +#include +#include +#include +#include +namespace aethera::render_3d::detail { +Gpu_Completion_Service& Gpu_Completion_Service::instance() { + static Gpu_Completion_Service service; + return service; +} +Gpu_Completion_Service::Gpu_Completion_Service() { + thread_ = std::thread([this] { + run(); + }); +} +Gpu_Completion_Service::~Gpu_Completion_Service() { + stopping_.store(true, std::memory_order_release); + wake(); + if (thread_.joinable()) thread_.join(); +} +Gpu_Completion_Service::Reservation::Reservation( + std::shared_ptr pending) noexcept : pending_(std::move(pending)) {} +Gpu_Completion_Service::Reservation::~Reservation() noexcept(false) { + cancel(); +} +Gpu_Completion_Service::Reservation::Reservation(Reservation&& other) noexcept : pending_(std::exchange(other.pending_, {})) {} +void Gpu_Completion_Service::Reservation::watch(VkDevice device, + VkFence fence) { + if (!pending_ || device == VK_NULL_HANDLE || fence == VK_NULL_HANDLE) throw std::logic_error("GPU completion reservation or fence is invalid"); + auto pending = std::exchange(pending_, {}); + auto* const service = pending->service; + { + std::lock_guard lock(pending->mutex); + if (pending->status != Pending_Fence::Status::reserved) throw std::logic_error("GPU completion reservation is not reserved"); + pending->device = device; + pending->fence = fence; + // This timestamp is part of correctness, not only observability: it + // bounds the lifetime of a submission whose fence never signals. + pending->watched_at = std::chrono::steady_clock::now(); + const std::size_t watched = + service->watched_.fetch_add(1, std::memory_order_relaxed) + 1; + update_peak(service->peak_watched_, watched); + pending->status = Pending_Fence::Status::watched; + } + service->wake(); +} +void Gpu_Completion_Service::Reservation::cancel() { + if (!pending_) return; + auto pending = std::exchange(pending_, {}); + cancel_reserved(pending); + pending->service->wake(); +} +void Gpu_Completion_Service::update_peak(std::atomic_size_t& peak, + std::size_t value) noexcept { + std::size_t current = peak.load(std::memory_order_relaxed); + while (current < value && + !peak.compare_exchange_weak(current, value, + std::memory_order_relaxed)) {} +} +void Gpu_Completion_Service::cancel_reserved( + const std::shared_ptr& pending) { + if (!pending) return; + std::lock_guard lock(pending->mutex); + if (pending->status == Pending_Fence::Status::reserved) pending->status = Pending_Fence::Status::canceled; +} +void Gpu_Completion_Service::acquire_slot() { + const auto started = std::chrono::steady_clock::now(); + if (!slots_.try_acquire()) { + backpressure_count_.fetch_add(1, std::memory_order_relaxed); + slots_.acquire(); + const auto waited = std::chrono::duration_cast( + std::chrono::steady_clock::now() - started) + .count(); + if (waited > 0) + backpressure_wait_ns_.fetch_add( + static_cast(waited), + std::memory_order_relaxed); + } + const std::size_t in_flight = + in_flight_.fetch_add(1, std::memory_order_relaxed) + 1; + update_peak(peak_in_flight_, in_flight); +} +void Gpu_Completion_Service::release_slot() noexcept { + in_flight_.fetch_sub(1, std::memory_order_relaxed); + slots_.release(); +} +Gpu_Completion_Service::Prepare_Result Gpu_Completion_Service::prepare( + Completion completion, Exception_Handler on_exception, bool observe) { + if (!completion) throw std::invalid_argument("GPU completion callback is empty"); + if (!on_exception) throw std::invalid_argument("GPU completion exception handler is empty"); + if (stopping_.load(std::memory_order_acquire)) return {{}, Admission_Result::stopping}; + auto pending = std::make_shared(); + pending->completion = std::move(completion); + pending->on_exception = std::move(on_exception); + pending->observe = observe; + pending->service = this; + acquire_slot(); + if (stopping_.load(std::memory_order_acquire)) { + release_slot(); + return {{}, Admission_Result::stopping}; + } + { std::lock_guard lock(pending_mutex_); pending_.push_back(pending); } + wake(); + return {Reservation(std::move(pending)), Admission_Result::none}; +} +Gpu_Completion_Service::Statistics Gpu_Completion_Service::statistics() const noexcept { + return { + static_cast(default_capacity), + in_flight_.load(std::memory_order_relaxed), + peak_in_flight_.load(std::memory_order_relaxed), + watched_.load(std::memory_order_relaxed), + peak_watched_.load(std::memory_order_relaxed), + backpressure_count_.load(std::memory_order_relaxed), + backpressure_wait_ns_.load(std::memory_order_relaxed), + fault_count_.load(std::memory_order_relaxed), + abandoned_count_.load(std::memory_order_relaxed) + }; +} +void Gpu_Completion_Service::wake() noexcept { + wake_generation_.fetch_add(1, std::memory_order_release); + wake_condition_.notify_one(); +} +void Gpu_Completion_Service::run() { + struct Device_Fences { + VkDevice device{VK_NULL_HANDLE}; + std::vector fences; + }; + std::vector> active; + active.reserve(static_cast(default_capacity)); + std::size_t wait_group_index{}; + const auto wait_age_ns = [](std::chrono::steady_clock::time_point started) { + const auto elapsed = std::chrono::duration_cast( + std::chrono::steady_clock::now() - started) + .count(); + return elapsed > 0 ? static_cast(elapsed) : 0ULL; + }; + const auto finish = [this, &wait_age_ns]( + const std::shared_ptr& pending, + VkResult result, Completion_Error error) { + Completion completion; + Exception_Handler on_exception; + std::chrono::steady_clock::time_point watched_at{}; + bool observe{}; + { + std::lock_guard lock(pending->mutex); + completion = std::move(pending->completion); + on_exception = std::move(pending->on_exception); + watched_at = pending->watched_at; + observe = pending->observe; + pending->status = Pending_Fence::Status::canceled; + } + watched_.fetch_sub(1, std::memory_order_relaxed); + Result completion_result; + completion_result.error = error; + completion_result.vulkan_result = result; + if (observe) completion_result.wait_duration_ns = wait_age_ns(watched_at); + if (error != Completion_Error::none) { + fault_count_.fetch_add(1, std::memory_order_relaxed); + abandoned_count_.fetch_add(1, std::memory_order_relaxed); + } + try { + completion(std::move(completion_result)); + } + catch (...) { + on_exception(contextual_exception("delivering GPU completion", std::current_exception())); + } + release_slot(); + }; + for (;;) { + const std::uint64_t wake_generation = + wake_generation_.load(std::memory_order_acquire); + { std::lock_guard lock(pending_mutex_); while (!pending_.empty()) { active.push_back(std::move(pending_.front())); pending_.pop_front(); } } + std::vector groups; + for (auto iterator = active.begin(); iterator != active.end();) { + Pending_Fence::Status status; + VkDevice device{VK_NULL_HANDLE}; + VkFence fence{VK_NULL_HANDLE}; + { + std::lock_guard lock((*iterator)->mutex); + status = (*iterator)->status; + device = (*iterator)->device; + fence = (*iterator)->fence; + } + if (status == Pending_Fence::Status::canceled || + (status == Pending_Fence::Status::reserved && + stopping_.load(std::memory_order_acquire))) { + cancel_reserved(*iterator); + iterator = active.erase(iterator); + release_slot(); + continue; + } + if (status == Pending_Fence::Status::watched) { + auto group = std::find_if( + groups.begin(), groups.end(), + [device](const Device_Fences& item) { + return item.device == device; + }); + if (group == groups.end()) { + groups.push_back(Device_Fences{device, {}}); + group = groups.end() - 1; + } + group->fences.push_back(fence); + } + ++iterator; + } + bool pending_empty; + { std::lock_guard lock(pending_mutex_); pending_empty = pending_.empty(); } + if (stopping_.load(std::memory_order_acquire) && active.empty() && pending_empty) return; + // Probe every watched fence first. A permanently unsignaled fence is + // converted into a logical failure after a bounded interval. The + // submission is explicitly marked abandoned so its owner can + // quarantine, rather than recycle, the referenced GPU resources. + bool completed_any = false; + for (auto iterator = active.begin(); iterator != active.end();) { + VkDevice device{VK_NULL_HANDLE}; + VkFence fence{VK_NULL_HANDLE}; + Pending_Fence::Status status; + std::chrono::steady_clock::time_point watched_at{}; + { + std::lock_guard lock((*iterator)->mutex); + status = (*iterator)->status; + device = (*iterator)->device; + fence = (*iterator)->fence; + watched_at = (*iterator)->watched_at; + } + if (status != Pending_Fence::Status::watched) { + ++iterator; + continue; + } + if (stopping_.load(std::memory_order_acquire)) { + auto pending = *iterator; + iterator = active.erase(iterator); + finish(pending, VK_TIMEOUT, Completion_Error::fence_abandoned); + completed_any = true; + continue; + } + if (wait_age_ns(watched_at) >= maximum_fence_age_ns) { + auto pending = *iterator; + iterator = active.erase(iterator); + finish(pending, VK_TIMEOUT, Completion_Error::fence_abandoned); + completed_any = true; + continue; + } + const VkResult result = vkGetFenceStatus(device, fence); + if (result == VK_NOT_READY) { + ++iterator; + continue; + } + auto pending = *iterator; + iterator = active.erase(iterator); + finish(pending, result, result == VK_SUCCESS + ? Completion_Error::none + : Completion_Error::vulkan_failure); + completed_any = true; + } + if (completed_any) continue; + if (groups.empty()) { + std::unique_lock lock(wait_mutex_); + if (wake_generation_.load(std::memory_order_acquire) == + wake_generation) { + wake_condition_.wait(lock, [this, wake_generation] { + return wake_generation_.load(std::memory_order_acquire) != + wake_generation; + }); + } + continue; + } + wait_group_index %= groups.size(); + const Device_Fences& group = groups[wait_group_index++]; + const VkResult wait_result = vkWaitForFences( + group.device, static_cast(group.fences.size()), + group.fences.data(), VK_FALSE, fence_wait_timeout_ns); + for (auto iterator = active.begin(); iterator != active.end();) { + VkDevice device{VK_NULL_HANDLE}; + VkFence fence{VK_NULL_HANDLE}; + Pending_Fence::Status status; + { + std::lock_guard lock((*iterator)->mutex); + status = (*iterator)->status; + device = (*iterator)->device; + fence = (*iterator)->fence; + } + if (status != Pending_Fence::Status::watched || + device != group.device) { + ++iterator; + continue; + } + VkResult result = wait_result; + if (wait_result == VK_SUCCESS || wait_result == VK_TIMEOUT) result = vkGetFenceStatus(device, fence); + if (result == VK_NOT_READY) { + ++iterator; + continue; + } + auto pending = *iterator; + iterator = active.erase(iterator); + finish(pending, result, result == VK_SUCCESS + ? Completion_Error::none + : Completion_Error::vulkan_failure); + } + } +} +} + + diff --git a/render_3D/render_3D/detail/Gpu_Completion_Service.hpp b/render_3D/render_3D/detail/Gpu_Completion_Service.hpp new file mode 100644 index 0000000..4e81db4 --- /dev/null +++ b/render_3D/render_3D/detail/Gpu_Completion_Service.hpp @@ -0,0 +1,122 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +namespace aethera::render_3d::detail { +class Gpu_Completion_Service final { + struct Pending_Fence; +public: + enum class Admission_Result : std::uint8_t { + none, + stopping + }; + enum class Completion_Error : std::uint8_t { + none, + fence_abandoned, + vulkan_failure + }; + struct Result { + Completion_Error error{}; /* 归一化完成结果。 */ + VkResult vulkan_result{VK_SUCCESS}; /* Vulkan 原始结果码。 */ + std::uint64_t wait_duration_ns{}; /* fence 等待时间,单位为纳秒。 */ + }; + struct Statistics { + std::size_t capacity{}; /* 最大并发 reservation 数。 */ + std::size_t in_flight{}; /* 当前 reservation 数。 */ + std::size_t peak_in_flight{}; /* 历史最大 reservation 数。 */ + std::size_t watched{}; /* 当前受监视 fence 数。 */ + std::size_t peak_watched{}; /* 历史最大受监视 fence 数。 */ + std::uint64_t backpressure_count{}; /* 容量不足累计次数。 */ + std::uint64_t backpressure_wait_ns{}; /* 准入累计等待时间,单位为纳秒。 */ + std::uint64_t fault_count{}; /* Vulkan 或超时故障累计次数。 */ + std::uint64_t abandoned_count{}; /* 被隔离的 fence 累计数。 */ + }; + using Completion = std::function; + using Exception_Handler = std::function; + class Reservation final { + public: + Reservation() = default; + ~Reservation() noexcept(false); + Reservation(const Reservation&) = delete; + Reservation& operator=(const Reservation&) = delete; + Reservation(Reservation&& other) noexcept; + Reservation& operator=(Reservation&&) = delete; + void watch(VkDevice device, VkFence fence); + private: + explicit Reservation(std::shared_ptr pending) noexcept; + void cancel(); + std::shared_ptr pending_; /* 尚未 watch 或 cancel 的准入记录。 */ + friend class Gpu_Completion_Service; + }; + struct Prepare_Result { + Reservation reservation; /* 成功时返回的 fence reservation。 */ + Admission_Result result{}; /* 准入结果。 */ + [[nodiscard]] explicit operator bool() const noexcept { + return result == Admission_Result::none; + } + }; + static Gpu_Completion_Service& instance(); + Gpu_Completion_Service(const Gpu_Completion_Service&) = delete; + Gpu_Completion_Service& operator=(const Gpu_Completion_Service&) = delete; + [[nodiscard]] Prepare_Result prepare(Completion completion, + Exception_Handler on_exception, + bool observe); + [[nodiscard]] Statistics statistics() const noexcept; +private: + struct Pending_Fence { + enum class Status { + reserved, + watched, + canceled + }; + std::mutex mutex; /* 保护本条记录的状态和回调移动。 */ + VkDevice device{VK_NULL_HANDLE}; /* fence 所属 Vulkan Device。 */ + VkFence fence{VK_NULL_HANDLE}; /* 受监视的 Vulkan fence。 */ + Completion completion; /* 完成或隔离后的交付回调。 */ + Exception_Handler on_exception; /* 回调异常的隔离入口。 */ + std::chrono::steady_clock::time_point watched_at{}; /* 开始监视的单调时钟时刻。 */ + Gpu_Completion_Service* service{}; /* 不拥有的服务实例。 */ + Status status{Status::reserved}; /* reservation 生命周期状态。 */ + bool observe{}; /* 是否采集 fence 等待耗时。 */ + }; + Gpu_Completion_Service(); + ~Gpu_Completion_Service(); + static void update_peak(std::atomic_size_t& peak, std::size_t value) noexcept; + static void cancel_reserved(const std::shared_ptr& pending); + void acquire_slot(); + void release_slot() noexcept; + void wake() noexcept; + void run(); + static constexpr std::ptrdiff_t default_capacity = 1024; + static constexpr std::uint64_t fence_wait_timeout_ns = 1'000'000; + static constexpr std::uint64_t maximum_fence_age_ns = 30'000'000'000ULL; + std::counting_semaphore slots_{default_capacity}; /* 有界 reservation 槽位。 */ + std::mutex pending_mutex_; /* 保护新登记 fence 队列。 */ + std::deque> pending_; /* 完成线程尚未分组的 fence。 */ + std::mutex wait_mutex_; /* 保护完成线程的条件等待。 */ + std::condition_variable wake_condition_; /* 新 fence 或停止请求的唤醒源。 */ + std::atomic_uint64_t wake_generation_{}; /* 防止丢失唤醒的版本。 */ + std::atomic_size_t in_flight_{}; /* 当前 reservation 数。 */ + std::atomic_size_t peak_in_flight_{}; /* 历史最大 reservation 数。 */ + std::atomic_size_t watched_{}; /* 当前受监视 fence 数。 */ + std::atomic_size_t peak_watched_{}; /* 历史最大受监视 fence 数。 */ + std::atomic_uint64_t backpressure_count_{}; /* 准入背压累计次数。 */ + std::atomic_uint64_t backpressure_wait_ns_{}; /* 准入背压累计纳秒。 */ + std::atomic_uint64_t fault_count_{}; /* 完成故障累计次数。 */ + std::atomic_uint64_t abandoned_count_{}; /* 放弃 fence 累计次数。 */ + std::atomic_bool stopping_{}; /* 服务是否正在停止。 */ + std::thread thread_; /* 专用 fence 完成线程。 */ +}; +} + + diff --git a/render_3D/render_3D/detail/Render_Domain.cpp b/render_3D/render_3D/detail/Render_Domain.cpp new file mode 100644 index 0000000..6538dd5 --- /dev/null +++ b/render_3D/render_3D/detail/Render_Domain.cpp @@ -0,0 +1,197 @@ +#include "Render_Domain.hpp" +#include +#include +#include +namespace aethera::render_3d::detail { +namespace { +struct Render_Domain_Registry { + std::mutex mutex; + std::unordered_map> domains; +}; +Render_Domain_Registry& registry() { + static Render_Domain_Registry value; + return value; +} +} +Render_Domain::Prepared_Task::~Prepared_Task() { + release(); +} +Render_Domain::Prepared_Task::Prepared_Task(Prepared_Task&& other) noexcept : domain_(std::move(other.domain_)), task_(std::move(other.task_)) {} +Render_Domain::Prepared_Task& Render_Domain::Prepared_Task::operator=(Prepared_Task&& other) noexcept { + if (this == &other) return *this; + release(); + domain_ = std::move(other.domain_); + task_ = std::move(other.task_); + return *this; +} +void Render_Domain::Prepared_Task::release() noexcept { + if (!domain_) return; + task_.reset(); + domain_->release_admission(); + domain_.reset(); +} +std::shared_ptr Render_Domain::acquire(std::uint32_t gpu_index) { + auto& storage = registry(); + std::lock_guard lock(storage.mutex); + auto& entry = storage.domains[gpu_index]; + if (auto domain = entry.lock()) return domain; + auto domain = std::shared_ptr(new Render_Domain(), + &Render_Domain::destroy); + entry = domain; + return domain; +} +Render_Domain::Render_Domain() { + thread_ = std::thread([this] { + run(); + }); +} +Render_Domain::~Render_Domain() { + request_stop(); + if (thread_.joinable()) thread_.join(); +} +void Render_Domain::destroy(Render_Domain* domain) noexcept { + if (!domain) return; + if (current_domain_ != domain) { + delete domain; + return; + } + domain->stopping_.store(true, std::memory_order_release); + domain->destroy_on_exit_.store(true, std::memory_order_release); +} +void Render_Domain::request_stop() noexcept { + bool expected = false; + if (!stopping_.compare_exchange_strong( + expected, true, std::memory_order_acq_rel)) + return; + slots_.acquire(); + { std::lock_guard lock(task_mutex_); tasks_.push_back({}); } + task_condition_.notify_one(); +} +void Render_Domain::update_peak(std::atomic_size_t& peak, std::size_t value) noexcept { + std::size_t current = peak.load(std::memory_order_relaxed); + while (current < value && !peak.compare_exchange_weak(current, value, std::memory_order_relaxed)) {} +} +void Render_Domain::acquire_admission() { + const auto started = std::chrono::steady_clock::now(); + if (!slots_.try_acquire()) { + backpressure_count_.fetch_add(1, std::memory_order_relaxed); + slots_.acquire(); + const auto waited = std::chrono::duration_cast( + std::chrono::steady_clock::now() - started).count(); + if (waited > 0) backpressure_wait_ns_.fetch_add(static_cast(waited), std::memory_order_relaxed); + } + const std::size_t admitted = admitted_.fetch_add(1, std::memory_order_relaxed) + 1; + update_peak(peak_admitted_, admitted); +} +bool Render_Domain::try_acquire_admission() noexcept { + if (!slots_.try_acquire()) { + backpressure_count_.fetch_add(1, std::memory_order_relaxed); + return false; + } + const std::size_t admitted = admitted_.fetch_add(1, std::memory_order_relaxed) + 1; + update_peak(peak_admitted_, admitted); + return true; +} +void Render_Domain::release_admission() noexcept { + admitted_.fetch_sub(1, std::memory_order_relaxed); + slots_.release(); +} +Render_Domain::Prepare_Result Render_Domain::prepare( + std::function function, Exception_Handler on_exception) { + if (!function) throw std::invalid_argument("render domain task is empty"); + if (!on_exception) throw std::invalid_argument("render domain exception handler is empty"); + if (stopping_.load(std::memory_order_acquire)) return {{}, Admission_Result::stopping}; + acquire_admission(); + if (stopping_.load(std::memory_order_acquire)) { + release_admission(); + return {{}, Admission_Result::stopping}; + } + try { + return { + Prepared_Task(shared_from_this(), std::make_unique( + std::move(function), std::move(on_exception))), + Admission_Result::none + }; + } + catch (...) { + release_admission(); + raise_context("preparing render domain task", std::current_exception()); + } +} +Render_Domain::Admission_Result Render_Domain::post( + std::function function, Exception_Handler on_exception) { + auto prepared = prepare(std::move(function), std::move(on_exception)); + if (!prepared) return prepared.result; + return post(std::move(prepared.task)); +} +Render_Domain::Try_Post_Result Render_Domain::try_post(std::function function, Exception_Handler on_exception) { + if (!function) throw std::invalid_argument("render domain task is empty"); + if (!on_exception) throw std::invalid_argument("render domain exception handler is empty"); + if (stopping_.load(std::memory_order_acquire)) return Try_Post_Result::stopping; + if (!try_acquire_admission()) return Try_Post_Result::queue_full; + if (stopping_.load(std::memory_order_acquire)) { release_admission(); return Try_Post_Result::stopping; } + try { + auto task = Prepared_Task(shared_from_this(), std::make_unique(std::move(function), std::move(on_exception))); + { std::lock_guard lock(task_mutex_); tasks_.push_back(std::move(task.task_)); } + task_condition_.notify_one(); + const std::size_t queued = queued_.fetch_add(1, std::memory_order_relaxed) + 1; + update_peak(peak_queued_, queued); + task.domain_.reset(); + return Try_Post_Result::queued; + } + catch (...) { + release_admission(); + raise_context("trying to post render domain task", std::current_exception()); + } +} +Render_Domain::Admission_Result Render_Domain::post(Prepared_Task task) { + if (!task.task_ || task.domain_.get() != this) throw std::logic_error("render domain prepared task is invalid"); + if (stopping_.load(std::memory_order_acquire)) return Admission_Result::stopping; + { std::lock_guard lock(task_mutex_); tasks_.push_back(std::move(task.task_)); } + task_condition_.notify_one(); + const std::size_t queued = queued_.fetch_add(1, std::memory_order_relaxed) + 1; + update_peak(peak_queued_, queued); + task.domain_.reset(); + return Admission_Result::none; +} +Render_Domain::Statistics Render_Domain::statistics() const noexcept { + return { + static_cast(default_capacity), + admitted_.load(std::memory_order_relaxed), + peak_admitted_.load(std::memory_order_relaxed), + queued_.load(std::memory_order_relaxed), + peak_queued_.load(std::memory_order_relaxed), + backpressure_count_.load(std::memory_order_relaxed), + backpressure_wait_ns_.load(std::memory_order_relaxed) + }; +} +void Render_Domain::run() { + current_domain_ = this; + for (;;) { + std::unique_ptr task; + { std::unique_lock lock(task_mutex_); task_condition_.wait(lock, [this] { return !tasks_.empty(); }); task = std::move(tasks_.front()); tasks_.pop_front(); } + if (!task) { + slots_.release(); + current_domain_ = nullptr; + return; + } + queued_.fetch_sub(1, std::memory_order_relaxed); + release_admission(); + try { + task->function(); + } + catch (...) { + task->on_exception(contextual_exception("executing render domain task", std::current_exception())); + } + task.reset(); + if (destroy_on_exit_.load(std::memory_order_acquire)) { + current_domain_ = nullptr; + if (thread_.joinable()) thread_.detach(); + delete this; + return; + } + } +} +} + + diff --git a/render_3D/render_3D/detail/Render_Domain.hpp b/render_3D/render_3D/detail/Render_Domain.hpp new file mode 100644 index 0000000..f567a19 --- /dev/null +++ b/render_3D/render_3D/detail/Render_Domain.hpp @@ -0,0 +1,156 @@ +#pragma once +#include "Exception.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +namespace aethera::render_3d::detail { +class Render_Domain final : public std::enable_shared_from_this { + struct Task { + Task(std::function value, + std::function exception_handler) : function(std::move(value)), + on_exception(std::move(exception_handler)) {} + std::function function; /* 在 Render Domain 线程执行的工作。 */ + std::function on_exception; /* 工作抛出异常时的隔离回调。 */ + }; +public: + enum class Admission_Result : std::uint8_t { + none, + stopping + }; + struct Statistics { + std::size_t capacity{}; /* 有界任务容量。 */ + std::size_t admitted{}; /* 已获准且尚未完成的任务数。 */ + std::size_t peak_admitted{}; /* 历史最大获准任务数。 */ + std::size_t queued{}; /* 当前等待执行的任务数。 */ + std::size_t peak_queued{}; /* 历史最大排队任务数。 */ + std::uint64_t backpressure_count{}; /* 遭遇容量不足的累计次数。 */ + std::uint64_t backpressure_wait_ns{}; /* 阻塞入口累计等待时间,单位为纳秒。 */ + }; + class Prepared_Task final { + public: + Prepared_Task() = default; + ~Prepared_Task(); + Prepared_Task(const Prepared_Task&) = delete; + Prepared_Task& operator=(const Prepared_Task&) = delete; + Prepared_Task(Prepared_Task&& other) noexcept; + Prepared_Task& operator=(Prepared_Task&& other) noexcept; + private: + Prepared_Task(std::shared_ptr domain, std::unique_ptr task) noexcept : domain_(std::move(domain)), task_(std::move(task)) {} + void release() noexcept; + std::shared_ptr domain_; /* 保持目标渲染域存活。 */ + std::unique_ptr task_; /* 尚未提交的已获准任务。 */ + friend class Render_Domain; + }; + struct Prepare_Result { + Prepared_Task task; /* 成功时返回的任务所有权。 */ + Admission_Result result{}; /* 准入结果。 */ + [[nodiscard]] explicit operator bool() const noexcept { + return result == Admission_Result::none; + } + }; + static std::shared_ptr acquire(std::uint32_t gpu_index); + ~Render_Domain(); + Render_Domain(const Render_Domain&) = delete; + Render_Domain& operator=(const Render_Domain&) = delete; + using Exception_Handler = std::function; + [[nodiscard]] Prepare_Result prepare(std::function function, + Exception_Handler on_exception); + [[nodiscard]] Admission_Result post(std::function function, + Exception_Handler on_exception); + enum class Try_Post_Result : std::uint8_t { queued, queue_full, stopping }; + /* 无等待入队;Paint Taskflow 节点只允许使用此入口。 */ + [[nodiscard]] Try_Post_Result try_post(std::function function, + Exception_Handler on_exception); + [[nodiscard]] Admission_Result post(Prepared_Task task); + template + struct Invoke_Result { + using Value = std::conditional_t, std::monostate, Result>; + std::optional value; /* 成功调用后的返回值占位。 */ + Admission_Result result{}; /* 调用是否被渲染域接纳。 */ + [[nodiscard]] explicit operator bool() const noexcept { + return result == Admission_Result::none; + } + }; + template + [[nodiscard]] auto invoke(Function&& function) -> Invoke_Result> { + using Result = std::invoke_result_t; + using Value = typename Invoke_Result::Value; + Invoke_Result output; + if (current_domain_ == this) { + try { + if constexpr (std::is_void_v) { + std::invoke(std::forward(function)); + output.value.emplace(); + } + else { + output.value.emplace(std::invoke(std::forward(function))); + } + } + catch (...) { + raise_context("executing inline render domain invocation", std::current_exception()); + } + return output; + } + auto promise = std::make_shared>(); + auto future = promise->get_future(); + auto callable = std::make_shared>( + std::forward(function)); + output.result = post( + [promise, callable] { + if constexpr (std::is_void_v) { + std::invoke(*callable); + promise->set_value(std::monostate{}); + } + else { + promise->set_value(std::invoke(*callable)); + } + }, + [promise](std::exception_ptr exception) { + promise->set_exception(std::move(exception)); + }); + if (output.result != Admission_Result::none) return output; + output.value.emplace(future.get()); + return output; + } + [[nodiscard]] Statistics statistics() const noexcept; +private: + Render_Domain(); + static void destroy(Render_Domain* domain) noexcept; + void request_stop() noexcept; + static void update_peak(std::atomic_size_t& peak, std::size_t value) noexcept; + void acquire_admission(); + [[nodiscard]] bool try_acquire_admission() noexcept; + void release_admission() noexcept; + void run(); + static constexpr std::ptrdiff_t default_capacity = 64; + inline static thread_local Render_Domain* current_domain_{}; /* 当前线程正在执行的渲染域。 */ + std::counting_semaphore slots_{default_capacity}; /* 有界准入槽位。 */ + std::mutex task_mutex_; /* 保护有界准入后的任务队列。 */ + std::condition_variable task_condition_; /* 新任务或停止哨兵的唤醒源。 */ + std::deque> tasks_; /* Render Domain 单消费者任务队列。 */ + std::atomic_size_t admitted_{}; /* 已获准任务数。 */ + std::atomic_size_t peak_admitted_{}; /* 历史最大获准任务数。 */ + std::atomic_size_t queued_{}; /* 当前队列任务数。 */ + std::atomic_size_t peak_queued_{}; /* 历史最大队列任务数。 */ + std::atomic_uint64_t backpressure_count_{}; /* 容量不足累计次数。 */ + std::atomic_uint64_t backpressure_wait_ns_{}; /* 阻塞准入累计等待纳秒。 */ + std::atomic_bool stopping_{}; /* 是否拒绝新任务并准备退出。 */ + std::atomic_bool destroy_on_exit_{}; /* 是否由工作线程在退出时自销毁。 */ + std::thread thread_; /* 唯一 Datoviz/Vulkan 访问线程。 */ +}; +} + + diff --git a/render_3D/render_3D/main.cpp b/render_3D/render_3D/main.cpp index 33c14ce..5768194 100644 --- a/render_3D/render_3D/main.cpp +++ b/render_3D/render_3D/main.cpp @@ -1,3 +1 @@ -int main() { - return 0; -} +#include "Render_3D.hpp" diff --git a/render_3D/render_3D/scene/Render_Scene_3D.cpp b/render_3D/render_3D/scene/Render_Scene_3D.cpp new file mode 100644 index 0000000..52d11c7 --- /dev/null +++ b/render_3D/render_3D/scene/Render_Scene_3D.cpp @@ -0,0 +1,10 @@ +#include "Render_Scene_3D.hpp" +namespace aethera::render_3d { +bool Render_Scene_3D::Prop::operator==(const Prop&) const = default; +bool Render_Scene_3D::State::operator==(const State&) const = default; +Render_Scene_3D::Request_Frame_Result Render_Scene_3D::request_frame() { return static_cast(*d).dispatch->request_frame(this); } +Render_Scene_3D::Dispatch_Event_Result Render_Scene_3D::dispatch_event(const Event& event) { return static_cast(*d).dispatch->dispatch_event(this, event); } +std::shared_ptr Render_Scene_3D::latest_frame() const { return static_cast(*d).dispatch->latest_frame(this); } +void Render_Scene_3D::activate_view() { static_cast(*d).dispatch->set_active(this, true); } +void Render_Scene_3D::deactivate_view() { static_cast(*d).dispatch->set_active(this, false); } +} diff --git a/render_3D/render_3D/scene/Render_Scene_3D.hpp b/render_3D/render_3D/scene/Render_Scene_3D.hpp new file mode 100644 index 0000000..de44fab --- /dev/null +++ b/render_3D/render_3D/scene/Render_Scene_3D.hpp @@ -0,0 +1,52 @@ +#pragma once +#include "../detail/Backend_Types.hpp" +#include "../visual/Visuals.hpp" +#include +#include +#include +namespace aethera::render_3d { +/* 执行 3D Renderable 图,并把 Paint 阶段无等待地提交给 Datoviz 渲染域。 */ +struct Render_Scene_3D : Def { + struct Prop : Prev_Prop { + Extent viewport{560, 320}; /* 离屏渲染目标尺寸,单位为像素。 */ + Linear_Color clear_color{}; /* 每帧开始时写入的线性背景色。 */ + bool view_active{true}; /* 是否接受新帧请求;停用不清除最近完成帧。 */ + bool operator==(const Prop&) const; + }; + struct State : Prev_State { + std::uint64_t submitted_frame_count{}; /* 已提交给 Vulkan 的累计帧数。 */ + std::uint64_t completed_frame_count{}; /* 已完成 GPU 读回并发布的累计帧数。 */ + std::uint64_t dropped_frame_count{}; /* 因背压或已有在途帧而丢弃的累计请求数。 */ + bool frame_in_flight{}; /* 是否存在尚未完成的 GPU 帧。 */ + bool backend_available{}; /* Datoviz 后端是否可接受新工作。 */ + bool operator==(const State&) const; + }; + struct Private; + template + struct Builder : Prev_Builder { + using Base = Prev_Builder; + using Attach_Visual = std::expected (*)(Object*, Root*); + template + explicit Builder(Visual_Object* visual, std::uint32_t gpu_index = 0, bool validation_enabled = false); + [[nodiscard]] std::expected, Dependency_Graph_Error> build(); + private: + Root* visual{}; /* 不拥有的唯一 Visual;生命周期必须覆盖 Scene。 */ + Visual_Family visual_family{Visual_Family::point}; /* Visual 编译期 Spec 对应的后端 family。 */ + void (*bind_visual)(Root*, std::shared_ptr){}; /* 把 Scene 拥有的弱提交上下文绑定到最终 Visual Private。 */ + Attach_Visual attach_visual{}; /* 以具体 Visual 类型安装 Prepare、Paint 和 Prop 依赖。 */ + std::uint32_t gpu_index{}; /* Datoviz 使用的物理 GPU 下标。 */ + bool validation_enabled{}; /* 是否启用 Vulkan 验证。 */ + }; + enum class Request_Frame_Result : std::uint8_t { queued, view_inactive, empty_viewport, backend_unavailable }; + /* 执行 CPU Taskflow;Paint 节点只入队,不等待 Vulkan 或 GPU fence。 */ + [[nodiscard]] Request_Frame_Result request_frame(); + enum class Dispatch_Event_Result : std::uint8_t { dispatched, ignored, invalid_event, backend_unavailable }; + /* 在 Datoviz 单线程渲染域中同步派发输入事件。 */ + [[nodiscard]] Dispatch_Event_Result dispatch_event(const Event& event); + /* 返回最近一次异步完成的 RGBA8 帧;尚无完成帧时为空。 */ + [[nodiscard]] std::shared_ptr latest_frame() const; + void activate_view(); + void deactivate_view(); +}; +} +#include "Render_Scene_3D.ipp" diff --git a/render_3D/render_3D/scene/Render_Scene_3D.ipp b/render_3D/render_3D/scene/Render_Scene_3D.ipp new file mode 100644 index 0000000..ad55f8b --- /dev/null +++ b/render_3D/render_3D/scene/Render_Scene_3D.ipp @@ -0,0 +1,48 @@ +#pragma once +#include "../detail/Async_Render_Backend.hpp" +#include +namespace aethera::render_3d { +namespace detail { +template +struct Scene_Paint_Context { + std::shared_ptr backend{}; /* Scene 拥有、异步命令延长生命周期的后端。 */ + Object* scene{}; /* 仅在 Scene 拥有本上下文期间读取当前 Prop。 */ +}; +} +struct Render_Scene_3D::Private : Prev_Private { + using Request_Run = Request_Frame_Result (*)(Root*); + using Event_Run = Dispatch_Event_Result (*)(Root*, const Event&); + using Frame_Run = std::shared_ptr (*)(const Root*); + using Active_Run = void (*)(Root*, bool); + struct Dispatch { + Request_Run request_frame; /* 执行 CPU 图并异步提交 Paint。 */ + Event_Run dispatch_event; /* 向 Datoviz 输入路由器派发事件。 */ + Frame_Run latest_frame; /* 获取最近异步完成帧。 */ + Active_Run set_active; /* 修改最终 Scene 的活动属性。 */ + }; + std::shared_ptr backend{}; /* Scene 拥有的异步后端;已入队命令自行延长实现寿命。 */ + std::shared_ptr paint_context{}; /* Paint 节点读取 Scene 当前 Prop 的生命周期门闩。 */ + const Dispatch* dispatch{}; /* 最终 Scene 类型对应的静态公开分派表。 */ + /* Builder 内部初始化后端;必须在绑定 Visual Paint 目标之前调用一次。 */ + template void initialize_backend(Object* object, std::uint32_t gpu_index, bool validation_enabled, Visual_Family visual_family); + /* CRTP 覆盖:绑定 Scene 机制和 Render_Scene_3D 公开薄壳。 */ + template void bind_private_crtp(Object* object); + /* CRTP 覆盖:执行 Kernel Scene Taskflow;其 Paint 子图只负责异步入队。 */ + template void process(Object* object, Callback&& callback) requires std::invocable; + /* CRTP 推进 hook:发布异步后端的统计快照。 */ + template void before_advance(Object* object, Prop_Type* pending_prop, State_Access pending_states, const Prop_Type* current_prop, State_Access current_states); + template [[nodiscard]] static const Dispatch& dispatch_for(); +}; +template template +Render_Scene_3D::Builder::Builder(Visual_Object* visual_value, std::uint32_t gpu_index_value, bool validation_enabled_value) : Base(), visual(visual_value), visual_family(Visual_Object::Attached_Object::Specification::family), gpu_index(gpu_index_value), validation_enabled(validation_enabled_value) { + bind_visual = [](Root* root, std::shared_ptr context) { auto* object = static_cast(root); Base::private_access(object).template get().bind_paint_target(std::move(context), [](void* raw_context, const detail::Prepared_Visual& prepared) { auto& submission = *static_cast*>(raw_context); const auto& prop = submission.scene->template read_prop(); submission.backend->enqueue(prepared, detail::Scene_3D_Parameters{prop.viewport, prop.clear_color}); }); }; + attach_visual = [](Object* scene, Root* root) { auto* object = static_cast(root); return scene->template edit_dependency_graph([&](auto& prepare, auto& paint) { prepare.add(object); paint.add(object); prepare.template add_prop_dependency(object, scene); }); }; +} +template +std::expected, Dependency_Graph_Error> Render_Scene_3D::Builder::build() { if (!visual || !bind_visual || !attach_visual) throw std::invalid_argument("Render_Scene_3D requires one Visual"); auto result = Base::build(); if (!result) return std::unexpected(result.error()); auto scene = std::move(result).value(); auto& private_data = Base::private_access(scene.get()).template get(); private_data.initialize_backend(scene.get(), gpu_index, validation_enabled, visual_family); bind_visual(visual, private_data.paint_context); auto graph_result = attach_visual(scene.get(), visual); if (!graph_result) return std::unexpected(graph_result.error()); return std::move(scene); } +template void Render_Scene_3D::Private::initialize_backend(Object* object, std::uint32_t gpu_index, bool validation_enabled, Visual_Family visual_family) { const auto& prop = object->template read_prop(); backend = std::make_shared(gpu_index, validation_enabled, visual_family, detail::Scene_3D_Parameters{prop.viewport, prop.clear_color}); paint_context = std::make_shared>(detail::Scene_Paint_Context{backend, object}); } +template void Render_Scene_3D::Private::process(Object* object, Callback&& callback) requires std::invocable { const auto& prop = object->template read_prop(); if (!prop.view_active) { std::invoke(std::forward(callback), Request_Frame_Result::view_inactive); return; } if (prop.viewport.empty()) { std::invoke(std::forward(callback), Request_Frame_Result::empty_viewport); return; } if (!backend || !backend->statistics().available) { std::invoke(std::forward(callback), Request_Frame_Result::backend_unavailable); return; } Prev_Private::process(object, [](const Scene::Private::Result&) {}); std::invoke(std::forward(callback), Request_Frame_Result::queued); } +template void Render_Scene_3D::Private::before_advance(Object*, Prop_Type*, State_Access pending_states, const Prop_Type*, State_Access) { if (!backend) return; const auto statistics = backend->statistics(); auto& state = pending_states.template get(); state.submitted_frame_count = statistics.submitted; state.completed_frame_count = statistics.completed; state.dropped_frame_count = statistics.dropped; state.frame_in_flight = statistics.frame_in_flight; state.backend_available = statistics.available; } +template const Render_Scene_3D::Private::Dispatch& Render_Scene_3D::Private::dispatch_for() { static const Dispatch value{[](Root* root) { auto* object = static_cast(root); Request_Frame_Result result{}; object->process([&](Request_Frame_Result value) { result = value; }); return result; }, [](Root* root, const Event& event) { auto* object = static_cast(root); auto& data = static_cast(*object->d); if (!data.backend) return Dispatch_Event_Result::backend_unavailable; const auto viewport = object->template read_prop().viewport; switch (data.backend->dispatch_event(event, viewport)) { case detail::Async_Render_Backend::Dispatch_Event_Result::dispatched: return Dispatch_Event_Result::dispatched; case detail::Async_Render_Backend::Dispatch_Event_Result::ignored: return Dispatch_Event_Result::ignored; case detail::Async_Render_Backend::Dispatch_Event_Result::invalid_event: return Dispatch_Event_Result::invalid_event; case detail::Async_Render_Backend::Dispatch_Event_Result::backend_unavailable: return Dispatch_Event_Result::backend_unavailable; } return Dispatch_Event_Result::backend_unavailable; }, [](const Root* root) { const auto* object = static_cast(root); const auto& data = static_cast(*object->d); return data.backend ? data.backend->latest_frame() : std::shared_ptr{}; }, [](Root* root, bool active) { static_cast(root)->template set<&Prop::view_active>(active); }}; return value; } +template void Render_Scene_3D::Private::bind_private_crtp(Object* object) { Prev_Private::bind_private_crtp(object); dispatch = &dispatch_for(); } +} diff --git a/render_3D/render_3D/visual/Basic_Visual.hpp b/render_3D/render_3D/visual/Basic_Visual.hpp new file mode 100644 index 0000000..f9b701a --- /dev/null +++ b/render_3D/render_3D/visual/Basic_Visual.hpp @@ -0,0 +1,37 @@ +#pragma once +#include "Prepared_Visual.hpp" +#include +#include +#include +#include +#include +namespace aethera::render_3d { +template +struct Basic_Visual : Def, Renderable, Tagged_Buffer> { + using Specification = Spec; + using Item = typename Spec::Item; + struct Prop : Basic_Visual::Prev_Prop, Spec::Settings { + Matrix4 transform{}; /* 本地坐标到 Scene 坐标的变换。 */ + bool visible{true}; /* 是否向后端提交可见图元。 */ + bool depth_test{true}; /* 是否启用深度测试。 */ + std::vector items{}; /* 外部读写的图元数据唯一权威来源。 */ + bool operator==(const Prop&) const; + }; + struct State : Basic_Visual::Prev_State { + std::size_t item_count{}; /* 最近一次推进后发布的输入图元数。 */ + std::size_t prepared_item_count{}; /* 最近一次 Prepare 输出的后端图元数。 */ + std::uint64_t prepared_revision{}; /* 最近一次 Prepare 输出版本。 */ + bool operator==(const State&) const; + }; + struct Private; + enum class Update_Items_Result : std::uint8_t { updated, invalid_data }; + /* 原子替换外部可读写图元集合;无效数据保持原值。 */ + [[nodiscard]] Update_Items_Result update_items(std::vector items); + enum class Edit_Items_Result : std::uint8_t { updated, empty_edit, invalid_data }; + /* 在当前 Prop 副本上编辑图元集合,通过验证后原子提交。 */ + [[nodiscard]] Edit_Items_Result edit_items(const std::function&)>& edit); + /* 返回当前发布 Prop 中的图元数。 */ + [[nodiscard]] std::size_t item_count() const; +}; +} +#include "Basic_Visual.ipp" diff --git a/render_3D/render_3D/visual/Basic_Visual.ipp b/render_3D/render_3D/visual/Basic_Visual.ipp new file mode 100644 index 0000000..91dcf76 --- /dev/null +++ b/render_3D/render_3D/visual/Basic_Visual.ipp @@ -0,0 +1,54 @@ +#pragma once +#include +#include +namespace aethera::render_3d { +template +struct Basic_Visual::Private : Basic_Visual::Prev_Private { + using Enqueue_Paint = void (*)(void*, const detail::Prepared_Visual&); + struct Paint_Target { + std::weak_ptr context{}; /* Scene 拥有的异步提交上下文;Scene 销毁后自动失效。 */ + Enqueue_Paint enqueue{}; /* 只入队不等待 GPU 完成的 Paint 入口。 */ + }; + Paint_Target paint_target{}; /* Builder 绑定的所属 Scene 异步 Paint 目标。 */ + std::uint64_t next_revision{1}; /* 下一份 Prepared_Visual 的单调版本。 */ + using Update_Run = Update_Items_Result (*)(Root*, std::vector); + using Edit_Run = Edit_Items_Result (*)(Root*, const std::function&)>&); + using Count_Run = std::size_t (*)(const Root*); + struct Dispatch { + Update_Run update_items; /* 替换最终对象的 Prop 图元。 */ + Edit_Run edit_items; /* 编辑最终对象的 Prop 图元。 */ + Count_Run item_count; /* 查询最终对象当前 Prop 图元数。 */ + }; + const Dispatch* dispatch{}; /* 最终对象类型对应的静态公开分派表。 */ + /* CRTP 覆盖:绑定 Renderable 能力和 Basic_Visual 公开薄壳。 */ + template void bind_private_crtp(Object* object); + /* Scene Builder 调用:绑定只入队的异步 Paint 目标,目标生命周期必须覆盖 Visual。 */ + void bind_paint_target(std::shared_ptr context, Enqueue_Paint enqueue); + /* CRTP 数据能力:从当前 Prop 构建后端字段,不访问其他缓冲角色。 */ + template void prepare_data(Object* object); + /* CRTP 子图能力:返回只负责提交异步后端工作的单节点图;节点不会等待 GPU。 */ + template [[nodiscard]] tf::Taskflow build_paint_graph(Object* object, const Prop& prop); + /* CRTP 覆盖:Visual 未绑定 Scene 后端时不执行 Paint。 */ + template [[nodiscard]] bool should_paint(Object* object, const State& state, bool dirty); + /* CRTP Prop hook:本层字段改变时标记 Prepare。 */ + template void after_prop_set(Object* object, Member Owner::* member, Prop_Access props); + /* CRTP 推进 hook:从 Prop 计算公开计数,不复制图元集合。 */ + template void before_advance(Object* object, Prop_Type* pending_prop, State_Access pending_states, const Prop_Type* current_prop, State_Access current_states); + template [[nodiscard]] static const Dispatch& dispatch_for(); + [[nodiscard]] static bool valid_items(const std::vector& items); +}; +template bool Basic_Visual::Prop::operator==(const Prop&) const = default; +template bool Basic_Visual::State::operator==(const State&) const = default; +template bool Basic_Visual::Private::valid_items(const std::vector& items) { return std::ranges::all_of(items, [](const Item& item) { return Spec::valid(item); }); } +template void Basic_Visual::Private::bind_paint_target(std::shared_ptr context, Enqueue_Paint enqueue) { paint_target = {std::move(context), enqueue}; } +template template void Basic_Visual::Private::prepare_data(Object* object) { using Visual_Tag = typename Basic_Visual::Base_Tag; const auto& prop = object->template read_prop(); if (!valid_items(prop.items) || !detail::finite(prop.transform)) throw std::invalid_argument("3D visual property contains invalid data"); auto& output = object->template pending_buffer(); output = {}; output.family = Spec::family; output.transform = prop.transform; output.visible = prop.visible; output.depth_test = prop.depth_test; output.revision = next_revision++; if constexpr (requires { prop.style; }) output.point_style = prop.style; Spec::prepare(prop.items, output); object->template update_state<&State::prepared_item_count, &State::prepared_revision>([&](State_Access states) { auto& state = states.template get(); state.prepared_item_count = output.positions.size(); state.prepared_revision = output.revision; }); } +template template tf::Taskflow Basic_Visual::Private::build_paint_graph(Object* object, const Prop&) { tf::Taskflow graph; graph.emplace([this, object] { if (auto context = paint_target.context.lock(); context && paint_target.enqueue) paint_target.enqueue(context.get(), object->template pending_buffer()); }).name("render_3d.paint.enqueue"); return graph; } +template template bool Basic_Visual::Private::should_paint(Object*, const State&, bool dirty) { return dirty && paint_target.enqueue != nullptr && !paint_target.context.expired(); } +template template void Basic_Visual::Private::after_prop_set(Object* object, Member Owner::*, Prop_Access) { if constexpr (std::same_as) object->template mark_dirty(); } +template template void Basic_Visual::Private::before_advance(Object*, Prop_Type*, State_Access pending_states, const Prop_Type* current_prop, State_Access) { using Visual_Tag = typename Basic_Visual::Base_Tag; pending_states.template get().item_count = static_cast(*current_prop).items.size(); } +template template const typename Basic_Visual::Private::Dispatch& Basic_Visual::Private::dispatch_for() { using Visual_Tag = typename Basic_Visual::Base_Tag; static const Dispatch value{[](Root* root, std::vector items) { if (!Private::valid_items(items)) return Update_Items_Result::invalid_data; static_cast(root)->template set<&Prop::items>(std::move(items)); return Update_Items_Result::updated; }, [](Root* root, const std::function&)>& edit) { if (!edit) return Edit_Items_Result::empty_edit; auto* object = static_cast(root); auto items = object->template read_prop().items; edit(items); if (!Private::valid_items(items)) return Edit_Items_Result::invalid_data; object->template set<&Prop::items>(std::move(items)); return Edit_Items_Result::updated; }, [](const Root* root) { return static_cast(root)->template read_prop().items.size(); }}; return value; } +template template void Basic_Visual::Private::bind_private_crtp(Object* object) { Basic_Visual::Prev_Private::bind_private_crtp(object); dispatch = &dispatch_for(); } +template typename Basic_Visual::Update_Items_Result Basic_Visual::update_items(std::vector items) { return static_cast(*this->d).dispatch->update_items(this, std::move(items)); } +template typename Basic_Visual::Edit_Items_Result Basic_Visual::edit_items(const std::function&)>& edit) { return static_cast(*this->d).dispatch->edit_items(this, edit); } +template std::size_t Basic_Visual::item_count() const { return static_cast(*this->d).dispatch->item_count(this); } +} diff --git a/render_3D/render_3D/visual/Prepared_Visual.hpp b/render_3D/render_3D/visual/Prepared_Visual.hpp new file mode 100644 index 0000000..1848280 --- /dev/null +++ b/render_3D/render_3D/visual/Prepared_Visual.hpp @@ -0,0 +1,22 @@ +#pragma once +#include "../base/Types.hpp" +namespace aethera::render_3d::detail { +struct Prepared_Visual_Tag {}; +struct Prepared_Visual { + Visual_Family family{Visual_Family::point}; /* Datoviz Visual 类型。 */ + Matrix4 transform{}; /* 本帧提交的对象变换。 */ + Point_Style point_style{}; /* point family 使用的点样式。 */ + bool visible{true}; /* 本帧是否绘制。 */ + bool depth_test{true}; /* 本帧是否启用深度测试。 */ + std::uint64_t revision{}; /* 数据或样式改变时递增的提交版本。 */ + std::vector> positions{}; /* 主位置字段。 */ + std::vector> colors{}; /* RGBA8 颜色字段。 */ + std::vector sizes{}; /* 点径、半径或线宽字段。 */ + std::vector> sigma{}; /* Splat 椭圆标准差字段。 */ + std::vector angles{}; /* Splat、Marker 旋转角字段。 */ + std::vector shapes{}; /* Marker 形状字段。 */ + std::vector> secondary_positions{}; /* 线段终点或向量字段。 */ + std::vector> normals{}; /* Primitive、Mesh 法线字段。 */ + std::vector> extents{}; /* Image、Labels 尺寸字段。 */ +}; +} diff --git a/render_3D/render_3D/visual/Visuals.hpp b/render_3D/render_3D/visual/Visuals.hpp new file mode 100644 index 0000000..d707881 --- /dev/null +++ b/render_3D/render_3D/visual/Visuals.hpp @@ -0,0 +1,149 @@ +#pragma once +#include "Basic_Visual.hpp" +namespace aethera::render_3d { +struct Point { + Vec3 position{}; /* Scene 坐标位置。 */ + Color color{Color::white()}; /* 点填充颜色。 */ + Pixel_Distance diameter_px{8.0F}; /* 点直径,单位为像素;必须为正数。 */ + bool operator==(const Point&) const = default; +}; +struct Splat { + Vec3 position{}; /* Scene 坐标位置。 */ + Color color{Color::white()}; /* Splat 颜色。 */ + Vec2 sigma{0.05F, 0.05F}; /* 两个主轴方向的标准差;必须为正数。 */ + Coordinate_3D angle{}; /* 主轴旋转角,单位为弧度。 */ + bool operator==(const Splat&) const = default; +}; +struct Pixel { + Vec3 position{}; /* Scene 坐标位置。 */ + Color color{Color::white()}; /* 像素颜色。 */ + Pixel_Distance size_px{1.0F}; /* 方形像素边长,单位为像素;必须为正数。 */ + bool operator==(const Pixel&) const = default; +}; +struct Marker { + Vec3 position{}; /* Scene 坐标位置。 */ + Color color{Color::white()}; /* 标记颜色。 */ + Pixel_Distance diameter_px{12.0F}; /* 标记直径,单位为像素;必须为正数。 */ + Coordinate_3D angle{}; /* 标记旋转角,单位为弧度。 */ + Marker_Shape shape{Marker_Shape::disc}; /* 标记图形。 */ + bool operator==(const Marker&) const = default; +}; +struct Sphere { + Vec3 center{}; /* 球心 Scene 坐标。 */ + Color color{Color::white()}; /* 球体颜色。 */ + Coordinate_3D radius{0.1F}; /* Scene 坐标半径;必须为正数。 */ + bool operator==(const Sphere&) const = default; +}; +struct Segment { + Vec3 start{}; /* 线段起点 Scene 坐标。 */ + Vec3 end{}; /* 线段终点 Scene 坐标。 */ + Color color{Color::white()}; /* 线段颜色。 */ + Pixel_Distance width_px{1.0F}; /* 线宽,单位为像素;必须为正数。 */ + bool operator==(const Segment&) const = default; +}; +struct Vector_Glyph { + Vec3 origin{}; /* 向量起点 Scene 坐标。 */ + Vec3 direction{1.0F, 0.0F, 0.0F}; /* 向量方向和长度。 */ + Color color{Color::white()}; /* 向量颜色。 */ + Pixel_Distance width_px{1.0F}; /* 线宽,单位为像素;必须为正数。 */ + bool operator==(const Vector_Glyph&) const = default; +}; +struct Primitive_Vertex { + Vec3 position{}; /* 顶点 Scene 坐标。 */ + Color color{Color::white()}; /* 顶点颜色。 */ + Vec3 normal{}; /* 顶点法线。 */ + bool operator==(const Primitive_Vertex&) const = default; +}; +struct Mesh_Vertex { + Vec3 position{}; /* 顶点 Scene 坐标。 */ + Color color{Color::white()}; /* 顶点颜色。 */ + Vec3 normal{}; /* 顶点法线。 */ + Vec2 texture_coordinate{}; /* 纹理坐标。 */ + bool operator==(const Mesh_Vertex&) const = default; +}; +struct Path_Vertex { + Vec3 position{}; /* 路径顶点 Scene 坐标。 */ + Color color{Color::white()}; /* 路径颜色。 */ + Pixel_Distance width_px{1.0F}; /* 路径宽度,单位为像素;必须为正数。 */ + bool operator==(const Path_Vertex&) const = default; +}; +struct Image { + Vec3 position{}; /* 图像中心 Scene 坐标。 */ + Vec2 extent{1.0F, 1.0F}; /* Scene 坐标尺寸;两个分量必须为正数。 */ + Vec4 texture_rectangle{0.0F, 0.0F, 1.0F, 1.0F}; /* 纹理采样矩形。 */ + Color tint{Color::white()}; /* 图像颜色调制。 */ + bool operator==(const Image&) const = default; +}; +struct Label { + Vec3 position{}; /* 标签中心 Scene 坐标。 */ + std::uint32_t image_index{}; /* 纹理数组图像下标。 */ + Vec2 extent{32.0F, 16.0F}; /* 标签尺寸,单位为像素;两个分量必须为正数。 */ + Color tint{Color::white()}; /* 标签颜色调制。 */ + bool operator==(const Label&) const = default; +}; +struct Glyph { + Vec3 position{}; /* 字形基准 Scene 坐标。 */ + Vec4 bounds{}; /* 字形边界。 */ + Vec4 texture_coordinates{}; /* 字形纹理坐标。 */ + Color color{Color::white()}; /* 字形颜色。 */ + Coordinate_3D angle{}; /* 字形旋转角,单位为弧度。 */ + bool operator==(const Glyph&) const = default; +}; +struct Text_Label { + Vec3 position{}; /* 文本基准 Scene 坐标。 */ + std::string text{}; /* UTF-8 文本;不能为空。 */ + Color color{Color::white()}; /* 文本颜色。 */ + Pixel_Distance size_px{18.0F}; /* 字号,单位为像素;必须为正数。 */ + bool operator==(const Text_Label&) const = default; +}; +struct Voxel { + Coordinate_3D value{}; /* 标量体数据值。 */ + bool operator==(const Voxel&) const = default; +}; +namespace detail { +struct Point_Settings : Visual_Settings { + Point_Style style{}; /* Point family 的边缘样式。 */ + bool operator==(const Point_Settings&) const = default; +}; +#define AETHERA_VISUAL_SPEC(Name, Item_Type, Settings_Type, Family_Value) \ +struct Name##_Spec { \ + using Item = Item_Type; \ + using Settings = Settings_Type; \ + static constexpr Visual_Family family = Visual_Family::Family_Value; \ + [[nodiscard]] static bool valid(const Item& item) noexcept; \ + static void prepare(const std::vector& items, Prepared_Visual& output); \ +} +AETHERA_VISUAL_SPEC(Point, Point, Point_Settings, point); +AETHERA_VISUAL_SPEC(Splat, Splat, Visual_Settings, splat); +AETHERA_VISUAL_SPEC(Pixel, Pixel, Visual_Settings, pixel); +AETHERA_VISUAL_SPEC(Marker, Marker, Visual_Settings, marker); +AETHERA_VISUAL_SPEC(Sphere, Sphere, Visual_Settings, sphere); +AETHERA_VISUAL_SPEC(Segment, Segment, Visual_Settings, segment); +AETHERA_VISUAL_SPEC(Vector, Vector_Glyph, Visual_Settings, vector); +AETHERA_VISUAL_SPEC(Primitive, Primitive_Vertex, Primitive_Settings, primitive); +AETHERA_VISUAL_SPEC(Mesh, Mesh_Vertex, Visual_Settings, mesh); +AETHERA_VISUAL_SPEC(Path, Path_Vertex, Visual_Settings, path); +AETHERA_VISUAL_SPEC(Image, Image, Texture_Field_Settings, image); +AETHERA_VISUAL_SPEC(Labels, Label, Texture_Field_Settings, labels); +AETHERA_VISUAL_SPEC(Glyph, Glyph, Texture_Field_Settings, glyph); +AETHERA_VISUAL_SPEC(Text, Text_Label, Visual_Settings, text); +AETHERA_VISUAL_SPEC(Volume, Voxel, Volume_Field_Settings, volume); +#undef AETHERA_VISUAL_SPEC +} +using Point_Visual = Basic_Visual; +using Splat_Visual = Basic_Visual; +using Pixel_Visual = Basic_Visual; +using Marker_Visual = Basic_Visual; +using Sphere_Visual = Basic_Visual; +using Segment_Visual = Basic_Visual; +using Vector_Visual = Basic_Visual; +using Primitive_Visual = Basic_Visual; +using Mesh_Visual = Basic_Visual; +using Path_Visual = Basic_Visual; +using Image_Visual = Basic_Visual; +using Labels_Visual = Basic_Visual; +using Glyph_Visual = Basic_Visual; +using Text_Visual = Basic_Visual; +using Volume_Visual = Basic_Visual; +} +#include "Visuals.ipp" diff --git a/render_3D/render_3D/visual/Visuals.ipp b/render_3D/render_3D/visual/Visuals.ipp new file mode 100644 index 0000000..0777236 --- /dev/null +++ b/render_3D/render_3D/visual/Visuals.ipp @@ -0,0 +1,35 @@ +#pragma once +namespace aethera::render_3d::detail { +inline std::array channels(Color value) { return {value.red, value.green, value.blue, value.alpha}; } +inline void reserve_common(Prepared_Visual& output, std::size_t count) { output.positions.reserve(count); output.colors.reserve(count); output.sizes.reserve(count); } +inline bool Point_Spec::valid(const Item& item) noexcept { return finite(item.position) && finite(item.diameter_px) && item.diameter_px > 0.0F; } +inline void Point_Spec::prepare(const std::vector& items, Prepared_Visual& output) { reserve_common(output, items.size()); for (const auto& item : items) { output.positions.push_back({item.position.x, item.position.y, item.position.z}); output.colors.push_back(channels(item.color)); output.sizes.push_back(item.diameter_px); } } +inline bool Splat_Spec::valid(const Item& item) noexcept { return finite(item.position) && finite(item.sigma) && item.sigma.x > 0.0F && item.sigma.y > 0.0F && finite(item.angle); } +inline void Splat_Spec::prepare(const std::vector& items, Prepared_Visual& output) { reserve_common(output, items.size()); output.sigma.reserve(items.size()); output.angles.reserve(items.size()); for (const auto& item : items) { output.positions.push_back({item.position.x, item.position.y, item.position.z}); output.colors.push_back(channels(item.color)); output.sigma.push_back({item.sigma.x, item.sigma.y}); output.angles.push_back(item.angle); } } +inline bool Pixel_Spec::valid(const Item& item) noexcept { return finite(item.position) && finite(item.size_px) && item.size_px > 0.0F; } +inline void Pixel_Spec::prepare(const std::vector& items, Prepared_Visual& output) { reserve_common(output, items.size()); for (const auto& item : items) { output.positions.push_back({item.position.x, item.position.y, item.position.z}); output.colors.push_back(channels(item.color)); output.sizes.push_back(item.size_px); } } +inline bool Marker_Spec::valid(const Item& item) noexcept { return finite(item.position) && finite(item.diameter_px) && item.diameter_px > 0.0F && finite(item.angle); } +inline void Marker_Spec::prepare(const std::vector& items, Prepared_Visual& output) { reserve_common(output, items.size()); output.angles.reserve(items.size()); output.shapes.reserve(items.size()); for (const auto& item : items) { output.positions.push_back({item.position.x, item.position.y, item.position.z}); output.colors.push_back(channels(item.color)); output.sizes.push_back(item.diameter_px); output.angles.push_back(item.angle); output.shapes.push_back(static_cast(item.shape)); } } +inline bool Sphere_Spec::valid(const Item& item) noexcept { return finite(item.center) && finite(item.radius) && item.radius > 0.0F; } +inline void Sphere_Spec::prepare(const std::vector& items, Prepared_Visual& output) { reserve_common(output, items.size()); for (const auto& item : items) { output.positions.push_back({item.center.x, item.center.y, item.center.z}); output.colors.push_back(channels(item.color)); output.sizes.push_back(item.radius); } } +inline bool Segment_Spec::valid(const Item& item) noexcept { return finite(item.start) && finite(item.end) && finite(item.width_px) && item.width_px > 0.0F; } +inline void Segment_Spec::prepare(const std::vector& items, Prepared_Visual& output) { reserve_common(output, items.size()); output.secondary_positions.reserve(items.size()); for (const auto& item : items) { output.positions.push_back({item.start.x, item.start.y, item.start.z}); output.secondary_positions.push_back({item.end.x, item.end.y, item.end.z}); output.colors.push_back(channels(item.color)); output.sizes.push_back(item.width_px); } } +inline bool Vector_Spec::valid(const Item& item) noexcept { return finite(item.origin) && finite(item.direction) && finite(item.width_px) && item.width_px > 0.0F; } +inline void Vector_Spec::prepare(const std::vector& items, Prepared_Visual& output) { reserve_common(output, items.size()); output.secondary_positions.reserve(items.size()); for (const auto& item : items) { output.positions.push_back({item.origin.x, item.origin.y, item.origin.z}); output.secondary_positions.push_back({item.direction.x, item.direction.y, item.direction.z}); output.colors.push_back(channels(item.color)); output.sizes.push_back(item.width_px); } } +inline bool Primitive_Spec::valid(const Item& item) noexcept { return finite(item.position) && finite(item.normal); } +inline void Primitive_Spec::prepare(const std::vector& items, Prepared_Visual& output) { reserve_common(output, items.size()); output.normals.reserve(items.size()); for (const auto& item : items) { output.positions.push_back({item.position.x, item.position.y, item.position.z}); output.colors.push_back(channels(item.color)); output.normals.push_back({item.normal.x, item.normal.y, item.normal.z}); } } +inline bool Mesh_Spec::valid(const Item& item) noexcept { return finite(item.position) && finite(item.normal) && finite(item.texture_coordinate); } +inline void Mesh_Spec::prepare(const std::vector& items, Prepared_Visual& output) { reserve_common(output, items.size()); output.normals.reserve(items.size()); for (const auto& item : items) { output.positions.push_back({item.position.x, item.position.y, item.position.z}); output.colors.push_back(channels(item.color)); output.normals.push_back({item.normal.x, item.normal.y, item.normal.z}); } } +inline bool Path_Spec::valid(const Item& item) noexcept { return finite(item.position) && finite(item.width_px) && item.width_px > 0.0F; } +inline void Path_Spec::prepare(const std::vector& items, Prepared_Visual& output) { reserve_common(output, items.size()); for (const auto& item : items) { output.positions.push_back({item.position.x, item.position.y, item.position.z}); output.colors.push_back(channels(item.color)); output.sizes.push_back(item.width_px); } } +inline bool Image_Spec::valid(const Item& item) noexcept { return finite(item.position) && finite(item.extent) && item.extent.x > 0.0F && item.extent.y > 0.0F && finite(item.texture_rectangle); } +inline void Image_Spec::prepare(const std::vector& items, Prepared_Visual& output) { output.positions.reserve(items.size()); output.extents.reserve(items.size()); output.colors.reserve(items.size()); for (const auto& item : items) { output.positions.push_back({item.position.x, item.position.y, item.position.z}); output.extents.push_back({item.extent.x, item.extent.y}); output.colors.push_back(channels(item.tint)); } } +inline bool Labels_Spec::valid(const Item& item) noexcept { return finite(item.position) && finite(item.extent) && item.extent.x > 0.0F && item.extent.y > 0.0F; } +inline void Labels_Spec::prepare(const std::vector& items, Prepared_Visual& output) { output.positions.reserve(items.size()); output.extents.reserve(items.size()); output.colors.reserve(items.size()); for (const auto& item : items) { output.positions.push_back({item.position.x, item.position.y, item.position.z}); output.extents.push_back({item.extent.x, item.extent.y}); output.colors.push_back(channels(item.tint)); } } +inline bool Glyph_Spec::valid(const Item& item) noexcept { return finite(item.position) && finite(item.bounds) && finite(item.texture_coordinates) && finite(item.angle); } +inline void Glyph_Spec::prepare(const std::vector& items, Prepared_Visual& output) { output.positions.reserve(items.size()); output.colors.reserve(items.size()); output.angles.reserve(items.size()); for (const auto& item : items) { output.positions.push_back({item.position.x, item.position.y, item.position.z}); output.colors.push_back(channels(item.color)); output.angles.push_back(item.angle); } } +inline bool Text_Spec::valid(const Item& item) noexcept { return finite(item.position) && !item.text.empty() && finite(item.size_px) && item.size_px > 0.0F; } +inline void Text_Spec::prepare(const std::vector& items, Prepared_Visual& output) { output.positions.reserve(items.size()); output.colors.reserve(items.size()); output.sizes.reserve(items.size()); for (const auto& item : items) { output.positions.push_back({item.position.x, item.position.y, item.position.z}); output.colors.push_back(channels(item.color)); output.sizes.push_back(item.size_px); } } +inline bool Volume_Spec::valid(const Item& item) noexcept { return finite(item.value); } +inline void Volume_Spec::prepare(const std::vector& items, Prepared_Visual& output) { output.sizes.reserve(items.size()); for (const auto& item : items) output.sizes.push_back(item.value); } +} diff --git a/render_3D/third_party/datoviz/src/scene/text/text_atlas.cpp b/render_3D/third_party/datoviz/src/scene/text/text_atlas.cpp index acc28f6..9a4d2c9 100644 --- a/render_3D/third_party/datoviz/src/scene/text/text_atlas.cpp +++ b/render_3D/third_party/datoviz/src/scene/text/text_atlas.cpp @@ -298,7 +298,7 @@ static bool _text_msdf_build_atlas( } msdf_atlas::ImmediateAtlasGenerator< - float, 4, &msdf_atlas::mtsdfGenerator, msdf_atlas::BitmapAtlasStorage> + float, 4, msdf_atlas::mtsdfGenerator, msdf_atlas::BitmapAtlasStorage> generator(width, height); msdf_atlas::GeneratorAttributes attributes; attributes.config.overlapSupport = true; diff --git a/webapp_gallery/src/app.tsx b/webapp_gallery/src/app.tsx deleted file mode 100644 index f71eda4..0000000 --- a/webapp_gallery/src/app.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import {Alert, Box, CircularProgress, Container, Stack, Typography, useMediaQuery} from "@mui/material"; -import {useTheme} from "@mui/material/styles"; -import {useEffect, useMemo, useState} from "react"; -import {Group, Panel, Separator} from "react-resizable-panels"; -import {Category_Filter} from "./gallery/category_filter"; -import {Gallery_Page} from "./gallery/gallery_page"; -import {Gallery_Summary} from "./gallery/gallery_summary"; -import {Gallery_Toolbar} from "./gallery/gallery_toolbar"; -import type {Selected_Plot} from "./gallery/plot_card"; -import {use_gallery_catalog} from "./hooks/use_gallery_catalog"; -import {Inspector_Panel} from "./inspector/inspector_panel"; - -export function App() { - const {catalog, state: socket_state, message: socket_message} = use_gallery_catalog(); - const [active_category, set_active_category] = useState(""); - const [streams_paused, set_streams_paused] = useState(false); - const [selected_plot, set_selected_plot] = useState(null); - const [selected_inspector_tab, set_selected_inspector_tab] = useState("controls"); - const [, set_visibility_epoch] = useState(0); - const theme = useTheme(); - const compact_layout = useMediaQuery(theme.breakpoints.down("md")); - - useEffect(() => { - const change = () => set_visibility_epoch(value => value + 1); - document.addEventListener("visibilitychange", change); - return () => document.removeEventListener("visibilitychange", change); - }, []); - - useEffect(() => { - if (!catalog) - return; - set_active_category(catalog.navigation.all_categories_label); - }, [catalog]); - - const modes = useMemo( - () => [...(catalog?.frame_modes ?? [])].sort((left, right) => left.order - right.order), - [catalog], - ); - const cases = useMemo( - () => [...(catalog?.cases ?? [])].sort((left, right) => left.order - right.order), - [catalog], - ); - const frame_mode = modes.find(mode => mode.id === catalog?.navigation.default_mode) - ?? modes.find(mode => mode.id === "low_latency") - ?? modes[0]; - const categories = catalog - ? [catalog.navigation.all_categories_label, ...new Set(cases.map(item => item.category))] - : []; - const visible_cases = catalog - ? cases.filter(item => active_category === catalog.navigation.all_categories_label || item.category === active_category) - : []; - - const gallery = - - - {!catalog && socket_state !== "error" && - - 等待后端返回控件与帧策略目录 - } - {!catalog && socket_state === "error" && {socket_message}} - {catalog && <> - - - {frame_mode && <> - - } - } - - - ; - - return - set_streams_paused(value => !value)} - /> - - - - {gallery} - - {selected_plot && <> - - - set_selected_plot(null)} - /> - - } - - - ; -} diff --git a/webapp_gallery/src/capture/capture_frames.tsx b/webapp_gallery/src/capture/capture_frames.tsx deleted file mode 100644 index 1a463aa..0000000 --- a/webapp_gallery/src/capture/capture_frames.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import {List,ListItemButton,ListItemText} from "@mui/material";import type {Gallery_Captured_Frame} from "../protocol/gallery_types";import {format_nanoseconds} from "../protocol/format"; -export function Capture_Frames({frames,selected_frame_id,on_select}:{frames:Gallery_Captured_Frame[];selected_frame_id:number|null;on_select:(id:number)=>void}) {return {frames.map(frame=>on_select(frame.frame_id)}>)};} diff --git a/webapp_gallery/src/capture/capture_panel.tsx b/webapp_gallery/src/capture/capture_panel.tsx deleted file mode 100644 index a367dd2..0000000 --- a/webapp_gallery/src/capture/capture_panel.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import {Divider,Paper,Stack,Typography} from "@mui/material"; -import {useState} from "react"; -import type {Gallery_Performance_Capture} from "../protocol/gallery_types"; -import {Capture_Toolbar} from "./capture_toolbar";import {Capture_Sessions} from "./capture_sessions";import {Capture_Frames} from "./capture_frames";import {Frame_Summary} from "./frame_summary";import {Worker_Timeline} from "./worker_timeline";import {Node_Detail} from "./node_detail";import {Plan_Comparison} from "./plan_comparison";import {Render_Dag} from "../dag/render_dag"; -export function Capture_Panel({capture,on_capture}:{capture:Gallery_Performance_Capture;on_capture:(action:string,count?:number)=>void}) { - const latest_session=capture.sessions.at(-1)??null; - const [selected_capture_session,set_selected_capture_session]=useState(latest_session?.session_id??null); - const session=capture.sessions.find(item=>item.session_id===selected_capture_session)??latest_session; - const [selected_capture_frame,set_selected_capture_frame]=useState(session?.frames.at(-1)?.frame_id??null); - const frame=session?.frames.find(item=>item.frame_id===selected_capture_frame)??session?.frames.at(-1); - const plan=frame?capture.plans.find(item=>item.version===frame.render_plan_version):undefined; - const [selected_node_id,set_selected_node_id]=useState(null); - return {session&&<>{set_selected_capture_session(id);set_selected_capture_frame(null);set_selected_node_id(null);}}/>{set_selected_capture_frame(id);set_selected_node_id(null);}}/>}{session&&frame&&plan&&<>Frame SummaryRender DAGWorker TimelineNode DetailPlan Comparison}; -} diff --git a/webapp_gallery/src/capture/capture_sessions.tsx b/webapp_gallery/src/capture/capture_sessions.tsx deleted file mode 100644 index 4fcd665..0000000 --- a/webapp_gallery/src/capture/capture_sessions.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import {MenuItem,TextField} from "@mui/material";import type {Gallery_Capture_Session} from "../protocol/gallery_types"; -export function Capture_Sessions({sessions,selected_session_id,on_select}:{sessions:Gallery_Capture_Session[];selected_session_id:number|null;on_select:(id:number)=>void}) {return on_select(Number(event.target.value))}>{sessions.map(session=>Session #{session.session_id} · {session.captured_count}/{session.requested_count})};} diff --git a/webapp_gallery/src/capture/capture_toolbar.tsx b/webapp_gallery/src/capture/capture_toolbar.tsx deleted file mode 100644 index 783af84..0000000 --- a/webapp_gallery/src/capture/capture_toolbar.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import DownloadIcon from "@mui/icons-material/Download"; -import {Button,LinearProgress,Stack,TextField,Typography} from "@mui/material"; -import {useState} from "react"; -import type {Gallery_Performance_Capture} from "../protocol/gallery_types"; -import {download_perfetto_trace} from "./perfetto_export"; -export function Capture_Toolbar({capture,on_capture}:{capture:Gallery_Performance_Capture;on_capture:(action:string,count?:number)=>void}) {const [capture_count_draft,set_capture_count_draft]=useState("20");const active=capture.sessions.find(session=>session.session_id===capture.controller.session_id)||capture.sessions.find(session=>session.active);const requested=active?.requested_count??0,captured=active?.captured_count??0;const has_frames=capture.sessions.some(session=>session.frames.length>0);return set_capture_count_draft(event.target.value)} sx={{width:110}}/>{requested?`captured ${captured} / ${requested}`:"Capture disabled"}{requested>0&&};} diff --git a/webapp_gallery/src/capture/frame_summary.tsx b/webapp_gallery/src/capture/frame_summary.tsx deleted file mode 100644 index fbd67a7..0000000 --- a/webapp_gallery/src/capture/frame_summary.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import {Metric_Grid} from "../common/metric_grid"; -import type {Gallery_Captured_Frame} from "../protocol/gallery_types"; -import {format_nanoseconds} from "../protocol/format"; - -export function Frame_Summary({frame}:{frame:Gallery_Captured_Frame}) { - const workers=new Set(frame.node_executions.filter(item=>item.cpu_duration_ns>0).map(item=>item.worker_id)); - return ; -} diff --git a/webapp_gallery/src/capture/node_detail.tsx b/webapp_gallery/src/capture/node_detail.tsx deleted file mode 100644 index 4820f44..0000000 --- a/webapp_gallery/src/capture/node_detail.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import {Stack,Typography} from "@mui/material"; -import type {Gallery_Capture_Session,Gallery_Captured_Frame,Gallery_Render_Plan,Json_Value} from "../protocol/gallery_types"; -import {Metric_Grid} from "../common/metric_grid"; -import {Json_Viewer} from "../common/json_viewer"; -import {format_nanoseconds} from "../protocol/format"; - -function attachment_value(type:string,content:string):Json_Value { - if(!type.endsWith(".json"))return content; - try{return JSON.parse(content) as Json_Value;}catch{return content;} -} - -export function Node_Detail({frame,plan,session,selected_node_id}:{frame:Gallery_Captured_Frame;plan:Gallery_Render_Plan;session:Gallery_Capture_Session;selected_node_id:number|null}) { - const node=plan.nodes.find(item=>item.node_id===selected_node_id); - const execution=frame.node_executions.find(item=>item.node_id===selected_node_id); - const analysis=frame.analysis?.nodes.find(item=>item.node_id===selected_node_id); - const history=session.node_statistics.find(item=>item.node_id===selected_node_id); - if(!node||!execution)return Select a DAG node or timeline interval to inspect it.; - return {execution.metrics&&} {execution.attachments.map((attachment,index)=>{attachment.type})}; -} diff --git a/webapp_gallery/src/capture/perfetto_export.ts b/webapp_gallery/src/capture/perfetto_export.ts deleted file mode 100644 index d5d001c..0000000 --- a/webapp_gallery/src/capture/perfetto_export.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type {Gallery_Performance_Capture} from "../protocol/gallery_types"; -interface Trace_Event {name:string;cat:string;ph:"X"|"M";ts?:number;dur?:number;pid:number;tid:number;args?:Record;} -export function perfetto_trace_json(capture:Gallery_Performance_Capture): string { - const events:Trace_Event[]=[]; - const plans=new Map(capture.plans.map(plan=>[plan.version,new Map(plan.nodes.map(node=>[node.node_id,node]))])); - for(const session of capture.sessions){let frame_base_ns=0;const workers=new Set();for(const frame of session.frames){const nodes=plans.get(frame.render_plan_version);events.push({name:`Frame ${frame.frame_id}`,cat:"renderive.frame",ph:"X",ts:frame_base_ns/1000,dur:frame.render_duration_ns/1000,pid:session.session_id,tid:0,args:{render_plan_version:frame.render_plan_version}});for(const execution of frame.node_executions){const node=nodes?.get(execution.node_id);const name=node?.name??`Node ${execution.node_id}`;if(execution.cpu_end_offset_ns>execution.start_offset_ns){workers.add(execution.worker_id);events.push({name,cat:"renderive.cpu",ph:"X",ts:(frame_base_ns+execution.start_offset_ns)/1000,dur:(execution.cpu_end_offset_ns-execution.start_offset_ns)/1000,pid:session.session_id,tid:execution.worker_id,args:{node_id:execution.node_id,kind:node?.kind,owner:node?.owner,status:execution.status}});}if(execution.external_end_offset_ns>execution.external_start_offset_ns)events.push({name:`${name} external`,cat:"renderive.external",ph:"X",ts:(frame_base_ns+execution.external_start_offset_ns)/1000,dur:(execution.external_end_offset_ns-execution.external_start_offset_ns)/1000,pid:session.session_id,tid:1000000,args:{node_id:execution.node_id,status:execution.status}});}frame_base_ns+=Math.max(frame.render_duration_ns,...frame.node_executions.map(node=>node.end_offset_ns),0)+1_000_000;}for(const worker of workers)events.push({name:"thread_name",cat:"__metadata",ph:"M",pid:session.session_id,tid:worker,args:{name:`oneTBB worker ${worker}`}});events.push({name:"thread_name",cat:"__metadata",ph:"M",pid:session.session_id,tid:1000000,args:{name:"External / GPU"}});} - return JSON.stringify({traceEvents:events,displayTimeUnit:"ns"}); -} -export function download_perfetto_trace(capture:Gallery_Performance_Capture): void {const blob=new Blob([perfetto_trace_json(capture)],{type:"application/json"});const url=URL.createObjectURL(blob);const anchor=document.createElement("a");anchor.href=url;anchor.download=`renderive-perfetto-${Date.now()}.json`;anchor.click();URL.revokeObjectURL(url);} diff --git a/webapp_gallery/src/capture/plan_comparison.tsx b/webapp_gallery/src/capture/plan_comparison.tsx deleted file mode 100644 index 4f86327..0000000 --- a/webapp_gallery/src/capture/plan_comparison.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import {Stack,Typography} from "@mui/material";import type {Gallery_Capture_Session,Gallery_Performance_Capture} from "../protocol/gallery_types";import {Json_Viewer} from "../common/json_viewer"; -export function Plan_Comparison({capture,session}:{capture:Gallery_Performance_Capture;session:Gallery_Capture_Session}) {if(session.plan_statistics.length<2)return 捕获不同 Render Plan 版本后可比较拓扑与性能。;const first=session.plan_statistics[0],second=session.plan_statistics.at(-1)!;const first_plan=capture.plans.find(plan=>plan.version===first.render_plan_version),second_plan=capture.plans.find(plan=>plan.version===second.render_plan_version);const first_nodes=new Set(first_plan?.nodes.map(node=>node.node_id)),second_nodes=new Set(second_plan?.nodes.map(node=>node.node_id));return Plan v{first.render_plan_version} → v{second.render_plan_version}!first_nodes.has(id)),removed_nodes:[...first_nodes].filter(id=>!second_nodes.has(id))}}/>;} diff --git a/webapp_gallery/src/capture/worker_timeline.tsx b/webapp_gallery/src/capture/worker_timeline.tsx deleted file mode 100644 index fb7dc34..0000000 --- a/webapp_gallery/src/capture/worker_timeline.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import * as echarts from "echarts"; -import type {EChartsOption} from "echarts"; -import {ECharts_View} from "../common/echarts_view"; -import type {Gallery_Captured_Frame,Gallery_Render_Plan} from "../protocol/gallery_types"; -import {format_nanoseconds} from "../protocol/format"; -interface Timeline_Datum {value:[number,number,number];node_id:number;name:string;duration_ns:number;selected:boolean;} -export function Worker_Timeline({frame,plan,selected_node_id,on_select_node}:{frame:Gallery_Captured_Frame;plan:Gallery_Render_Plan;selected_node_id:number|null;on_select_node:(id:number)=>void}) { - const cpu_nodes=frame.node_executions.filter(item=>item.cpu_duration_ns>0),external_nodes=frame.node_executions.filter(item=>item.external_duration_ns>0),workers=[...new Set(cpu_nodes.map(item=>item.worker_id))].toSorted((a,b)=>a-b),lanes=[...workers.map(worker=>`Worker ${worker}`),...(external_nodes.length?["External"]:[])],names=new Map(plan.nodes.map(node=>[node.node_id,node.name])),duration=Math.max(1,frame.render_duration_ns,...frame.node_executions.map(item=>item.end_offset_ns)); - const cpu_data:Timeline_Datum[]=cpu_nodes.map(item=>({value:[workers.indexOf(item.worker_id),item.start_offset_ns,item.cpu_end_offset_ns],node_id:item.node_id,name:names.get(item.node_id)??String(item.node_id),duration_ns:item.cpu_duration_ns,selected:item.node_id===selected_node_id})); - const external_lane=workers.length,external_data:Timeline_Datum[]=external_nodes.map(item=>({value:[external_lane,item.external_start_offset_ns,item.external_end_offset_ns],node_id:item.node_id,name:names.get(item.node_id)??String(item.node_id),duration_ns:item.external_duration_ns,selected:item.node_id===selected_node_id})); - const render_item=(params:any,api:any)=>{const lane=Number(api.value(0)),start=api.coord([api.value(1),lane]),end=api.coord([api.value(2),lane]),height=Math.max(6,Number(api.size([0,1])[1])*.55),shape=echarts.graphic.clipRectByRect({x:start[0],y:start[1]-height/2,width:Math.max(1,end[0]-start[0]),height},{x:params.coordSys.x,y:params.coordSys.y,width:params.coordSys.width,height:params.coordSys.height});return shape?{type:"rect" as const,shape,style:api.style()}:null;}; - const series=(name:string,data:Timeline_Datum[],opacity:number)=>({name,type:"custom" as const,renderItem:render_item,encode:{x:[1,2],y:0},data:data.map(item=>({...item,itemStyle:{opacity,borderWidth:item.selected?2:0,borderColor:item.selected?"#fff":undefined}}))}); - const option:EChartsOption={backgroundColor:"transparent",animation:false,tooltip:{formatter:(params:any)=>{const item=params.data as Timeline_Datum;return `${item.name} · ${params.seriesName} · ${format_nanoseconds(item.duration_ns)}`;}},legend:{data:["CPU",...(external_nodes.length?["External"]:[])]},grid:{left:92,right:24,top:42,bottom:42},xAxis:{type:"value",min:0,max:duration,axisLabel:{formatter:value=>format_nanoseconds(Number(value))}},yAxis:{type:"category",data:lanes},dataZoom:[{type:"inside",xAxisIndex:0},{type:"slider",xAxisIndex:0,height:16,bottom:8}],series:[series("CPU",cpu_data,1),...(external_nodes.length?[series("External",external_data,.65)]:[])]}; - return {const item=data as Timeline_Datum|undefined;if(item?.node_id!==undefined)on_select_node(item.node_id);}}/>; -} diff --git a/webapp_gallery/src/common/copy_button.tsx b/webapp_gallery/src/common/copy_button.tsx deleted file mode 100644 index 782ff99..0000000 --- a/webapp_gallery/src/common/copy_button.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import ContentCopyIcon from "@mui/icons-material/ContentCopy"; -import {IconButton,Tooltip} from "@mui/material"; -export function Copy_Button({value}:{value:string}) {return void navigator.clipboard.writeText(value)}>;} diff --git a/webapp_gallery/src/common/dashboard_field_grid.tsx b/webapp_gallery/src/common/dashboard_field_grid.tsx deleted file mode 100644 index bb60012..0000000 --- a/webapp_gallery/src/common/dashboard_field_grid.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import {Box,Typography} from "@mui/material"; -import {format_dashboard_field, value_at_path} from "../protocol/format"; -import type {Gallery_Dashboard, Gallery_Dashboard_Field} from "../protocol/gallery_types"; -export function Dashboard_Field_Grid({fields,telemetry,dashboard}:{fields:Gallery_Dashboard_Field[];telemetry:Record;dashboard:Gallery_Dashboard}) { - const visible=fields.filter(field=>field.source===undefined||value_at_path(telemetry,field.source)!==undefined||field.default!==undefined); - return {visible.map((field,index)=>{format_dashboard_field(field,telemetry,dashboard)}{field.label})}; -} diff --git a/webapp_gallery/src/common/echarts_view.tsx b/webapp_gallery/src/common/echarts_view.tsx deleted file mode 100644 index f916a23..0000000 --- a/webapp_gallery/src/common/echarts_view.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import * as echarts from "echarts"; -import type {EChartsOption} from "echarts"; -import {useEffect,useRef} from "react"; -export function ECharts_View({option,height=220,on_click}:{option:EChartsOption;height?:number;on_click?:(data:unknown)=>void}) { - const element=useRef(null),chart=useRef|null>(null); - useEffect(()=>{if(!element.current)return;chart.current=echarts.init(element.current,"dark");const observer=new ResizeObserver(()=>chart.current?.resize());observer.observe(element.current);return()=>{observer.disconnect();chart.current?.dispose();chart.current=null;};},[]); - useEffect(()=>{chart.current?.setOption(option,true);},[option]); - useEffect(()=>{const current=chart.current;if(!current||!on_click)return;const handler=(event:{data?:unknown})=>on_click(event.data);current.on("click",handler);return()=>{current.off("click",handler);};},[on_click]); - return
; -} diff --git a/webapp_gallery/src/common/empty_state.tsx b/webapp_gallery/src/common/empty_state.tsx deleted file mode 100644 index 7a22c0f..0000000 --- a/webapp_gallery/src/common/empty_state.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import {Box,Typography} from "@mui/material"; -export function Empty_State({message}:{message:string}) {return {message};} diff --git a/webapp_gallery/src/common/json_viewer.tsx b/webapp_gallery/src/common/json_viewer.tsx deleted file mode 100644 index 7d11861..0000000 --- a/webapp_gallery/src/common/json_viewer.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import {Box} from "@mui/material"; -import {Copy_Button} from "./copy_button"; -export function Json_Viewer({value}:{value:unknown}) {const text=JSON.stringify(value,null,2)??"undefined";return {text};} diff --git a/webapp_gallery/src/common/metric_grid.tsx b/webapp_gallery/src/common/metric_grid.tsx deleted file mode 100644 index 5d5cfdb..0000000 --- a/webapp_gallery/src/common/metric_grid.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import {Box} from "@mui/material"; -import {Metric_Value} from "./metric_value"; -export function Metric_Grid({values}:{values:Array<[string,string|number]>}) {return {values.map(([label,value])=>)};} diff --git a/webapp_gallery/src/common/metric_value.tsx b/webapp_gallery/src/common/metric_value.tsx deleted file mode 100644 index 58c7939..0000000 --- a/webapp_gallery/src/common/metric_value.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import {Box,Typography} from "@mui/material"; -export function Metric_Value({label,value}:{label:string;value:string|number}) {return {value}{label};} diff --git a/webapp_gallery/src/common/status_chip.tsx b/webapp_gallery/src/common/status_chip.tsx deleted file mode 100644 index c92510d..0000000 --- a/webapp_gallery/src/common/status_chip.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import {Chip} from "@mui/material"; -export function Status_Chip({state,label}:{state:string;label:string}) {const color=state==="ready"?"success":state==="error"||state==="closed"?"error":"warning";return ;} diff --git a/webapp_gallery/src/dag/dag_layout.ts b/webapp_gallery/src/dag/dag_layout.ts deleted file mode 100644 index 5ab55f2..0000000 --- a/webapp_gallery/src/dag/dag_layout.ts +++ /dev/null @@ -1,3 +0,0 @@ -import ELK from "elkjs/lib/elk.bundled.js";import type {Dag_View_Model} from "./dag_types"; -const elk=new ELK();const layout_cache=new Map(); -export async function layout_dag(model:Dag_View_Model,direction:"RIGHT"|"DOWN"="RIGHT"):Promise{const key=`${model.version}:${direction}:${model.nodes.map(node=>node.id).join(",")}:${model.edges.map(edge=>`${edge.source}>${edge.target}`).join(",")}`;const cached=layout_cache.get(key);if(cached)return{...cached,nodes:cached.nodes.map((node,index)=>({...node,data:model.nodes[index].data})),edges:model.edges};const result=await elk.layout({id:"root",layoutOptions:{"elk.algorithm":"layered","elk.direction":direction,"elk.spacing.nodeNode":"36","elk.layered.spacing.nodeNodeBetweenLayers":"70"},children:model.nodes.map(node=>({id:node.id,width:250,height:96})),edges:model.edges.map(edge=>({id:edge.id,sources:[edge.source],targets:[edge.target]}))});const positions=new Map(result.children?.map(node=>[node.id,{x:node.x??0,y:node.y??0}]));const laid={...model,nodes:model.nodes.map(node=>({...node,position:positions.get(node.id)??node.position}))};layout_cache.set(key,laid);return laid;} diff --git a/webapp_gallery/src/dag/dag_legend.tsx b/webapp_gallery/src/dag/dag_legend.tsx deleted file mode 100644 index 15c6c7a..0000000 --- a/webapp_gallery/src/dag/dag_legend.tsx +++ /dev/null @@ -1 +0,0 @@ -import {Chip,Stack} from "@mui/material";export function Dag_Legend(){return ;} diff --git a/webapp_gallery/src/dag/dag_model.ts b/webapp_gallery/src/dag/dag_model.ts deleted file mode 100644 index 84e283f..0000000 --- a/webapp_gallery/src/dag/dag_model.ts +++ /dev/null @@ -1,2 +0,0 @@ -import type {Gallery_Captured_Frame,Gallery_Node_Statistics,Gallery_Render_Plan} from "../protocol/gallery_types";import type {Dag_View_Model} from "./dag_types"; -export function build_dag_model(plan:Gallery_Render_Plan,frame:Gallery_Captured_Frame|null,statistics:Gallery_Node_Statistics[]=[],selected_node_id:number|null=null):Dag_View_Model {const executions=new Map((frame?.node_executions??[]).map(item=>[item.node_id,item]));const analysis=new Map((frame?.analysis?.nodes??[]).map(item=>[item.node_id,item]));const historical=new Map(statistics.map(item=>[item.node_id,item]));return{version:plan.version,nodes:plan.nodes.map(node=>{const id=String(node.node_id??node.id);const execution=executions.get(node.node_id);return{id,type:"dag_node",position:{x:0,y:0},data:{render_node:node,duration_ns:execution?.duration_ns,worker_id:execution?.worker_id,critical:analysis.get(node.node_id)?.on_critical_path,selected:node.node_id===selected_node_id,historical:historical.get(node.node_id)}};}),edges:plan.edges.map((edge,index)=>({id:`${edge.from}-${edge.to}-${index}`,source:String(edge.from),target:String(edge.to),animated:Boolean(frame)}))};} diff --git a/webapp_gallery/src/dag/dag_node.tsx b/webapp_gallery/src/dag/dag_node.tsx deleted file mode 100644 index f4c2e3e..0000000 --- a/webapp_gallery/src/dag/dag_node.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import {Handle,Position,type NodeProps} from "@xyflow/react";import {Box,Typography} from "@mui/material";import type {Node} from "@xyflow/react";import type {Dag_Node_Data} from "./dag_types";import {format_nanoseconds} from "../protocol/format"; -export function Dag_Node({data}:NodeProps>) {const node=data.render_node;return {node.kind}{node.name||node.label}{node.owner}{data.duration_ns===undefined?"未执行":`${format_nanoseconds(data.duration_ns)} · worker ${data.worker_id}`};} diff --git a/webapp_gallery/src/dag/dag_types.ts b/webapp_gallery/src/dag/dag_types.ts deleted file mode 100644 index 52d96ca..0000000 --- a/webapp_gallery/src/dag/dag_types.ts +++ /dev/null @@ -1,3 +0,0 @@ -import type {Edge,Node} from "@xyflow/react";import type {Gallery_Render_Node} from "../protocol/gallery_types"; -export interface Dag_Node_Data extends Record {render_node: Gallery_Render_Node; duration_ns?: number; worker_id?: number; critical?: boolean; selected?: boolean;} -export interface Dag_View_Model {nodes: Node[];edges: Edge[];version:number;} diff --git a/webapp_gallery/src/dag/render_dag.tsx b/webapp_gallery/src/dag/render_dag.tsx deleted file mode 100644 index 975d44a..0000000 --- a/webapp_gallery/src/dag/render_dag.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import MapOutlinedIcon from "@mui/icons-material/MapOutlined"; -import RefreshIcon from "@mui/icons-material/Refresh"; -import {Alert,Box,Button,CircularProgress} from "@mui/material"; -import {Background,ControlButton,Controls,MiniMap,ReactFlow} from "@xyflow/react"; -import {useEffect,useMemo,useState} from "react"; -import type {Gallery_Captured_Frame,Gallery_Node_Statistics,Gallery_Render_Plan} from "../protocol/gallery_types"; -import {layout_dag} from "./dag_layout"; -import {build_dag_model} from "./dag_model"; -import {Dag_Node} from "./dag_node"; -import type {Dag_View_Model} from "./dag_types"; -const node_types={dag_node:Dag_Node}; -export function Render_Dag({plan,frame=null,statistics=[],selected_node_id,on_select_node}:{plan:Gallery_Render_Plan;frame?:Gallery_Captured_Frame|null;statistics?:Gallery_Node_Statistics[];selected_node_id:number|null;on_select_node:(id:number)=>void;}) { - const source=useMemo(()=>build_dag_model(plan,frame,statistics,selected_node_id),[plan,frame,statistics,selected_node_id]); - const layout_identity=String(plan.version); - const [layout,set_layout]=useState<{identity:string;model:Dag_View_Model}|null>(null); - const [layout_error,set_layout_error]=useState(""); - const [layout_retry,set_layout_retry]=useState(0); - const [minimap_visible,set_minimap_visible]=useState(true); - useEffect(()=>{let current=true;set_layout_error("");void layout_dag(source).then(value=>{if(current)set_layout({identity:layout_identity,model:value});}).catch(error=>{if(current)set_layout_error(error instanceof Error?error.message:"DAG 自动布局失败");});return()=>{current=false;};},[layout_identity,layout_retry]); - const laid_out=layout?.identity===layout_identity?layout.model:null; - const model=useMemo(()=>{if(!laid_out)return null;const positions=new Map(laid_out.nodes.map(node=>[node.id,node.position]));return {...source,nodes:source.nodes.map(node=>({...node,position:positions.get(node.id)??node.position}))};},[source,laid_out]); - return {layout_error?} onClick={()=>set_layout_retry(value=>value+1)}>重试布局}>{layout_error}:!model?:on_select_node(Number(node.id))}>{minimap_visible&&}set_minimap_visible(value=>!value)}>}; -} diff --git a/webapp_gallery/src/gallery/category_filter.tsx b/webapp_gallery/src/gallery/category_filter.tsx deleted file mode 100644 index e00c0ff..0000000 --- a/webapp_gallery/src/gallery/category_filter.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import {Chip,Stack} from "@mui/material"; -export function Category_Filter({categories,active_category,on_change}:{categories:string[];active_category:string;on_change:(category:string)=>void}) {return {categories.map(category=>on_change(category)}/>)};} diff --git a/webapp_gallery/src/gallery/frame_mode_tabs.tsx b/webapp_gallery/src/gallery/frame_mode_tabs.tsx deleted file mode 100644 index 0fd0908..0000000 --- a/webapp_gallery/src/gallery/frame_mode_tabs.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import {Tab,Tabs} from "@mui/material";import type {Gallery_Frame_Mode} from "../protocol/gallery_types"; -export function Frame_Mode_Tabs({frame_modes,active_mode,on_change}:{frame_modes:Gallery_Frame_Mode[];active_mode:string;on_change:(id:string)=>void}) {return on_change(value)} variant="scrollable" scrollButtons="auto">{frame_modes.map(mode=>)};} diff --git a/webapp_gallery/src/gallery/gallery_page.tsx b/webapp_gallery/src/gallery/gallery_page.tsx deleted file mode 100644 index a74892e..0000000 --- a/webapp_gallery/src/gallery/gallery_page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import {Box} from "@mui/material"; -import {useCallback,useEffect,useRef} from "react"; -import type {Gallery_Case,Gallery_Dashboard,Gallery_Frame_Mode} from "../protocol/gallery_types"; -import type {Gallery_Plot_Session} from "../session/gallery_plot_session"; -import {Plot_Card,type Selected_Plot} from "./plot_card"; - -export function Gallery_Page({cases,frame_mode,frame_modes,dashboard,streams_paused,on_open_inspector}:{cases:Gallery_Case[];frame_mode:Gallery_Frame_Mode;frame_modes:Gallery_Frame_Mode[];dashboard:Gallery_Dashboard;streams_paused:boolean;on_open_inspector:(plot:Selected_Plot)=>void}) { - const sessions=useRef(new Set()); - const register=useCallback((session:Gallery_Plot_Session,mount:boolean)=>{mount?sessions.current.add(session):sessions.current.delete(session);},[]); - useEffect(()=>{let animation_frame=0;const loop=(now:number)=>{for(const session of sessions.current)session.tick(now);animation_frame=requestAnimationFrame(loop);};animation_frame=requestAnimationFrame(loop);return()=>cancelAnimationFrame(animation_frame);},[]); - return - {cases.map(gallery_case=>)} - ; -} diff --git a/webapp_gallery/src/gallery/gallery_summary.tsx b/webapp_gallery/src/gallery/gallery_summary.tsx deleted file mode 100644 index e00cd58..0000000 --- a/webapp_gallery/src/gallery/gallery_summary.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import {Box,Paper,Typography} from "@mui/material"; -import type {Gallery_Catalog} from "../protocol/gallery_types"; -import {Metric_Grid} from "../common/metric_grid"; - -export function Gallery_Summary({catalog}:{catalog:Gallery_Catalog}) { - const coverage=catalog.coverage; - return - - {catalog.navigation.hero_eyebrow} - {catalog.navigation.hero_title} - 所有属性、动作、观察者和性能数据均由后端通过 WebSocket 返回。 - - - ; -} diff --git a/webapp_gallery/src/gallery/gallery_toolbar.tsx b/webapp_gallery/src/gallery/gallery_toolbar.tsx deleted file mode 100644 index 85c748e..0000000 --- a/webapp_gallery/src/gallery/gallery_toolbar.tsx +++ /dev/null @@ -1,4 +0,0 @@ -import PauseIcon from "@mui/icons-material/Pause";import PlayArrowIcon from "@mui/icons-material/PlayArrow"; -import {AppBar,Box,Button,Toolbar,Typography} from "@mui/material"; -import {Status_Chip} from "../common/status_chip"; -export function Gallery_Toolbar({socket_state,socket_message,streams_paused,on_toggle_streams}:{socket_state:string;socket_message:string;streams_paused:boolean;on_toggle_streams:()=>void}) {return R2CORE2 · KERNEL · WEBSOCKETRenderive 性能画廊;} diff --git a/webapp_gallery/src/gallery/plot_card.tsx b/webapp_gallery/src/gallery/plot_card.tsx deleted file mode 100644 index 98158a7..0000000 --- a/webapp_gallery/src/gallery/plot_card.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import MoreHorizIcon from "@mui/icons-material/MoreHoriz"; -import RefreshIcon from "@mui/icons-material/Refresh"; -import {Box,Button,Card,CardActions,CardContent,CardHeader,Chip,Stack,Typography} from "@mui/material"; -import {useEffect} from "react"; -import type {Gallery_Case,Gallery_Dashboard,Gallery_Frame_Mode} from "../protocol/gallery_types"; -import {use_plot_session} from "../hooks/use_plot_session"; -import type {Gallery_Plot_Session} from "../session/gallery_plot_session"; -import {Plot_Canvas} from "../plot/plot_canvas"; -import {Plot_Status} from "../plot/plot_status"; -import {Performance_Strip} from "../plot/performance_strip"; -import {Kernel_Observer_Summary} from "../plot/kernel_observer_summary"; - -export interface Selected_Plot {session:Gallery_Plot_Session;} -export function Plot_Card({gallery_case,frame_mode,frame_modes,dashboard,active,streams_paused,on_register,on_open_inspector}:{gallery_case:Gallery_Case;frame_mode:Gallery_Frame_Mode;frame_modes:Gallery_Frame_Mode[];dashboard:Gallery_Dashboard;active:boolean;streams_paused:boolean;on_register:(session:Gallery_Plot_Session,mount:boolean)=>void;on_open_inspector:(plot:Selected_Plot)=>void}) { - const [session,snapshot]=use_plot_session(gallery_case,frame_mode,frame_modes); - useEffect(()=>{on_register(session,true);return()=>on_register(session,false);},[session,on_register]); - useEffect(()=>session.set_activity(active,streams_paused),[session,active,streams_paused]); - return {event.preventDefault();if(snapshot.ready)on_open_inspector({session});}}> - } action={}/> - {frame_mode.observer_visible&&}{gallery_case.description}{snapshot.notice&&{snapshot.notice}} - {snapshot.controls.length} 属性{snapshot.actions.length} 动作{snapshot.frame_count} 像素帧{session.frame_mode.id==="manual"&&} - ; -} diff --git a/webapp_gallery/src/hooks/use_element_size.ts b/webapp_gallery/src/hooks/use_element_size.ts deleted file mode 100644 index 56e9ef8..0000000 --- a/webapp_gallery/src/hooks/use_element_size.ts +++ /dev/null @@ -1,3 +0,0 @@ -import {useEffect, useState, type RefObject} from "react"; -export interface Element_Size {width:number;height:number;} -export function use_element_size(ref: RefObject): Element_Size {const [size,set_size]=useState({width:0,height:0});useEffect(()=>{const element=ref.current;if(!element)return;const observer=new ResizeObserver(entries=>{const rect=entries[0]?.contentRect;if(rect)set_size({width:rect.width,height:rect.height});});observer.observe(element);return()=>observer.disconnect();},[ref]);return size;} diff --git a/webapp_gallery/src/hooks/use_gallery_catalog.ts b/webapp_gallery/src/hooks/use_gallery_catalog.ts deleted file mode 100644 index ca4508d..0000000 --- a/webapp_gallery/src/hooks/use_gallery_catalog.ts +++ /dev/null @@ -1,4 +0,0 @@ -import {useEffect, useState} from "react"; -import type {Gallery_Catalog} from "../protocol/gallery_types"; -import {load_gallery_catalog} from "../transport/catalog_loader"; -export function use_gallery_catalog(): {catalog: Gallery_Catalog|null; state: string; message: string} {const [catalog,set_catalog]=useState(null);const [status,set_status]=useState({state:"connecting",message:"读取后端目录"});useEffect(()=>load_gallery_catalog(set_catalog,(state,message)=>set_status({state,message})),[]);return {catalog,...status};} diff --git a/webapp_gallery/src/hooks/use_plot_session.ts b/webapp_gallery/src/hooks/use_plot_session.ts deleted file mode 100644 index eaf7748..0000000 --- a/webapp_gallery/src/hooks/use_plot_session.ts +++ /dev/null @@ -1,9 +0,0 @@ -import {useEffect, useMemo, useSyncExternalStore} from "react"; -import type {Gallery_Case, Gallery_Frame_Mode} from "../protocol/gallery_types"; -import {Gallery_Plot_Session} from "../session/gallery_plot_session"; -export function use_plot_session(gallery_case: Gallery_Case, frame_mode: Gallery_Frame_Mode, frame_modes: Gallery_Frame_Mode[]): [Gallery_Plot_Session, ReturnType] { - const session=useMemo(()=>new Gallery_Plot_Session(gallery_case,frame_mode,frame_modes),[gallery_case,frame_mode,frame_modes]); - const snapshot=useSyncExternalStore(session.subscribe,session.get_snapshot,session.get_snapshot); - useEffect(()=>()=>session.dispose(),[session]); - return [session,snapshot]; -} diff --git a/webapp_gallery/src/inspector/actions_panel.tsx b/webapp_gallery/src/inspector/actions_panel.tsx deleted file mode 100644 index 68405e9..0000000 --- a/webapp_gallery/src/inspector/actions_panel.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import {Button,Paper,Stack,TextField,Typography} from "@mui/material";import {useState} from "react";import type {Gallery_Action,Json_Primitive} from "../protocol/gallery_types"; -function Action_Row({action,on_action}:{action:Gallery_Action;on_action:(id:string,argument?:Json_Primitive)=>void}) {const [argument,set_argument]=useState(String(action.argument_default??""));return {action.label}{action.api}{action.argument_input&&set_argument(event.target.value)} sx={{width:130}}/>};} -export function Actions_Panel({actions,on_action}:{actions:Gallery_Action[];on_action:(id:string,argument?:Json_Primitive)=>void}) {return {actions.map(action=>)};} diff --git a/webapp_gallery/src/inspector/control_field.tsx b/webapp_gallery/src/inspector/control_field.tsx deleted file mode 100644 index 6700364..0000000 --- a/webapp_gallery/src/inspector/control_field.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import {FormControlLabel,MenuItem,Switch,TextField} from "@mui/material";import {useEffect,useState} from "react";import type {Gallery_Control,Json_Value} from "../protocol/gallery_types"; -export function Control_Field({control,on_commit}:{control:Gallery_Control;on_commit:(value:Json_Value)=>void}) {const [draft,set_draft]=useState(String(control.value??""));const [editing,set_editing]=useState(false);useEffect(()=>{if(!editing)set_draft(String(control.value??""));},[control.value,editing]);if(control.input==="boolean")return on_commit(checked)}/>} label={control.label}/>;if(control.input==="select")return set_editing(true)} onBlur={()=>set_editing(false)} onChange={event=>{set_draft(event.target.value);on_commit(event.target.value);}}>{control.options.map(option=>{option.label})};const commit=()=>{set_editing(false);if(control.input!=="number"){if(draft!==String(control.value??""))on_commit(draft);return;}const number=Number(draft);if(!Number.isFinite(number)||(control.minimum!==undefined&&numbercontrol.maximum)){set_draft(String(control.value??""));return;}if(number!==Number(control.value))on_commit(number);};return set_editing(true)} onChange={event=>set_draft(event.target.value)} onBlur={commit} onKeyDown={event=>{if(event.key==="Enter")commit();else if(event.key==="Escape"){set_draft(String(control.value??""));set_editing(false);}}}/>;} diff --git a/webapp_gallery/src/inspector/controls_panel.tsx b/webapp_gallery/src/inspector/controls_panel.tsx deleted file mode 100644 index 8cfcbc9..0000000 --- a/webapp_gallery/src/inspector/controls_panel.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import {Accordion,AccordionDetails,AccordionSummary,Stack,Typography} from "@mui/material";import ExpandMoreIcon from "@mui/icons-material/ExpandMore";import type {Gallery_Control,Json_Value} from "../protocol/gallery_types";import {Control_Field} from "./control_field";import {nested_patch} from "../protocol/gallery_descriptor"; -export function Controls_Panel({controls,on_patch}:{controls:Gallery_Control[];on_patch:(target:string,patch:Record)=>void}) {const groups=new Map();for(const control of controls){const group=control.group||"其他";groups.set(group,[...(groups.get(group)??[]),control]);}return {[...groups].map(([group,items],index)=>}>{group} · {items.length} 项{items.map(control=>on_patch(control.target,nested_patch(control.path,value))}/>)})};} diff --git a/webapp_gallery/src/inspector/inspector_panel.tsx b/webapp_gallery/src/inspector/inspector_panel.tsx deleted file mode 100644 index 4f8b296..0000000 --- a/webapp_gallery/src/inspector/inspector_panel.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import CloseIcon from "@mui/icons-material/Close"; -import RefreshIcon from "@mui/icons-material/Refresh"; -import RestartAltIcon from "@mui/icons-material/RestartAlt"; -import {Box, IconButton, Paper, Stack, Tooltip, Typography} from "@mui/material"; -import {useEffect, useState, useSyncExternalStore} from "react"; -import {Capture_Panel} from "../capture/capture_panel"; -import {Empty_State} from "../common/empty_state"; -import {Render_Dag} from "../dag/render_dag"; -import type {Gallery_Dashboard} from "../protocol/gallery_types"; -import type {Gallery_Plot_Session} from "../session/gallery_plot_session"; -import {Actions_Panel} from "./actions_panel"; -import {Controls_Panel} from "./controls_panel"; -import {Inspector_Tabs} from "./inspector_tabs"; -import {Observer_Panel} from "./observer_panel"; -import {Performance_Panel} from "./performance_panel"; - -export function Inspector_Panel({ - session, - dashboard, - active_tab, - on_change_tab, - on_close, -}: { - session: Gallery_Plot_Session; - dashboard: Gallery_Dashboard; - active_tab: string; - on_change_tab: (tab: string) => void; - on_close: () => void; -}) { - const snapshot = useSyncExternalStore( - session.subscribe, - session.get_snapshot, - session.get_snapshot, - ); - const [selected_node_id, set_selected_node_id] = useState(null); - - useEffect(() => set_selected_node_id(null), [session]); - - let body = null; - if (active_tab === "controls") - body = session.patch(target, patch)}/>; - else if (active_tab === "actions") - body = { - session.action(action, argument); - setTimeout(() => session.request_frame(performance.now(), true), 40); - }}/>; - else if (active_tab === "observer") - body = ; - else if (active_tab === "performance") - body = ; - else if (active_tab === "performance_capture") - body = snapshot.performance_capture - ? { - session.action(action, count); - setTimeout(() => session.request_frame(performance.now(), true), 40); - }}/> - : ; - else if (active_tab === "render_plan") - body = snapshot.render_plan - ? - : ; - - return - - - - {`${session.frame_mode.strategy} / ${session.gallery_case.component}`} - - {session.gallery_case.title} - - - session.reset_monitoring()}> - - - session.refresh()}> - - - - - - - {body} - ; -} diff --git a/webapp_gallery/src/inspector/inspector_tabs.tsx b/webapp_gallery/src/inspector/inspector_tabs.tsx deleted file mode 100644 index 377fc9b..0000000 --- a/webapp_gallery/src/inspector/inspector_tabs.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import {Tab,Tabs} from "@mui/material"; -export const INSPECTOR_TABS=[{id:"controls",label:"控件属性"},{id:"actions",label:"专属 API"},{id:"observer",label:"内核观察器"},{id:"performance",label:"性能监测"},{id:"performance_capture",label:"Performance Capture"},{id:"render_plan",label:"Render DAG"}] as const; -export function Inspector_Tabs({active_tab,on_change}:{active_tab:string;on_change:(tab:string)=>void}) {return on_change(value)} variant="scrollable" scrollButtons="auto">{INSPECTOR_TABS.map(tab=>)};} diff --git a/webapp_gallery/src/inspector/observer_panel.tsx b/webapp_gallery/src/inspector/observer_panel.tsx deleted file mode 100644 index 379402e..0000000 --- a/webapp_gallery/src/inspector/observer_panel.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; -import {Accordion,AccordionDetails,AccordionSummary,Paper,Stack,Typography} from "@mui/material"; -import {Dashboard_Field_Grid} from "../common/dashboard_field_grid"; -import {Json_Viewer} from "../common/json_viewer"; -import type {Gallery_Dashboard,Gallery_Telemetry} from "../protocol/gallery_types"; -export function Observer_Panel({telemetry,dashboard}:{telemetry:Gallery_Telemetry;dashboard:Gallery_Dashboard}) { - const header=dashboard.observer.header; - return - {`${header.prefix}与 Renderable ${header.suffix}`} - - {dashboard.observer.sections.map(section=>{section.aria_label??section.class_name})} - {telemetry.renderable_observers?.map(resource=>}>{resource.title??resource.target})} - }>原始观察数据 - ; -} diff --git a/webapp_gallery/src/inspector/performance_panel.tsx b/webapp_gallery/src/inspector/performance_panel.tsx deleted file mode 100644 index 2e3d044..0000000 --- a/webapp_gallery/src/inspector/performance_panel.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import type {EChartsOption} from "echarts"; -import {Accordion,AccordionDetails,AccordionSummary,Box,Chip,Paper,Stack,Typography} from "@mui/material"; -import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; -import {useEffect,useMemo,useState} from "react"; -import {Dashboard_Field_Grid} from "../common/dashboard_field_grid"; -import {ECharts_View} from "../common/echarts_view"; -import {Json_Viewer} from "../common/json_viewer"; -import {format_nanoseconds,value_at_path} from "../protocol/format"; -import type {Gallery_Dashboard,Gallery_Telemetry} from "../protocol/gallery_types"; -interface Trend_Sample {time:number;values:Record;} -export function Performance_Panel({telemetry,dashboard,history_key}:{telemetry:Gallery_Telemetry;dashboard:Gallery_Dashboard;history_key:unknown}) { - const [history,set_history]=useState([]); - const trend_fields=useMemo(()=>dashboard.performance.fields.filter(field=>field.trend_group&&field.source),[dashboard]); - useEffect(()=>set_history([]),[history_key]); - useEffect(()=>{const values:Record={};for(const field of trend_fields){const raw=value_at_path(telemetry,field.source);if(typeof raw==="number"&&Number.isFinite(raw))values[field.source!]=raw;}if(Object.keys(values).length)set_history(items=>[...items,{time:Date.now(),values}].slice(-180));},[telemetry,trend_fields]); - const groups=useMemo(()=>[...new Set(trend_fields.map(field=>field.trend_group!))],[trend_fields]); - const current_limit=String(value_at_path(telemetry,dashboard.limits.current_source)??""); - return - 性能监测 - - {groups.map(group=>{const fields=trend_fields.filter(field=>field.trend_group===group);const option:EChartsOption={backgroundColor:"transparent",animation:false,tooltip:{trigger:"axis"},legend:{type:"scroll"},grid:{left:58,right:24,top:42,bottom:34},xAxis:{type:"time"},yAxis:{type:"value",scale:true},series:fields.map(field=>{const data:Array<[number,number]>=[];for(const sample of history){const value=sample.values[field.source!];if(value!==undefined)data.push([sample.time,value]);}return {name:field.label,type:"line",showSymbol:false,data};})};return {group};})} - - {dashboard.limits.title} - {dashboard.limits.fields.map(field=>{const enabled=!field.enabled_source||Boolean(value_at_path(telemetry,field.enabled_source));const active=enabled&&String(field.active_value)===current_limit;const duration=field.duration_source?format_nanoseconds(value_at_path(telemetry,field.duration_source)):"";return ;})} - - }>原始性能数据 - ; -} diff --git a/webapp_gallery/src/main.tsx b/webapp_gallery/src/main.tsx deleted file mode 100644 index bad8984..0000000 --- a/webapp_gallery/src/main.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import {StrictMode} from "react"; -import {createRoot} from "react-dom/client"; -import {CssBaseline,ThemeProvider} from "@mui/material"; -import {gallery_theme} from "./theme"; -import {App} from "./app"; -import "@xyflow/react/dist/style.css"; -createRoot(document.getElementById("root")!).render(); diff --git a/webapp_gallery/src/plot/kernel_observer_summary.tsx b/webapp_gallery/src/plot/kernel_observer_summary.tsx deleted file mode 100644 index 13dd5b2..0000000 --- a/webapp_gallery/src/plot/kernel_observer_summary.tsx +++ /dev/null @@ -1,4 +0,0 @@ -import {Box,Typography} from "@mui/material"; -import {Dashboard_Field_Grid} from "../common/dashboard_field_grid"; -import type {Gallery_Dashboard,Gallery_Telemetry} from "../protocol/gallery_types"; -export function Kernel_Observer_Summary({telemetry,dashboard}:{telemetry:Gallery_Telemetry;dashboard:Gallery_Dashboard}) {const header=dashboard.observer.header;return Kernel Observer;} diff --git a/webapp_gallery/src/plot/performance_strip.tsx b/webapp_gallery/src/plot/performance_strip.tsx deleted file mode 100644 index af91cd9..0000000 --- a/webapp_gallery/src/plot/performance_strip.tsx +++ /dev/null @@ -1,4 +0,0 @@ -import {Box,Typography} from "@mui/material"; -import {format_dashboard_field} from "../protocol/format"; -import type {Gallery_Dashboard,Gallery_Telemetry} from "../protocol/gallery_types"; -export function Performance_Strip({telemetry,dashboard}:{telemetry:Gallery_Telemetry;dashboard:Gallery_Dashboard}) {const fields=dashboard.performance.fields.filter(field=>field.summary);return {fields.map(field=>{field.label}{format_dashboard_field(field,telemetry,dashboard)})};} diff --git a/webapp_gallery/src/plot/plot_canvas.tsx b/webapp_gallery/src/plot/plot_canvas.tsx deleted file mode 100644 index 7546626..0000000 --- a/webapp_gallery/src/plot/plot_canvas.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import {Box} from "@mui/material"; -import {useEffect, useRef} from "react"; - -import {use_element_size} from "../hooks/use_element_size"; -import type {Gallery_Plot_Session} from "../session/gallery_plot_session"; - -function modifiers(event: { - shiftKey: boolean; - ctrlKey: boolean; - altKey: boolean; - metaKey: boolean; -}): number { - return (event.shiftKey ? 1 : 0) | - (event.ctrlKey ? 2 : 0) | - (event.altKey ? 4 : 0) | - (event.metaKey ? 8 : 0); -} - -function canvas_position( - canvas: HTMLCanvasElement, - event: {clientX: number; clientY: number}, -): {x: number; y: number} { - const rect = canvas.getBoundingClientRect(); - return { - x: (event.clientX - rect.left) * canvas.width / Math.max(1, rect.width), - y: (event.clientY - rect.top) * canvas.height / Math.max(1, rect.height), - }; -} - -function angle_delta(delta: number, delta_mode: number): number { - // Kernel wheel events use Qt-compatible eighth-degree units. Browser pixel wheels commonly - // report about 100 px per notch, while line/page devices report much smaller logical deltas. - const eighth_degrees_per_unit = delta_mode === 1 ? 40 : delta_mode === 2 ? 120 : 1.2; - return -delta * eighth_degrees_per_unit; -} - -export function Plot_Canvas({session}: {session: Gallery_Plot_Session}) { - const shell_ref = useRef(null); - const canvas_ref = useRef(null); - const size = use_element_size(shell_ref); - - useEffect(() => { - const canvas = canvas_ref.current; - if (!canvas) - return; - - const wheel = (event: WheelEvent) => { - event.preventDefault(); - session.wheel({ - ...canvas_position(canvas, event), - pixelDeltaX: event.deltaX, - pixelDeltaY: event.deltaY, - angleDeltaX: angle_delta(event.deltaX, event.deltaMode), - angleDeltaY: angle_delta(event.deltaY, event.deltaMode), - buttons: event.buttons, - modifiers: modifiers(event), - }); - }; - - session.attach_canvas(canvas); - canvas.addEventListener("wheel", wheel, {passive: false}); - return () => { - canvas.removeEventListener("wheel", wheel); - session.detach_canvas(); - }; - }, [session]); - - useEffect(() => { - if (size.width && size.height) - session.resize(size.width, size.height); - }, [session, size]); - - const pointer = ( - type: "pointer_move" | "pointer_press" | "pointer_release", - event: React.PointerEvent, - ) => { - const canvas = canvas_ref.current; - if (!canvas) - return; - session.pointer(type, { - ...canvas_position(canvas, event), - button: ["left", "middle", "right"][event.button] ?? "none", - buttons: event.buttons, - modifiers: modifiers(event), - }); - }; - - return session.key("key_press", { - key: event.key, - nativeKey: event.keyCode, - repeat: event.repeat, - modifiers: modifiers(event), - })} - onKeyUp={event => session.key("key_release", { - key: event.key, - nativeKey: event.keyCode, - repeat: false, - modifiers: modifiers(event), - })} - > - pointer("pointer_move", event)} - onPointerDown={event => { - if (event.button === 2) - return; - shell_ref.current?.focus(); - event.currentTarget.setPointerCapture(event.pointerId); - pointer("pointer_press", event); - }} - onPointerUp={event => { - if (event.button !== 2) - pointer("pointer_release", event); - }} - onPointerLeave={() => session.leave()} - /> - ; -} diff --git a/webapp_gallery/src/plot/plot_status.tsx b/webapp_gallery/src/plot/plot_status.tsx deleted file mode 100644 index 373e02b..0000000 --- a/webapp_gallery/src/plot/plot_status.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import {Stack} from "@mui/material";import {Status_Chip} from "../common/status_chip";import type {Gallery_Plot_Snapshot} from "../session/gallery_plot_snapshot"; -export function Plot_Status({snapshot,active}:{snapshot:Gallery_Plot_Snapshot;active:boolean}) {return ;} diff --git a/webapp_gallery/src/protocol/format.ts b/webapp_gallery/src/protocol/format.ts deleted file mode 100644 index aa7eb26..0000000 --- a/webapp_gallery/src/protocol/format.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type {Gallery_Dashboard_Field, Gallery_Dashboard} from "./gallery_types"; -export function format_nanoseconds(value: unknown): string {const ns = Math.max(0, Number(value) || 0); return ns < 1_000 ? `${Math.round(ns)} ns` : ns < 1_000_000 ? `${(ns / 1_000).toFixed(2)} µs` : `${(ns / 1_000_000).toFixed(3)} ms`;} -export function value_at_path(root: unknown, path = ""): unknown {return path.split(".").filter(Boolean).reduce((value, key) => value && typeof value === "object" ? (value as Record)[key] : undefined, root);} -function mapped_value(field: Gallery_Dashboard_Field, raw: unknown, dashboard: Gallery_Dashboard): string {return dashboard.value_maps?.[field.value_map ?? ""]?.[String(raw)] ?? String(raw ?? "—");} -function format_bytes(value: number): string {const size=Math.max(0,value);return size<1024?`${Math.round(size)} B`:size<1024*1024?`${(size/1024).toFixed(1)} KiB`:`${(size/1024/1024).toFixed(2)} MiB`;} -export function format_dashboard_field(field: Gallery_Dashboard_Field, telemetry: Record, dashboard: Gallery_Dashboard): string { - if (field.enabled_source && !Boolean(value_at_path(telemetry, field.enabled_source))) return field.disabled_label ?? "已关闭"; - if (field.format === "pair") return (field.sources ?? []).map(source => String(value_at_path(telemetry, source) ?? "—")).join(field.separator ?? " / "); - const raw = value_at_path(telemetry, field.source) ?? field.default ?? ""; - const number = Number(raw) || 0, digits = field.digits ?? 0; - if (field.format === "duration_enum") {const label=mapped_value(field,raw,dashboard);const source=field.duration_sources?.[String(raw)];return source?`${label} · ${format_nanoseconds(value_at_path(telemetry,source))}`:label;} - if (field.format === "fixed") return number.toFixed(digits); - if (field.format === "integer") return number.toLocaleString(); - if (field.format === "milliseconds") return `${number.toFixed(digits)} ms`; - if (field.format === "fps") return `${number.toFixed(digits)} FPS`; - if (field.format === "frequency") return `${number.toFixed(digits >= 0 ? digits : 2)} Hz`; - if (field.format === "bytes") return format_bytes(number); - if (field.format === "nanoseconds") return format_nanoseconds(number); - if (field.format === "enum" || field.format === "flags") return mapped_value(field,raw,dashboard); - return String(raw ?? "—"); -} diff --git a/webapp_gallery/src/protocol/gallery_descriptor.ts b/webapp_gallery/src/protocol/gallery_descriptor.ts deleted file mode 100644 index d68e112..0000000 --- a/webapp_gallery/src/protocol/gallery_descriptor.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type {Gallery_Control, Gallery_Descriptor_Field, Gallery_Resource, Json_Value} from "./gallery_types"; - -export function field_visible(expression: string | undefined, data: Record | undefined): boolean { - if (!expression) return true; - const equality = expression.match(/^\$\{\$self\.([A-Za-z_][A-Za-z0-9_]*) == '([^']*)'}$/); - return equality ? String(data?.[equality[1]]) === equality[2] : true; -} -export function build_controls(resources: Gallery_Resource[] = []): Gallery_Control[] { - const controls: Gallery_Control[] = []; - const visit = (fields: Gallery_Descriptor_Field[], data: Record, target: string, group: string, path: string[] = [], labels: string[] = []) => { - for (const field of fields ?? []) { - const presentation = field.presentation ?? {}; - if (!field_visible(presentation.visible_on, data)) continue; - const field_path = [...path, field.name]; - const field_labels = [...labels, presentation.label ?? field.name]; - const value = data?.[field.name]; - if (field.children?.length) { - visit(field.children, value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}, target, group, field_path, field_labels); - } else if (field.editable) { - controls.push({id: field_path.join("."), target, path: field_path, label: field_labels.join(" / "), api: field_path.join("."), description: presentation.description ?? "", group, input: presentation.control === "automatic" ? "text" : presentation.control ?? "text", minimum: field.minimum, maximum: field.maximum, step: field.multiple_of, options: presentation.options ?? [], value}); - } - } - }; - for (const resource of resources) visit(resource.descriptor?.fields ?? [], resource.data ?? {}, resource.target, resource.view?.title ?? resource.title ?? resource.descriptor?.label ?? "控件属性"); - return controls; -} -export function nested_patch(path: string[], value: Json_Value): Record { - let result: Json_Value = value; - for (const key of [...path].reverse()) result = {[key]: result}; - return result as Record; -} diff --git a/webapp_gallery/src/protocol/gallery_messages.ts b/webapp_gallery/src/protocol/gallery_messages.ts deleted file mode 100644 index 2b59483..0000000 --- a/webapp_gallery/src/protocol/gallery_messages.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type {Gallery_Client_Performance, Json_Primitive, Json_Value} from "./gallery_types"; - -export function gallery_message(type: string, payload: Record = {}): string { - return JSON.stringify({category: "event", type, ...payload}); -} -export function gallery_open_message(case_id: string, frame_mode: string): string {return gallery_message("gallery_open", {case: case_id, frame_mode});} -export function gallery_patch_message(target: string, patch: Record): string {return gallery_message("gallery_patch", {target, patch});} -export function gallery_action_message(action: string, argument?: Json_Primitive): string {return gallery_message("gallery_action", argument === undefined ? {action} : {action, argument});} -export function gallery_metrics_payload(client_performance: Gallery_Client_Performance): Record {return {client_metrics: client_performance as unknown as Record};} diff --git a/webapp_gallery/src/protocol/gallery_parser.ts b/webapp_gallery/src/protocol/gallery_parser.ts deleted file mode 100644 index a3926c8..0000000 --- a/webapp_gallery/src/protocol/gallery_parser.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type {Gallery_Response} from "./gallery_types"; - -const KNOWN_RESPONSE_TYPES = new Set(["catalog", "case_state", "refresh_state", "observer_state", "error"]); -export class Gallery_Protocol_Error extends Error {} - -export function parse_gallery_response(raw: string): Gallery_Response | null { - let value: unknown; - try { value = JSON.parse(raw); } catch { throw new Gallery_Protocol_Error("后端返回了无效 JSON"); } - if (!value || typeof value !== "object" || Array.isArray(value)) throw new Gallery_Protocol_Error("后端响应必须是对象"); - const type = (value as {type?: unknown}).type; - if (typeof type !== "string") throw new Gallery_Protocol_Error("后端响应缺少 type"); - if (!KNOWN_RESPONSE_TYPES.has(type)) return null; - return value as Gallery_Response; -} diff --git a/webapp_gallery/src/protocol/gallery_types.ts b/webapp_gallery/src/protocol/gallery_types.ts deleted file mode 100644 index 705861c..0000000 --- a/webapp_gallery/src/protocol/gallery_types.ts +++ /dev/null @@ -1,51 +0,0 @@ -export type Json_Primitive = string | number | boolean | null; -export type Json_Value = Json_Primitive | Json_Value[] | {[key: string]: Json_Value}; -export type Connection_State = "connecting" | "ready" | "error" | "closed"; - -export interface Gallery_Option {label: string; value: Json_Primitive;} -export interface Gallery_Descriptor_Field { - name: string; editable?: boolean; minimum?: number; maximum?: number; multiple_of?: number; - children?: Gallery_Descriptor_Field[]; - presentation?: {label?: string; description?: string; control?: string; visible_on?: string; options?: Gallery_Option[]}; -} -export interface Gallery_Descriptor {label?: string; fields: Gallery_Descriptor_Field[];} -export interface Gallery_Resource {target: string; title?: string; descriptor: Gallery_Descriptor; data: Record; view?: {title?: string};} -export interface Gallery_Control {id: string; target: string; path: string[]; label: string; api: string; description: string; group: string; input: string; minimum?: number; maximum?: number; step?: number; options: Gallery_Option[]; value: Json_Value | undefined;} -export interface Gallery_Action {id: string; label: string; api: string; group?: string; argument_input?: string; argument_default?: Json_Primitive; request_frame?: boolean;} - -export interface Gallery_Navigation {default_mode: string; all_categories_label: string; hero_eyebrow: string; hero_title: string; catalog_loaded_text: string;} -export interface Gallery_Coverage {case_count: number; page_count: number; canvas_count: number; manual_control_count: number; manual_action_count: number;} -export interface Gallery_Frame_Mode {id: string; title: string; strategy: string; description: string; order: number; accent: string; observer_visible: boolean; frame_button_label: string;} -export interface Gallery_Case {id: string; title: string; description: string; component: string; category: string; order: number; control_count_by_mode?: Record; action_count_by_mode?: Record;} -export interface Gallery_Dashboard_Field {label: string; source?: string; sources?: string[]; enabled_source?: string; duration_sources?: Record; duration_source?: string; active_value?: Json_Primitive; disabled_label?: string; default?: Json_Primitive; digits?: number; format?: string; separator?: string; joiner?: string; value_map?: string; cell_class?: string; trend_group?: string; summary?: boolean;} -export interface Gallery_Dashboard_Observer_Section {class_name: string; aria_label?: string; fields: Gallery_Dashboard_Field[];} -export interface Gallery_Dashboard_Observer {aria_label: string; header: {prefix: string; suffix: string; event_label: string; mode: Gallery_Dashboard_Field; limit: Gallery_Dashboard_Field; event: Gallery_Dashboard_Field}; sections: Gallery_Dashboard_Observer_Section[]; descriptor?: Json_Value; consumer_feedback_descriptor?: Json_Value;} -export interface Gallery_Dashboard {performance: {fields: Gallery_Dashboard_Field[]}; limits: {title: string; aria_label: string; current_source: string; active_label: string; inactive_label: string; disabled_label: string; fields: Gallery_Dashboard_Field[]}; observer: Gallery_Dashboard_Observer; menu_views: Record; value_maps?: Record>;} -export interface Gallery_Catalog {type: "catalog"; navigation: Gallery_Navigation; dashboard: Gallery_Dashboard; frame_modes: Gallery_Frame_Mode[]; cases: Gallery_Case[]; coverage: Gallery_Coverage; descriptor?: Gallery_Descriptor; transport?: {socket_per_canvas?: boolean};} - -export interface Gallery_Client_Performance {transport_fps: number; presentation_fps: number; display_interval_latest_ms: number; display_interval_ms: number; display_interval_average_ms: number; display_interval_p95_ms: number; display_interval_p99_ms: number; display_interval_deviation_ms: number; frame_round_trip_ms: number; frame_round_trip_average_ms: number; frame_round_trip_p95_ms: number; frame_round_trip_p99_ms: number; frame_round_trip_deviation_ms: number; changed_pixel_frames: number; duplicate_pixel_frames: number; overwritten_pixel_frames: number; frame_request_timeout_count: number; last_pixel_receive_age_ms: number; last_pixel_change_age_ms: number; websocket_buffered_bytes: number;} -export interface Gallery_Performance extends Record {} -export interface Gallery_Kernel_Observer extends Record {} -export interface Gallery_Scheduler_Statistics {concurrency: number; active_workers: number; peak_workers: number; active_external_threads: number; peak_external_threads: number; worker_entry_count: number; worker_exit_count: number; external_entry_count: number; external_exit_count: number;} -export interface Gallery_Renderable_Observer extends Gallery_Resource {} -export interface Gallery_Telemetry {[key: string]: unknown; kernel_observer?: Gallery_Kernel_Observer; scheduler?: Gallery_Scheduler_Statistics; queue_pressure?: Record; renderable_observers?: Gallery_Renderable_Observer[]; performance_capture?: Gallery_Performance_Capture;} - -export interface Gallery_Render_Node {node_id: number; id?: string | number; name: string; label?: string; owner: string; kind: string;} -export interface Gallery_Render_Edge {from: number | string; to: number | string;} -export interface Gallery_Renderable_Cache {owner_id: number; name: string; prepare_cache: string; paint_cache: string;} -export interface Gallery_Render_Plan {version: number; nodes: Gallery_Render_Node[]; edges: Gallery_Render_Edge[]; renderables: Gallery_Renderable_Cache[];} -export interface Gallery_Node_Diagnostic_Attachment {type: string; content: string;} -export interface Gallery_Node_Execution {node_id: number; worker_id: number; start_offset_ns: number; cpu_end_offset_ns: number; external_start_offset_ns: number; external_end_offset_ns: number; end_offset_ns: number; duration_ns: number; cpu_duration_ns: number; external_duration_ns: number; status: "pending"|"ready"|"running"|"waiting_external"|"complete"|"failed"|"cancelled"; metrics?: Record; attachments: Gallery_Node_Diagnostic_Attachment[];} -export interface Gallery_Node_Analysis {node_id: number; duration_ns: number; cpu_duration_ns: number; render_domain_cpu_duration_ns: number; total_cpu_work_duration_ns: number; external_duration_ns: number; render_domain_queue_wait_ns: number; gpu_completion_wait_ns: number; gpu_execution_duration_ns: number; start_offset_ns: number; end_offset_ns: number; dependency_ready_offset_ns: number; scheduler_wait_ns: number; work_contribution: number; critical_path_contribution: number; on_critical_path: boolean;} -export interface Gallery_Worker_Analysis {worker_id: number; work_duration_ns: number; utilization: number;} -export interface Gallery_Frame_Analysis {total_render_duration_ns: number; critical_path_duration_ns: number; total_work_duration_ns: number; scheduler_work_duration_ns: number; render_domain_work_duration_ns: number; total_external_duration_ns: number; total_render_domain_queue_wait_ns: number; total_gpu_completion_wait_ns: number; total_gpu_execution_duration_ns: number; scheduler_parallel_overlap_ns: number; scheduler_peak_parallelism: number; average_cpu_concurrency: number; scheduler_average_parallelism: number; render_domain_utilization: number; critical_path: number[]; bottleneck_nodes: number[]; nodes: Gallery_Node_Analysis[]; workers: Gallery_Worker_Analysis[];} -export interface Gallery_Captured_Frame {frame_id: number; render_plan_version: number; render_duration_ns: number; node_executions: Gallery_Node_Execution[]; analysis?: Gallery_Frame_Analysis;} -export interface Gallery_Node_Statistics {node_id: number; execution_count: number; average_ns: number; moving_average_ns: number; p50_ns: number; p95_ns: number; p99_ns: number; minimum_ns: number; maximum_ns: number; average_cpu_ns: number; average_render_domain_cpu_ns: number; average_total_cpu_work_ns: number; average_external_ns: number; average_render_domain_queue_wait_ns: number; average_gpu_completion_wait_ns: number; average_gpu_execution_ns: number; p95_cpu_ns: number; p95_render_domain_cpu_ns: number; p95_external_ns: number; p95_gpu_execution_ns: number; critical_path_frequency: number; average_scheduler_wait_ns: number;} -export interface Gallery_Plan_Statistics {render_plan_version: number; frame_count: number; render_average_ns: number; render_p50_ns: number; render_p95_ns: number; render_maximum_ns: number; average_cpu_concurrency: number; scheduler_average_parallelism: number; render_domain_utilization: number; scheduler_peak_parallelism: number; scheduler_wait_average_ns: number; scheduler_wait_p95_ns: number; nodes: Gallery_Node_Statistics[];} -export interface Gallery_Capture_Session {session_id: number; active: boolean; requested_count: number; captured_count: number; frames: Gallery_Captured_Frame[]; node_statistics: Gallery_Node_Statistics[]; plan_statistics: Gallery_Plan_Statistics[]; summary?: Record;} -export interface Gallery_Performance_Capture {controller: {enabled?: boolean; session_id?: number}; sessions: Gallery_Capture_Session[]; plans: Gallery_Render_Plan[];} - -export interface Gallery_Error_Response {type: "error"; message: string; field_errors?: Record;} -export interface Gallery_Case_State {type: "case_state" | "refresh_state"; case?: string; frame_mode?: string; controls?: {resources?: Gallery_Resource[]; observers?: Gallery_Renderable_Observer[]; render_plan?: Gallery_Render_Plan; performance_capture?: Gallery_Performance_Capture}; actions?: {data?: Gallery_Action[]}; telemetry?: Gallery_Telemetry; notice?: string;} -export interface Gallery_Observer_State {type: "observer_state"; frame_mode?: string; telemetry: Gallery_Telemetry;} -export type Gallery_Response = Gallery_Catalog | Gallery_Error_Response | Gallery_Case_State | Gallery_Observer_State; diff --git a/webapp_gallery/src/protocol/pixel_frame.ts b/webapp_gallery/src/protocol/pixel_frame.ts deleted file mode 100644 index f0712b3..0000000 --- a/webapp_gallery/src/protocol/pixel_frame.ts +++ /dev/null @@ -1,20 +0,0 @@ -export const PIXEL_FRAME_HEADER_SIZE = 24; -export interface Pixel_Frame {width: number; height: number; stride: number; sequence: bigint; pixels: Uint8Array;} -export function decode_pixel_frame(buffer: ArrayBuffer): Pixel_Frame { - if (buffer.byteLength < PIXEL_FRAME_HEADER_SIZE) throw new Error("RVP2 frame is shorter than its header"); - const bytes = new Uint8Array(buffer); - if (String.fromCharCode(...bytes.subarray(0, 4)) !== "RVP2") throw new Error("RVP2 magic is invalid"); - const view = new DataView(buffer, 0, PIXEL_FRAME_HEADER_SIZE); - const width = view.getUint32(4, true), height = view.getUint32(8, true), stride = view.getUint32(12, true), sequence = view.getBigUint64(16, true); - if (!width || !height || stride < width * 4) throw new Error("RVP2 dimensions or stride are invalid"); - const payload_size = stride * height; - if (!Number.isSafeInteger(payload_size) || buffer.byteLength < PIXEL_FRAME_HEADER_SIZE + payload_size) throw new Error("RVP2 pixel payload is truncated"); - return {width, height, stride, sequence, pixels: bytes.subarray(PIXEL_FRAME_HEADER_SIZE, PIXEL_FRAME_HEADER_SIZE + payload_size)}; -} -export function pack_pixel_rows(frame: Pixel_Frame): Uint8ClampedArray { - const row_size = frame.width * 4; - if (frame.stride === row_size) return new Uint8ClampedArray(frame.pixels.buffer, frame.pixels.byteOffset, row_size * frame.height); - const packed = new Uint8ClampedArray(row_size * frame.height); - for (let row = 0; row < frame.height; row++) packed.set(frame.pixels.subarray(row * frame.stride, row * frame.stride + row_size), row * row_size); - return packed; -} diff --git a/webapp_gallery/src/runtime/client_performance.ts b/webapp_gallery/src/runtime/client_performance.ts deleted file mode 100644 index b6d58f3..0000000 --- a/webapp_gallery/src/runtime/client_performance.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type {Gallery_Client_Performance} from "../protocol/gallery_types"; -interface Timed_Sample {timestamp: number; value: number;} -interface Statistics {average: number; deviation: number; p50: number; p95: number; p99: number;} -function statistics(values: number[]): Statistics {if (!values.length) return {average: 0, deviation: 0, p50: 0, p95: 0, p99: 0}; const sorted = values.toSorted((left, right) => left - right); const average = sorted.reduce((sum, value) => sum + value, 0) / sorted.length; const deviation = Math.sqrt(sorted.reduce((sum, value) => sum + (value - average) ** 2, 0) / sorted.length); const percentile = (ratio: number) => sorted[Math.max(0, Math.ceil(ratio * sorted.length) - 1)]; return {average, deviation, p50: percentile(.5), p95: percentile(.95), p99: percentile(.99)};} -function trim(samples: Timed_Sample[], after: number): void {while (samples.length && samples[0].timestamp < after) samples.shift();} -function rate(samples: Timed_Sample[], now: number): number {trim(samples, now - 1000); return samples.length < 2 ? 0 : (samples.length - 1) * 1000 / Math.max(1, samples.at(-1)!.timestamp - samples[0].timestamp);} - -export class Client_Performance { - private transport_samples: Timed_Sample[] = []; private presentation_samples: Timed_Sample[] = []; private display_samples: Timed_Sample[] = []; private round_trip_samples: Timed_Sample[] = []; - private last_animation_frame = 0; private last_pixel_receive = 0; private last_pixel_change = 0; private previous_sequence: bigint | null = null; - private changed_frames = 0; private duplicate_frames = 0; private overwritten_frames = 0; private timeout_count = 0; - record_animation_frame(now: number): void {if (this.last_animation_frame) this.display_samples.push({timestamp: now, value: Math.max(0, now - this.last_animation_frame)}); this.last_animation_frame = now; trim(this.display_samples, now - 10_000);} - record_pixel(now: number, sequence: bigint, overwritten: boolean): void {this.transport_samples.push({timestamp: now, value: 0}); this.last_pixel_receive = now; if (sequence !== this.previous_sequence) {this.changed_frames++; this.last_pixel_change = now;} else this.duplicate_frames++; this.previous_sequence = sequence; if (overwritten) this.overwritten_frames++;} - record_presentation(now: number): void {this.presentation_samples.push({timestamp: now, value: 0});} - record_round_trip(now: number, value: number): void {this.round_trip_samples.push({timestamp: now, value}); trim(this.round_trip_samples, now - 10_000);} - record_timeout(): void {this.timeout_count++;} - reset(): void {this.transport_samples=[]; this.presentation_samples=[]; this.display_samples=[]; this.round_trip_samples=[]; this.last_animation_frame=0; this.last_pixel_receive=0; this.last_pixel_change=0; this.previous_sequence=null; this.changed_frames=0; this.duplicate_frames=0; this.overwritten_frames=0; this.timeout_count=0;} - snapshot(now: number, websocket_buffered_bytes: number): Gallery_Client_Performance {trim(this.round_trip_samples, now - 10_000); trim(this.display_samples, now - 10_000); const display=statistics(this.display_samples.map(item=>item.value)); const round_trip=statistics(this.round_trip_samples.map(item=>item.value)); return {transport_fps:rate(this.transport_samples,now),presentation_fps:rate(this.presentation_samples,now),display_interval_latest_ms:this.display_samples.at(-1)?.value??0,display_interval_ms:display.p50,display_interval_average_ms:display.average,display_interval_p95_ms:display.p95,display_interval_p99_ms:display.p99,display_interval_deviation_ms:display.deviation,frame_round_trip_ms:this.round_trip_samples.at(-1)?.value??0,frame_round_trip_average_ms:round_trip.average,frame_round_trip_p95_ms:round_trip.p95,frame_round_trip_p99_ms:round_trip.p99,frame_round_trip_deviation_ms:round_trip.deviation,changed_pixel_frames:this.changed_frames,duplicate_pixel_frames:this.duplicate_frames,overwritten_pixel_frames:this.overwritten_frames,frame_request_timeout_count:this.timeout_count,last_pixel_receive_age_ms:this.last_pixel_receive?Math.max(0,now-this.last_pixel_receive):0,last_pixel_change_age_ms:this.last_pixel_change?Math.max(0,now-this.last_pixel_change):0,websocket_buffered_bytes};} -} diff --git a/webapp_gallery/src/runtime/frame_request_controller.ts b/webapp_gallery/src/runtime/frame_request_controller.ts deleted file mode 100644 index 340bdb0..0000000 --- a/webapp_gallery/src/runtime/frame_request_controller.ts +++ /dev/null @@ -1,9 +0,0 @@ -export const FRAME_TIMEOUT_MS = 1500; -export class Frame_Request_Controller { - private pending = false; private started_at = 0; private timeout: number | undefined; - constructor(private readonly send_frame: () => boolean, private readonly on_timeout: () => void, private readonly on_round_trip: (now: number, value: number) => void) {} - request_frame(now: number): boolean {if (this.pending || !this.send_frame()) return false; this.pending=true; this.started_at=now; this.timeout=window.setTimeout(()=>{this.pending=false;this.started_at=0;this.timeout=undefined;this.on_timeout();},FRAME_TIMEOUT_MS); return true;} - receive_frame(now: number): void {if (!this.pending) return; this.pending=false; window.clearTimeout(this.timeout); this.timeout=undefined; this.on_round_trip(now,Math.max(0,now-this.started_at)); this.started_at=0;} - cancel(): void {this.pending=false;this.started_at=0;window.clearTimeout(this.timeout);this.timeout=undefined;} - is_pending(): boolean {return this.pending;} -} diff --git a/webapp_gallery/src/runtime/pixel_presenter.ts b/webapp_gallery/src/runtime/pixel_presenter.ts deleted file mode 100644 index cba65d5..0000000 --- a/webapp_gallery/src/runtime/pixel_presenter.ts +++ /dev/null @@ -1,9 +0,0 @@ -import {decode_pixel_frame, pack_pixel_rows, type Pixel_Frame} from "../protocol/pixel_frame"; -export class Pixel_Presenter { - private latest_frame: Pixel_Frame | null = null; private canvas: HTMLCanvasElement | null = null; private context: CanvasRenderingContext2D | null = null; - attach(canvas: HTMLCanvasElement): void {this.canvas=canvas; this.context=canvas.getContext("2d",{alpha:false});} - detach(): void {this.canvas=null;this.context=null;this.latest_frame=null;} - has_pending_frame(): boolean {return this.latest_frame!==null;} - accept(buffer: ArrayBuffer): {sequence: bigint; overwritten: boolean} {const frame=decode_pixel_frame(buffer);const overwritten=this.latest_frame!==null;this.latest_frame=frame;return {sequence:frame.sequence,overwritten};} - present(): boolean {const frame=this.latest_frame, canvas=this.canvas, context=this.context;if(!frame||!canvas||!context)return false;this.latest_frame=null;if(canvas.width!==frame.width||canvas.height!==frame.height){canvas.width=frame.width;canvas.height=frame.height;}const pixels=new Uint8ClampedArray(pack_pixel_rows(frame));context.putImageData(new ImageData(pixels,frame.width,frame.height),0,0);return true;} -} diff --git a/webapp_gallery/src/session/gallery_plot_session.ts b/webapp_gallery/src/session/gallery_plot_session.ts deleted file mode 100644 index 20b26b8..0000000 --- a/webapp_gallery/src/session/gallery_plot_session.ts +++ /dev/null @@ -1,51 +0,0 @@ -import {build_controls} from "../protocol/gallery_descriptor"; -import {parse_gallery_response} from "../protocol/gallery_parser"; -import type {Gallery_Case, Gallery_Frame_Mode, Gallery_Performance_Capture, Gallery_Render_Plan, Json_Primitive, Json_Value} from "../protocol/gallery_types"; -import {Gallery_Socket, gallery_socket_url} from "../transport/gallery_socket"; -import {Client_Performance} from "../runtime/client_performance"; -import {Frame_Request_Controller} from "../runtime/frame_request_controller"; -import {Pixel_Presenter} from "../runtime/pixel_presenter"; -import {EMPTY_PLOT_SNAPSHOT, type Gallery_Plot_Snapshot} from "./gallery_plot_snapshot"; - -export class Gallery_Plot_Session { - private snapshot: Gallery_Plot_Snapshot = EMPTY_PLOT_SNAPSHOT; - private readonly listeners = new Set<() => void>(); - private readonly socket: Gallery_Socket; - private readonly presenter = new Pixel_Presenter(); - private readonly client_performance = new Client_Performance(); - private readonly frame_controller: Frame_Request_Controller; - private current_frame_mode: Gallery_Frame_Mode; - private disposed = false; private visible = false; private streams_paused = false; private last_frame_request_at = 0; private last_observe_at = 0; private sent_width = 0; private sent_height = 0; private received_frames = 0; - constructor(readonly gallery_case: Gallery_Case, frame_mode: Gallery_Frame_Mode, - private readonly frame_modes: Gallery_Frame_Mode[]) { - this.current_frame_mode=frame_mode; - this.socket = new Gallery_Socket(gallery_socket_url(), {on_json: raw=>this.receive_json(raw),on_binary: buffer=>this.receive_pixels(buffer),on_state: state=>this.receive_socket_state(state)}); - this.frame_controller = new Frame_Request_Controller(()=>this.socket.send("frame"),()=>{this.client_performance.record_timeout();if(this.stream_active())this.request_frame(performance.now());},(now,value)=>this.client_performance.record_round_trip(now,value)); - } - get frame_mode(): Gallery_Frame_Mode {return this.current_frame_mode;} - subscribe = (listener: () => void): (() => void) => {this.listeners.add(listener);return()=>this.listeners.delete(listener);}; - get_snapshot = (): Gallery_Plot_Snapshot => this.snapshot; - private update(patch: Partial): void {this.snapshot={...this.snapshot,...patch};this.listeners.forEach(listener=>listener());} - private update_frame_mode(mode_id: string | undefined): void {const mode=this.frame_modes.find(value=>value.id===mode_id);if(!mode||mode.id===this.current_frame_mode.id)return;this.current_frame_mode=mode;this.last_frame_request_at=0;} - private next_refresh_interval_ms(): number {const value=this.snapshot.telemetry.kernel_observer?.next_refresh_interval_ns;return typeof value==="number"&&Number.isFinite(value)&&value>0?value/1_000_000:0;} - connect(): void {if(this.disposed)return;this.socket.connect();} - dispose(): void {this.disposed=true;this.frame_controller.cancel();if(this.snapshot.ready)this.socket.send("hide");this.socket.close();this.presenter.detach();this.listeners.clear();} - attach_canvas(canvas: HTMLCanvasElement): void {this.presenter.attach(canvas);} - detach_canvas(): void {this.presenter.detach();} - set_activity(visible: boolean, streams_paused: boolean): void {const was_active=this.stream_active();this.visible=visible;this.streams_paused=streams_paused;if(visible&&!this.socket.is_open())this.connect();const active=this.stream_active();if(active&&!was_active)this.last_frame_request_at=0;if(this.snapshot.ready&&active!==was_active)this.socket.send(active?"show":"hide");if(active)this.request_frame(performance.now());} - private stream_active(): boolean {return this.visible&&!this.streams_paused;} - private receive_socket_state(state: "connecting"|"ready"|"error"|"closed"): void {if(this.disposed)return;if(state==="ready"){this.last_frame_request_at=0;this.update({connection_state:"ready",protocol_error:""});this.socket.send("gallery_open",{case:this.gallery_case.id,frame_mode:this.frame_mode.id});return;}this.update({connection_state:state,ready:state==="error"?this.snapshot.ready:false});if(state==="closed"&&!this.visible)this.socket.close();} - private receive_json(raw: string): void {try{const response=parse_gallery_response(raw);if(!response)return;if(response.type==="error"){this.update({notice:Object.values(response.field_errors??{})[0]??response.message});return;}if(response.type==="observer_state"){this.update_frame_mode(response.frame_mode);this.update({telemetry:response.telemetry,performance_capture:response.telemetry.performance_capture??this.snapshot.performance_capture,frame_count:this.received_frames});return;}if(response.type==="case_state"||response.type==="refresh_state"){const controls=response.controls;this.update_frame_mode(response.frame_mode);this.update({ready:true,controls:build_controls(controls?.resources),actions:response.actions?.data??[],telemetry:response.telemetry??{},render_plan:controls?.render_plan??null,performance_capture:controls?.performance_capture??response.telemetry?.performance_capture??null,notice:response.notice??"",frame_count:this.received_frames,protocol_error:""});if(this.stream_active()){this.socket.send("show");this.request_frame(performance.now());}}}catch(error){this.update({connection_state:"error",protocol_error:error instanceof Error?error.message:"协议解析失败"});this.socket.close();}} - private receive_pixels(buffer: ArrayBuffer): void {try{const now=performance.now();this.frame_controller.receive_frame(now);const accepted=this.presenter.accept(buffer);this.received_frames++;this.client_performance.record_pixel(now,accepted.sequence,accepted.overwritten);}catch(error){this.update({connection_state:"error",protocol_error:error instanceof Error?error.message:"像素协议错误"});this.socket.close();}} - tick(now: number): void {if(!this.stream_active())return;this.client_performance.record_animation_frame(now);const presented=this.presenter.present();if(presented)this.client_performance.record_presentation(now);this.request_frame(now);if(this.snapshot.ready&&now-this.last_observe_at>=650){this.last_observe_at=now;this.socket.send("gallery_observe",{client_metrics:this.client_performance.snapshot(now,this.socket.buffered_bytes()) as unknown as Json_Value});}} - request_frame(now=performance.now(), explicit=false): boolean {if(!this.snapshot.ready||!this.socket.is_open()||(!explicit&&!this.stream_active())||(explicit&&(!this.visible||this.frame_mode.id!=="manual"))||(!explicit&&(this.frame_mode.id==="manual"||this.presenter.has_pending_frame())))return false;if(!explicit&&this.last_frame_request_at&&now-this.last_frame_request_at): void {this.socket.send(type,payload);} - leave(): void {this.socket.send("leave");} - wheel(payload: Record): void {this.socket.send("wheel",payload);} - key(type: "key_press"|"key_release",payload: Record): void {this.socket.send(type,payload);} - patch(target: string,patch: Record): void {this.socket.send("gallery_patch",{target,patch});} - action(action: string,argument?: Json_Primitive): void {this.socket.send("gallery_action",argument===undefined?{action}:{action,argument});} - refresh(): void {this.socket.send("gallery_refresh",{client_metrics:this.client_performance.snapshot(performance.now(),this.socket.buffered_bytes()) as unknown as Json_Value});} - reset_monitoring(): void {this.client_performance.reset();this.socket.send("gallery_reset_monitoring");} -} diff --git a/webapp_gallery/src/session/gallery_plot_snapshot.ts b/webapp_gallery/src/session/gallery_plot_snapshot.ts deleted file mode 100644 index c47eaef..0000000 --- a/webapp_gallery/src/session/gallery_plot_snapshot.ts +++ /dev/null @@ -1,3 +0,0 @@ -import type {Connection_State, Gallery_Action, Gallery_Control, Gallery_Performance_Capture, Gallery_Render_Plan, Gallery_Telemetry} from "../protocol/gallery_types"; -export interface Gallery_Plot_Snapshot {connection_state: Connection_State; ready: boolean; controls: Gallery_Control[]; actions: Gallery_Action[]; telemetry: Gallery_Telemetry; render_plan: Gallery_Render_Plan | null; performance_capture: Gallery_Performance_Capture | null; notice: string; frame_count: number; protocol_error: string;} -export const EMPTY_PLOT_SNAPSHOT: Gallery_Plot_Snapshot = {connection_state:"connecting",ready:false,controls:[],actions:[],telemetry:{},render_plan:null,performance_capture:null,notice:"",frame_count:0,protocol_error:""}; diff --git a/webapp_gallery/src/theme.ts b/webapp_gallery/src/theme.ts deleted file mode 100644 index cea9af5..0000000 --- a/webapp_gallery/src/theme.ts +++ /dev/null @@ -1,2 +0,0 @@ -import {createTheme} from "@mui/material/styles"; -export const gallery_theme=createTheme({palette:{mode:"dark",primary:{main:"#59d6c5"},secondary:{main:"#ffbd69"},background:{default:"#07111f",paper:"#0d1b2d"}},shape:{borderRadius:12},typography:{fontFamily:'Inter,"Segoe UI","Microsoft YaHei",sans-serif',h1:{fontWeight:800},h2:{fontWeight:750},button:{textTransform:"none",fontWeight:700}},components:{MuiPaper:{styleOverrides:{root:{backgroundImage:"none",border:"1px solid rgba(132,179,206,.16)"}}},MuiButton:{defaultProps:{disableElevation:true}}}}); diff --git a/webapp_gallery/src/transport/catalog_loader.ts b/webapp_gallery/src/transport/catalog_loader.ts deleted file mode 100644 index ac38919..0000000 --- a/webapp_gallery/src/transport/catalog_loader.ts +++ /dev/null @@ -1,36 +0,0 @@ -import {parse_gallery_response} from "../protocol/gallery_parser"; -import {Gallery_Socket, gallery_socket_url} from "./gallery_socket"; -import type {Gallery_Catalog} from "../protocol/gallery_types"; -export function load_gallery_catalog(on_catalog: (catalog: Gallery_Catalog) => void, on_state: (state: string, message: string) => void): () => void { - let disposed = false, completed = false; - const socket = new Gallery_Socket(gallery_socket_url(), { - on_state: state => { - if (disposed || completed) - return; - if (state === "ready") - socket.send("gallery_catalog"); - else if (state === "connecting" || state === "closed") - on_state("connecting", "目录连接中 · 自动重连"); - else if (state === "error") - on_state("error", "目录连接错误 · 自动重连"); - }, - on_binary: () => undefined, - on_json: raw => { - try { - const response = parse_gallery_response(raw); - if (response?.type === "catalog") { - completed = true; - on_catalog(response); - on_state("ready", response.navigation.catalog_loaded_text); - socket.close(); - } else if (response?.type === "error") { - on_state("error", response.message); - } - } catch (error) { - on_state("error", error instanceof Error ? error.message : "目录解析失败"); - } - } - }); - socket.connect(); - return () => {disposed = true; if (!completed) socket.close();}; -} diff --git a/webapp_gallery/src/transport/gallery_socket.ts b/webapp_gallery/src/transport/gallery_socket.ts deleted file mode 100644 index ddecb03..0000000 --- a/webapp_gallery/src/transport/gallery_socket.ts +++ /dev/null @@ -1,32 +0,0 @@ -import ReconnectingWebSocket from "reconnecting-websocket"; -import {gallery_message} from "../protocol/gallery_messages"; -import type {Json_Value} from "../protocol/gallery_types"; -export interface Gallery_Socket_Events {on_json(raw: string): void; on_binary(buffer: ArrayBuffer): void; on_state(state: "connecting" | "ready" | "error" | "closed"): void;} -export function gallery_socket_url(location_value: Location = window.location): string { - const query = new URLSearchParams(location_value.search); - const hosted = location_value.protocol !== "file:" && ["/", "/index.html", "/gallery", "/gallery/"].includes(location_value.pathname); - const preview = location_value.port === "63342"; - const port = query.get("port") || (hosted && !preview ? location_value.port : "8848") || "8848"; - const host = query.get("host") || (hosted ? location_value.hostname : "127.0.0.1") || "127.0.0.1"; - return `${location_value.protocol === "https:" ? "wss" : "ws"}://${host}:${port}/renderive/gallery`; -} -export class Gallery_Socket { - private socket: ReconnectingWebSocket | null = null; - constructor(private readonly url: string, private readonly events: Gallery_Socket_Events) {} - connect(): void { - if (this.socket) - return; - this.events.on_state("connecting"); - const socket = new ReconnectingWebSocket(this.url, [], {minReconnectionDelay: 750, maxReconnectionDelay: 8000, reconnectionDelayGrowFactor: 1.5, minUptime: 3000, connectionTimeout: 4000, maxEnqueuedMessages: 0}); - this.socket = socket; - socket.binaryType = "arraybuffer"; - socket.addEventListener("open", () => {if (this.socket === socket) this.events.on_state("ready");}); - socket.addEventListener("message", event => {if (this.socket !== socket) return; typeof event.data === "string" ? this.events.on_json(event.data) : event.data instanceof ArrayBuffer && this.events.on_binary(event.data);}); - socket.addEventListener("error", () => {if (this.socket === socket) this.events.on_state("error");}); - socket.addEventListener("close", () => {if (this.socket === socket) this.events.on_state("closed");}); - } - close(): void {const socket = this.socket; this.socket = null; socket?.close();} - send(type: string, payload: Record = {}): boolean {if (!this.socket || this.socket.readyState !== WebSocket.OPEN) return false; this.socket.send(gallery_message(type, payload)); return true;} - buffered_bytes(): number {return this.socket?.bufferedAmount ?? 0;} - is_open(): boolean {return this.socket?.readyState === WebSocket.OPEN;} -} diff --git a/webapp_gallery/tests/components/control_field.test.tsx b/webapp_gallery/tests/components/control_field.test.tsx deleted file mode 100644 index 4258bf5..0000000 --- a/webapp_gallery/tests/components/control_field.test.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import {fireEvent,render,screen} from "@testing-library/react";import {describe,expect,it,vi} from "vitest";import {Control_Field} from "../../src/inspector/control_field"; -const control={id:"gain",target:"plot",path:["gain"],label:"Gain",api:"gain",description:"",group:"Plot",input:"number",minimum:0,maximum:10,options:[],value:3}; -describe("control draft",()=>{it("does not overwrite an active draft during backend refresh",()=>{const commit=vi.fn();const view=render();const input=screen.getByLabelText("Gain");fireEvent.focus(input);fireEvent.change(input,{target:{value:"7"}});view.rerender();expect(input).toHaveValue(7);fireEvent.blur(input);expect(commit).toHaveBeenCalledWith(7);});}); diff --git a/webapp_gallery/tests/dag/dag_model.test.ts b/webapp_gallery/tests/dag/dag_model.test.ts deleted file mode 100644 index 1f84966..0000000 --- a/webapp_gallery/tests/dag/dag_model.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import {describe, expect, it} from "vitest"; -import {build_dag_model} from "../../src/dag/dag_model"; -import type {Gallery_Captured_Frame} from "../../src/protocol/gallery_types"; - -describe("DAG view model", () => { - it("uses node_id for current plan and capture overlays", () => { - const plan = { - version: 2, - nodes: [{node_id: 7, name: "paint", owner: "plot", kind: "paint"}], - edges: [], - renderables: [], - }; - - const frame: Gallery_Captured_Frame = { - frame_id: 1, - render_plan_version: 2, - render_duration_ns: 10, - node_executions: [{ - node_id: 7, - worker_id: 3, - start_offset_ns: 0, - cpu_end_offset_ns: 10, - external_start_offset_ns: 0, - external_end_offset_ns: 0, - end_offset_ns: 10, - duration_ns: 10, - cpu_duration_ns: 10, - external_duration_ns: 0, - status: "complete", - attachments: [], - }], - analysis: { - total_render_duration_ns: 10, - critical_path_duration_ns: 10, - total_work_duration_ns: 10, - scheduler_work_duration_ns: 10, - render_domain_work_duration_ns: 0, - total_external_duration_ns: 0, - total_render_domain_queue_wait_ns: 0, - total_gpu_completion_wait_ns: 0, - total_gpu_execution_duration_ns: 0, - scheduler_parallel_overlap_ns: 0, - scheduler_peak_parallelism: 1, - average_cpu_concurrency: 1, - scheduler_average_parallelism: 1, - render_domain_utilization: 0, - critical_path: [7], - bottleneck_nodes: [7], - nodes: [{ - node_id: 7, - duration_ns: 10, - cpu_duration_ns: 10, - render_domain_cpu_duration_ns: 0, - total_cpu_work_duration_ns: 10, - external_duration_ns: 0, - render_domain_queue_wait_ns: 0, - gpu_completion_wait_ns: 0, - gpu_execution_duration_ns: 0, - start_offset_ns: 0, - end_offset_ns: 10, - dependency_ready_offset_ns: 0, - scheduler_wait_ns: 0, - work_contribution: 1, - critical_path_contribution: 1, - on_critical_path: true, - }], - workers: [{ - worker_id: 3, - work_duration_ns: 10, - utilization: 1, - }], - }, - }; - - const model = build_dag_model(plan, frame, [], 7); - expect(model.nodes[0].id).toBe("7"); - expect(model.nodes[0].data).toMatchObject({ - duration_ns: 10, - worker_id: 3, - critical: true, - selected: true, - }); - }); -}); diff --git a/webapp_gallery/tests/e2e/gallery.spec.ts b/webapp_gallery/tests/e2e/gallery.spec.ts deleted file mode 100644 index 31947ef..0000000 --- a/webapp_gallery/tests/e2e/gallery.spec.ts +++ /dev/null @@ -1,2 +0,0 @@ -import {expect,test} from "@playwright/test"; -test("renders the React gallery shell while catalog reconnects",async({page})=>{await page.goto("/");await expect(page.getByText("Renderive 性能画廊")).toBeVisible();await expect(page.getByText("等待后端返回控件与帧策略目录")).toBeVisible();}); diff --git a/webapp_gallery/tests/protocol/gallery_parser.test.ts b/webapp_gallery/tests/protocol/gallery_parser.test.ts deleted file mode 100644 index 103d267..0000000 --- a/webapp_gallery/tests/protocol/gallery_parser.test.ts +++ /dev/null @@ -1,2 +0,0 @@ -import {describe,expect,it} from "vitest";import {Gallery_Protocol_Error,parse_gallery_response} from "../../src/protocol/gallery_parser"; -describe("gallery parser",()=>{it("accepts known responses",()=>expect(parse_gallery_response('{"type":"observer_state","telemetry":{}}')?.type).toBe("observer_state"));it("ignores future response types",()=>expect(parse_gallery_response('{"type":"future"}')).toBeNull());it("contains malformed input at the boundary",()=>expect(()=>parse_gallery_response("{" )).toThrow(Gallery_Protocol_Error));}); diff --git a/webapp_gallery/tests/protocol/pixel_frame.test.ts b/webapp_gallery/tests/protocol/pixel_frame.test.ts deleted file mode 100644 index 26b5b31..0000000 --- a/webapp_gallery/tests/protocol/pixel_frame.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import {describe,expect,it} from "vitest";import {decode_pixel_frame,pack_pixel_rows} from "../../src/protocol/pixel_frame"; -function frame(width:number,height:number,stride:number,payload:number[],sequence=7n):ArrayBuffer {const buffer=new ArrayBuffer(24+payload.length);const bytes=new Uint8Array(buffer);bytes.set([82,86,80,50]);const view=new DataView(buffer);view.setUint32(4,width,true);view.setUint32(8,height,true);view.setUint32(12,stride,true);view.setBigUint64(16,sequence,true);bytes.set(payload,24);return buffer;} -describe("RVP2 decoder",()=>{it("decodes packed RGBA and sequence",()=>{const result=decode_pixel_frame(frame(1,1,4,[1,2,3,4],42n));expect(result).toMatchObject({width:1,height:1,stride:4,sequence:42n});expect([...pack_pixel_rows(result)]).toEqual([1,2,3,4]);});it("removes per-row padding",()=>{const result=decode_pixel_frame(frame(1,2,8,[1,2,3,4,90,91,92,93,5,6,7,8,94,95,96,97]));expect([...pack_pixel_rows(result)]).toEqual([1,2,3,4,5,6,7,8]);});it("rejects invalid magic, stride, and truncated payload",()=>{expect(()=>decode_pixel_frame(frame(1,1,3,[1,2,3]))).toThrow();const truncated=frame(2,2,8,[1,2,3]);expect(()=>decode_pixel_frame(truncated)).toThrow();const magic=frame(1,1,4,[1,2,3,4]);new Uint8Array(magic)[0]=0;expect(()=>decode_pixel_frame(magic)).toThrow();});}); diff --git a/webapp_gallery/tests/runtime/client_performance.test.ts b/webapp_gallery/tests/runtime/client_performance.test.ts deleted file mode 100644 index 3851b6f..0000000 --- a/webapp_gallery/tests/runtime/client_performance.test.ts +++ /dev/null @@ -1,2 +0,0 @@ -import {describe,expect,it} from "vitest";import {Client_Performance} from "../../src/runtime/client_performance"; -describe("client performance",()=>{it("tracks change, duplicate and overwrite independently",()=>{const performance=new Client_Performance();performance.record_animation_frame(10);performance.record_animation_frame(26);performance.record_pixel(30,1n,false);performance.record_pixel(40,1n,true);performance.record_pixel(50,2n,false);performance.record_presentation(55);performance.record_round_trip(60,12);const value=performance.snapshot(70,9);expect(value.changed_pixel_frames).toBe(2);expect(value.duplicate_pixel_frames).toBe(1);expect(value.overwritten_pixel_frames).toBe(1);expect(value.display_interval_latest_ms).toBe(16);expect(value.websocket_buffered_bytes).toBe(9);});}); diff --git a/webapp_gallery/tests/runtime/frame_request_controller.test.ts b/webapp_gallery/tests/runtime/frame_request_controller.test.ts deleted file mode 100644 index 3466242..0000000 --- a/webapp_gallery/tests/runtime/frame_request_controller.test.ts +++ /dev/null @@ -1,2 +0,0 @@ -import {afterEach,describe,expect,it,vi} from "vitest";import {FRAME_TIMEOUT_MS,Frame_Request_Controller} from "../../src/runtime/frame_request_controller"; -describe("frame request controller",()=>{afterEach(()=>vi.useRealTimers());it("allows one in-flight request and records binary RTT",()=>{const send=vi.fn(()=>true),round_trip=vi.fn();const controller=new Frame_Request_Controller(send,vi.fn(),round_trip);expect(controller.request_frame(10)).toBe(true);expect(controller.request_frame(11)).toBe(false);controller.receive_frame(26);expect(round_trip).toHaveBeenCalledWith(26,16);expect(controller.request_frame(27)).toBe(true);});it("releases a timed-out request",()=>{vi.useFakeTimers();const timeout=vi.fn();const controller=new Frame_Request_Controller(()=>true,timeout,vi.fn());controller.request_frame(0);vi.advanceTimersByTime(FRAME_TIMEOUT_MS);expect(timeout).toHaveBeenCalledOnce();expect(controller.is_pending()).toBe(false);});}); diff --git a/webapp_gallery/tests/setup.ts b/webapp_gallery/tests/setup.ts deleted file mode 100644 index f149f27..0000000 --- a/webapp_gallery/tests/setup.ts +++ /dev/null @@ -1 +0,0 @@ -import "@testing-library/jest-dom/vitest";