3D性能优化
This commit is contained in:
+106
-49
@@ -25,18 +25,20 @@ type Schema = {protocol: "aethera.plot.inspector"; version: 2; components: Compo
|
||||
type State_Histories = Record<string, number[]>;
|
||||
type Stream_Status = "CONNECTING" | "LIVE" | "OFFLINE";
|
||||
type Frame_Pacing_Mode = "manual" | "fixed_rate" | "minimum_latency" | "maximum_rate";
|
||||
type Frame_Metadata = {kind: "frame_metadata"; protocol: "aethera.frame"; version: 4; sequence: number; correlation_id: number;
|
||||
type Frame_Delivery = "pixels" | "diagnostics";
|
||||
type Frame_Metadata = {kind: "frame_metadata"; protocol: "aethera.frame"; version: 5; sequence: number; correlation_id: number;
|
||||
delivery: Frame_Delivery;
|
||||
created_time_unix_ms: number; pixel: {width: number; height: number; format: "rgba8"; byte_length: number};
|
||||
pacing: {mode: Frame_Pacing_Mode; fixed_rate_fps: number; minimum_latency_headroom: number};
|
||||
trace: {clock: "steady_elapsed_ns"; markers: Record<string, number>; measurements: Record<string, number>}};
|
||||
type Frame_Stage_Values = Record<string, number>;
|
||||
type Frame_Sample = {sequence: number; correlation_id: number; received_at_ms: number; values: Frame_Stage_Values};
|
||||
type Frame_Sample = {sequence: number; correlation_id: number; delivery: Frame_Delivery; received_at_ms: number; values: Frame_Stage_Values};
|
||||
type Frame_Diagnostics = {metadata: Frame_Metadata; samples: Frame_Sample[]; frame_rate_fps: number; interval_jitter_p95_ms: number;
|
||||
request_to_pixels_average_ms: number; request_to_pixels_p50_ms: number; request_to_pixels_p95_ms: number; request_to_pixels_p99_ms: number;
|
||||
frame_interval_average_ms: number; frame_interval_p95_ms: number; dropped_sequence_count: number; latest: Frame_Stage_Values};
|
||||
type Frame_Metrics = {sequence: number; generated_time_unix_ms: number; request_to_pixels_ms: number; average_request_to_pixels_ms: number;
|
||||
p95_request_to_pixels_ms: number; p99_request_to_pixels_ms: number; frame_rate_fps: number; p95_frame_interval_jitter_ms: number;
|
||||
pacing_mode: Frame_Pacing_Mode; fixed_rate_fps: number; minimum_latency_headroom: number};
|
||||
pacing_mode: Frame_Pacing_Mode; fixed_rate_fps: number; minimum_latency_headroom: number; delivery: Frame_Delivery};
|
||||
type Frame_Policy_Event = {plot_id: string; key: "pacing_mode" | "fixed_rate_fps" | "minimum_latency_headroom"; value: unknown};
|
||||
type Stage_Statistic = "average" | "variability" | "p95" | "p99";
|
||||
type Stage_Unit = "value" | "percentage";
|
||||
@@ -51,8 +53,9 @@ function local_time_milliseconds() {
|
||||
function valid_frame_metadata(value: unknown): value is Frame_Metadata {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const frame = value as Partial<Frame_Metadata>;
|
||||
return frame.kind === "frame_metadata" && frame.protocol === "aethera.frame" && frame.version === 4
|
||||
return frame.kind === "frame_metadata" && frame.protocol === "aethera.frame" && frame.version === 5
|
||||
&& typeof frame.sequence === "number" && typeof frame.correlation_id === "number"
|
||||
&& (frame.delivery === "pixels" || frame.delivery === "diagnostics")
|
||||
&& Boolean(frame.pixel) && frame.pixel?.format === "rgba8" && Boolean(frame.trace);
|
||||
}
|
||||
|
||||
@@ -225,12 +228,18 @@ function build_frame_diagnostics(metadata: Frame_Metadata, samples: Frame_Sample
|
||||
};
|
||||
}
|
||||
|
||||
function use_plot_stream(plot: Plot, canvas_ref: React.RefObject<HTMLCanvasElement | null>) {
|
||||
function use_plot_stream(plot: Plot, canvas_ref: React.RefObject<HTMLCanvasElement | null>, stream_pixels: boolean) {
|
||||
const [status, set_status] = useState<Stream_Status>("CONNECTING");
|
||||
const [metrics, set_metrics] = useState<Frame_Metrics | null>(null);
|
||||
const socket_ref = useRef<ReconnectingWebSocket | null>(null);
|
||||
const pending_pointer_move = useRef<Record<string, unknown> | null>(null);
|
||||
const viewport_ref = useRef({width: 720, height: 420});
|
||||
const stream_pixels_ref = useRef(stream_pixels);
|
||||
|
||||
useEffect(() => {
|
||||
stream_pixels_ref.current = stream_pixels;
|
||||
window.dispatchEvent(new CustomEvent("aethera-frame-delivery", {detail: {plot_id: plot.id}}));
|
||||
}, [plot.id, stream_pixels]);
|
||||
|
||||
const envelope = useCallback((kind: "frame" | "input", event?: Record<string, unknown>) => {
|
||||
const canvas = canvas_ref.current;
|
||||
@@ -280,6 +289,7 @@ function use_plot_stream(plot: Plot, canvas_ref: React.RefObject<HTMLCanvasEleme
|
||||
const clear_timer = () => { if (timer) window.clearTimeout(timer); timer = 0; };
|
||||
const clear_diagnostics_timer = () => { if (diagnostics_timer) window.clearTimeout(diagnostics_timer); diagnostics_timer = 0; };
|
||||
const target_interval = () => {
|
||||
if (!stream_pixels_ref.current) return plot.dimension === "2D" ? 500 : 1000;
|
||||
if (pacing.mode === "maximum_rate") return 0;
|
||||
if (pacing.mode === "fixed_rate") return 1000 / Math.max(0.1, pacing.fixed_rate_fps);
|
||||
if (pacing.mode === "minimum_latency") return latest_metrics
|
||||
@@ -289,7 +299,7 @@ function use_plot_stream(plot: Plot, canvas_ref: React.RefObject<HTMLCanvasEleme
|
||||
};
|
||||
const schedule_next = (immediate = false) => {
|
||||
clear_timer();
|
||||
if (stopped || pacing.mode === "manual") return;
|
||||
if (stopped || (stream_pixels_ref.current && pacing.mode === "manual")) return;
|
||||
const due = immediate || previous_request_time === 0 ? performance.now() : previous_request_time + target_interval();
|
||||
timer = window.setTimeout(() => request_frame(false), Math.max(0, due - performance.now()));
|
||||
};
|
||||
@@ -310,7 +320,8 @@ function use_plot_stream(plot: Plot, canvas_ref: React.RefObject<HTMLCanvasEleme
|
||||
if (!message) return;
|
||||
const request_id = next_request_id++;
|
||||
const started_at = performance.now();
|
||||
socket.send(JSON.stringify({...message, request_id}));
|
||||
const delivery: Frame_Delivery = stream_pixels_ref.current ? "pixels" : "diagnostics";
|
||||
socket.send(JSON.stringify({...message, request_id, delivery}));
|
||||
request_started_at.set(request_id, started_at);
|
||||
frame_pending = true;
|
||||
manual_frame_pending = false;
|
||||
@@ -335,7 +346,9 @@ function use_plot_stream(plot: Plot, canvas_ref: React.RefObject<HTMLCanvasEleme
|
||||
}
|
||||
clear_diagnostics_timer();
|
||||
previous_diagnostics_publish_time = now;
|
||||
const diagnostics = build_frame_diagnostics(latest_metadata, samples);
|
||||
const delivery_samples = samples.filter(sample => sample.delivery === latest_metadata!.delivery);
|
||||
if (delivery_samples.length === 0) return;
|
||||
const diagnostics = build_frame_diagnostics(latest_metadata, delivery_samples);
|
||||
const latest = diagnostics.latest;
|
||||
latest_metrics = {
|
||||
sequence: latest_metadata.sequence,
|
||||
@@ -348,7 +361,8 @@ function use_plot_stream(plot: Plot, canvas_ref: React.RefObject<HTMLCanvasEleme
|
||||
p95_frame_interval_jitter_ms: diagnostics.interval_jitter_p95_ms,
|
||||
pacing_mode: latest_metadata.pacing.mode,
|
||||
fixed_rate_fps: latest_metadata.pacing.fixed_rate_fps,
|
||||
minimum_latency_headroom: latest_metadata.pacing.minimum_latency_headroom
|
||||
minimum_latency_headroom: latest_metadata.pacing.minimum_latency_headroom,
|
||||
delivery: latest_metadata.delivery
|
||||
};
|
||||
set_metrics(latest_metrics);
|
||||
window.dispatchEvent(new CustomEvent("aethera-frame-diagnostics", {detail: {plot_id: plot.id, diagnostics}}));
|
||||
@@ -371,30 +385,39 @@ function use_plot_stream(plot: Plot, canvas_ref: React.RefObject<HTMLCanvasEleme
|
||||
});
|
||||
presentation_callbacks.add(first);
|
||||
};
|
||||
const complete_frame = (pair: {value: Frame_Metadata; received_at: number}, completed_at: number, canvas_upload_ms: number, presentation: boolean) => {
|
||||
const started_at = request_started_at.get(pair.value.correlation_id);
|
||||
if (started_at === undefined) return;
|
||||
frame_pending = false;
|
||||
request_started_at.delete(pair.value.correlation_id);
|
||||
latest_metadata = pair.value;
|
||||
synchronize_pacing(pair.value);
|
||||
const sample_capacity = plot.dimension === "2D" ? 900 : 600;
|
||||
samples = [...samples, {
|
||||
sequence: pair.value.sequence,
|
||||
correlation_id: pair.value.correlation_id,
|
||||
delivery: pair.value.delivery,
|
||||
received_at_ms: completed_at,
|
||||
values: pipeline_stage_values(frame_stage_values(pair.value, started_at, pair.received_at, completed_at, canvas_upload_ms), plot.dimension)
|
||||
}].slice(-sample_capacity);
|
||||
const delivery_sample_count = samples.filter(sample => sample.delivery === pair.value.delivery).length;
|
||||
publish_diagnostics(delivery_sample_count === 1);
|
||||
if (presentation) mark_presentation_opportunity(pair.value.sequence, completed_at);
|
||||
if (manual_frame_pending) request_frame(true); else schedule_next();
|
||||
};
|
||||
const receive_pixels = (bytes: ArrayBuffer) => {
|
||||
const pair = pending_metadata;
|
||||
pending_metadata = null;
|
||||
const canvas = canvas_ref.current;
|
||||
if (!pair || !canvas) return;
|
||||
const started_at = request_started_at.get(pair.value.correlation_id);
|
||||
if (started_at === undefined) return;
|
||||
frame_pending = false;
|
||||
const pixels_received_at = performance.now();
|
||||
const completed_at = performance.now();
|
||||
const canvas_upload_ms = draw_pixels(canvas, bytes, pair.value);
|
||||
request_started_at.delete(pair.value.correlation_id);
|
||||
if (canvas_upload_ms !== null) {
|
||||
latest_metadata = pair.value;
|
||||
synchronize_pacing(pair.value);
|
||||
samples = [...samples, {
|
||||
sequence: pair.value.sequence,
|
||||
correlation_id: pair.value.correlation_id,
|
||||
received_at_ms: pixels_received_at,
|
||||
values: pipeline_stage_values(frame_stage_values(pair.value, started_at, pair.received_at, pixels_received_at, canvas_upload_ms), plot.dimension)
|
||||
}].slice(-10_000);
|
||||
publish_diagnostics(samples.length === 1);
|
||||
mark_presentation_opportunity(pair.value.sequence, pixels_received_at);
|
||||
if (canvas_upload_ms !== null) complete_frame(pair, completed_at, canvas_upload_ms, true);
|
||||
else {
|
||||
frame_pending = false;
|
||||
request_started_at.delete(pair.value.correlation_id);
|
||||
schedule_next();
|
||||
}
|
||||
if (manual_frame_pending) request_frame(true); else schedule_next();
|
||||
};
|
||||
const on_policy_change = (event: Event) => {
|
||||
const detail = (event as CustomEvent<Frame_Policy_Event>).detail;
|
||||
@@ -430,17 +453,26 @@ function use_plot_stream(plot: Plot, canvas_ref: React.RefObject<HTMLCanvasEleme
|
||||
set_metrics(null);
|
||||
window.dispatchEvent(new CustomEvent("aethera-frame-diagnostics-cleared", {detail: {plot_id: plot.id}}));
|
||||
};
|
||||
const on_delivery_change = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{plot_id: string}>).detail;
|
||||
if (detail?.plot_id === plot.id && !frame_pending) schedule_next(true);
|
||||
};
|
||||
window.addEventListener("aethera-frame-policy", on_policy_change);
|
||||
window.addEventListener("aethera-manual-frame", on_manual_frame);
|
||||
window.addEventListener("aethera-reset-camera", on_camera_reset);
|
||||
window.addEventListener("aethera-reset-frame-diagnostics", on_diagnostics_reset);
|
||||
window.addEventListener("aethera-frame-delivery", on_delivery_change);
|
||||
socket.onopen = () => { frame_pending = false; pending_metadata = null; request_started_at.clear(); set_status("LIVE"); request_frame(true); };
|
||||
socket.onclose = () => { clear_timer(); frame_pending = false; pending_metadata = null; request_started_at.clear(); if (!stopped) set_status("CONNECTING"); };
|
||||
socket.onmessage = event => {
|
||||
if (typeof event.data === "string") {
|
||||
try {
|
||||
const decoded: unknown = JSON.parse(event.data);
|
||||
if (valid_frame_metadata(decoded)) pending_metadata = {value: decoded, received_at: performance.now()};
|
||||
if (valid_frame_metadata(decoded)) {
|
||||
const pair = {value: decoded, received_at: performance.now()};
|
||||
if (decoded.delivery === "diagnostics") { pending_metadata = null; complete_frame(pair, pair.received_at, 0, false); }
|
||||
else pending_metadata = pair;
|
||||
}
|
||||
} catch { return; }
|
||||
return;
|
||||
}
|
||||
@@ -457,6 +489,7 @@ function use_plot_stream(plot: Plot, canvas_ref: React.RefObject<HTMLCanvasEleme
|
||||
window.removeEventListener("aethera-manual-frame", on_manual_frame);
|
||||
window.removeEventListener("aethera-reset-camera", on_camera_reset);
|
||||
window.removeEventListener("aethera-reset-frame-diagnostics", on_diagnostics_reset);
|
||||
window.removeEventListener("aethera-frame-delivery", on_delivery_change);
|
||||
socket_ref.current = null;
|
||||
socket.close();
|
||||
};
|
||||
@@ -700,7 +733,7 @@ function State_Field_View({component, field, histories}: {component: Component;
|
||||
}
|
||||
|
||||
type Pipeline_Stage_Definition = [string, string, string];
|
||||
const pipeline_common_start: Pipeline_Stage_Definition = ["pipeline_request_transport_ms", "请求传输", "浏览器发出帧请求到服务端创建 Render_Frame 之前的耗时。"];
|
||||
const pipeline_common_start: Pipeline_Stage_Definition = ["pipeline_request_transport_ms", "请求与元数据往返", "浏览器请求、服务端建帧前调度以及完成元数据返回浏览器的合计衔接耗时。"];
|
||||
const pipeline_common_finish: Pipeline_Stage_Definition[] = [
|
||||
["pipeline_payload_transport_ms", "像素传输", "浏览器收到元数据后,直到完整像素载荷到达的耗时。"],
|
||||
["pipeline_canvas_upload_ms", "Canvas 写入", "浏览器把 RGBA 像素写入 Canvas 的耗时。"],
|
||||
@@ -714,8 +747,7 @@ const pipeline_2d_definitions: Pipeline_Stage_Definition[] = [
|
||||
["pipeline_2d_scene_coordination_ms", "2D Scene 编排", "Scene 渲染区间内除事件、Prepare、Paint 外的依赖图编排耗时。"],
|
||||
["pipeline_2d_callback_ms", "2D 完成回调", "同步二维帧完成后回调到 Plot 发布线程的耗时。"],
|
||||
["pipeline_2d_encode_ms", "BGRA→RGBA 编码", "逐行把 Blend2D BGRA 帧缓存转换成 WebSocket RGBA 载荷的耗时。"],
|
||||
["pipeline_2d_frame_handoff_ms", "2D 帧建立与发布调度", "Render_Frame 建立、进入 Scene 以及编码完成后生成元数据的调度间隙。"],
|
||||
...pipeline_common_finish
|
||||
["pipeline_2d_frame_handoff_ms", "2D 帧建立与发布调度", "Render_Frame 建立、进入 Scene 以及编码完成后生成元数据的调度间隙。"]
|
||||
];
|
||||
const pipeline_3d_definitions: Pipeline_Stage_Definition[] = [
|
||||
pipeline_common_start,
|
||||
@@ -736,10 +768,20 @@ const pipeline_3d_definitions: Pipeline_Stage_Definition[] = [
|
||||
["pipeline_3d_readback_ms", "3D CPU 回读", "GPU 完成后由 Datoviz 收集并复制 RGBA 像素的耗时。"],
|
||||
["pipeline_3d_callback_ms", "3D 完成回调", "异步后端完成后回调到 Plot 发布线程的耗时。"],
|
||||
["pipeline_3d_encode_ms", "3D WebSocket 封装", "把连续 RGBA 像素封装为 WebSocket 消息并生成元数据的耗时。"],
|
||||
["pipeline_3d_completion_handoff_ms", "3D 完成调度衔接", "GPU 回读、回调与发布边界之间尚未由 trace marker 单独覆盖的调度衔接时间。"],
|
||||
...pipeline_common_finish
|
||||
["pipeline_3d_completion_handoff_ms", "3D 完成调度衔接", "GPU 回读、回调与发布边界之间尚未由 trace marker 单独覆盖的调度衔接时间。"]
|
||||
];
|
||||
const pipeline_definitions = (dimension: Plot["dimension"]) => dimension === "2D" ? pipeline_2d_definitions : pipeline_3d_definitions;
|
||||
const pipeline_definitions = (dimension: Plot["dimension"], delivery: Frame_Delivery) => {
|
||||
const core = (dimension === "2D" ? pipeline_2d_definitions : pipeline_3d_definitions).map(definition => {
|
||||
if (delivery === "pixels") return definition;
|
||||
const [key] = definition;
|
||||
if (key === "pipeline_2d_encode_ms") return [key, "2D 诊断编码", "跳过 BGRA 像素转换,仅生成二维诊断元数据的耗时。"] as Pipeline_Stage_Definition;
|
||||
if (key === "pipeline_3d_gpu_copy_ms") return [key, "GPU 回读复制(跳过)", "诊断帧不复制像素到回读资源;该项应接近零。"] as Pipeline_Stage_Definition;
|
||||
if (key === "pipeline_3d_readback_ms") return [key, "3D 完成收集", "收集 GPU 完成状态和时间戳、但不下载 RGBA 像素的耗时。"] as Pipeline_Stage_Definition;
|
||||
if (key === "pipeline_3d_encode_ms") return [key, "3D 诊断编码", "跳过像素消息,仅生成三维诊断元数据的耗时。"] as Pipeline_Stage_Definition;
|
||||
return definition;
|
||||
});
|
||||
return [...core, ...(delivery === "pixels" ? pipeline_common_finish : [])];
|
||||
};
|
||||
|
||||
function diagnostic_value(value: number, key: string) {
|
||||
if (key === "payload_megabytes") return `${value.toFixed(2)} MiB`;
|
||||
@@ -761,18 +803,22 @@ function Frame_Timeline_Chart({diagnostics, dimension, paused, on_context_menu}:
|
||||
const chart = chart_ref.current;
|
||||
if (!chart) return;
|
||||
const visible_samples = diagnostics.samples;
|
||||
const total_series: [string, string, string] = diagnostics.metadata.delivery === "pixels"
|
||||
? ["request_to_presentation_opportunity_ms", "完整像素流水线", "#5ce4c2"]
|
||||
: ["request_to_pixels_ms", "诊断往返", "#5ce4c2"];
|
||||
const pixel_delivery = diagnostics.metadata.delivery === "pixels";
|
||||
const series_keys: Array<[string, string, string]> = dimension === "2D" ? [
|
||||
["request_to_presentation_opportunity_ms", "完整流水线", "#5ce4c2"],
|
||||
total_series,
|
||||
["pipeline_2d_prepare_ms", "2D Prepare", "#62a8ff"],
|
||||
["pipeline_2d_paint_ms", "Blend2D 绘制", "#f4bd63"],
|
||||
["pipeline_payload_transport_ms", "像素传输", "#ff7d9c"],
|
||||
["pipeline_presentation_wait_ms", "呈现等待", "#b998ff"]
|
||||
pixel_delivery ? ["pipeline_payload_transport_ms", "像素传输", "#ff7d9c"] : ["pipeline_2d_encode_ms", "诊断编码", "#ff7d9c"],
|
||||
pixel_delivery ? ["pipeline_presentation_wait_ms", "呈现等待", "#b998ff"] : ["pipeline_2d_frame_handoff_ms", "发布衔接", "#b998ff"]
|
||||
] : [
|
||||
["request_to_presentation_opportunity_ms", "完整流水线", "#5ce4c2"],
|
||||
total_series,
|
||||
["pipeline_3d_prepare_ms", "Visual Prepare", "#62a8ff"],
|
||||
["pipeline_3d_backend_queue_ms", "后端排队", "#f4bd63"],
|
||||
["pipeline_3d_gpu_render_ms", "GPU Render", "#ff7d9c"],
|
||||
["pipeline_3d_readback_ms", "CPU 回读", "#b998ff"]
|
||||
pixel_delivery ? ["pipeline_3d_readback_ms", "CPU 回读", "#b998ff"] : ["pipeline_3d_gpu_sync_ms", "GPU 同步", "#b998ff"]
|
||||
];
|
||||
chart.setOption({
|
||||
backgroundColor: "transparent",
|
||||
@@ -809,20 +855,22 @@ function Frame_Diagnostics_View({diagnostics, dimension, on_reset}: {diagnostics
|
||||
const displayed = paused ? snapshot : diagnostics;
|
||||
if (!displayed) return <section className="diagnosticEmpty"><strong>等待流水线样本</strong><span>采样独立于图形面板可见性;收到第一帧后开始统计。</span></section>;
|
||||
const copy = async () => { await navigator.clipboard.writeText(JSON.stringify(displayed.metadata, null, 2)); set_copied(true); window.setTimeout(() => set_copied(false), 1200); };
|
||||
const definitions = pipeline_definitions(dimension);
|
||||
const definitions = pipeline_definitions(dimension, displayed.metadata.delivery);
|
||||
const stage_values = definitions.map(([key, label, description]) => {
|
||||
const history = displayed.samples.map(sample => sample.values[key]).filter(Number.isFinite);
|
||||
return [key, label, description, stage_statistic(history, stage_statistic_mode)] as const;
|
||||
});
|
||||
const total_value = stage_values.reduce((sum, [, , , value]) => sum + (Number.isFinite(value) ? value : 0), 0);
|
||||
const statistic_labels: Record<Stage_Statistic, string> = {average: "滑动平均", variability: "波动", p95: "P95", p99: "P99"};
|
||||
const pixel_delivery = displayed.metadata.delivery === "pixels";
|
||||
const completion = pixel_delivery ? "完整像素到达浏览器" : "诊断元数据到达浏览器";
|
||||
const summaries: Array<[string, string, string]> = [
|
||||
["帧率", `${displayed.frame_rate_fps.toFixed(1)} FPS`, "当前统计区间内每秒完成的帧数。"],
|
||||
["端到端平均", `${displayed.request_to_pixels_average_ms.toFixed(2)} ms`, "帧请求发出到像素完整到达浏览器的平均耗时。"],
|
||||
[pixel_delivery ? "像素帧率" : "诊断频率", `${displayed.frame_rate_fps.toFixed(1)} FPS`, `当前统计区间内每秒完成的${pixel_delivery ? "完整像素帧" : "后台诊断帧"}数量。`],
|
||||
["端到端平均", `${displayed.request_to_pixels_average_ms.toFixed(2)} ms`, `帧请求发出到${completion}的平均耗时。`],
|
||||
["端到端 P50", `${displayed.request_to_pixels_p50_ms.toFixed(2)} ms`, "一半样本不超过该端到端耗时。"],
|
||||
["端到端 P95", `${displayed.request_to_pixels_p95_ms.toFixed(2)} ms`, "95% 样本不超过该端到端耗时,用于观察长尾。"],
|
||||
["端到端 P99", `${displayed.request_to_pixels_p99_ms.toFixed(2)} ms`, "99% 样本不超过该端到端耗时,用于观察极端长尾。"],
|
||||
["帧间隔平均", `${displayed.frame_interval_average_ms.toFixed(2)} ms`, "相邻两帧像素到达浏览器的平均间隔。"],
|
||||
["帧间隔平均", `${displayed.frame_interval_average_ms.toFixed(2)} ms`, `相邻两次${completion}的平均间隔。`],
|
||||
["帧间隔抖动 P95", `${displayed.interval_jitter_p95_ms.toFixed(2)} ms`, "帧间隔相对中位数偏差的第 95 百分位。"],
|
||||
["请求 ID 缺口", displayed.dropped_sequence_count.toLocaleString("zh-CN"), "相邻已完成请求 ID 之间缺失的数量;可能表示请求未形成完整样本。"]
|
||||
];
|
||||
@@ -837,7 +885,7 @@ function Frame_Diagnostics_View({diagnostics, dimension, on_reset}: {diagnostics
|
||||
};
|
||||
const reset = () => { set_paused(false); set_snapshot(null); set_context_menu(null); on_reset(); };
|
||||
return <section className="frameDiagnosticPanel">
|
||||
<div className="diagnosticNotice" title="暂停只冻结分析视图,不会停止后台帧请求与样本采集。">当前区间 {displayed.samples.length} 帧;图表右键可暂停并缩放查看。暂停只冻结视图,采样始终继续。“呈现机会”不等同于物理屏幕扫描时刻。</div>
|
||||
<div className="diagnosticNotice" title="暂停只冻结分析视图,不会停止后台帧请求与样本采集。">当前为{pixel_delivery ? "完整像素" : "后台诊断"}模式,区间内 {displayed.samples.length} 帧;图表右键可暂停并缩放查看。{pixel_delivery ? "“呈现机会”不等同于物理屏幕扫描时刻。" : "离屏诊断仍执行真实渲染,但跳过 GPU 像素回读、像素传输和 Canvas 写入。"}</div>
|
||||
<dl className="frameDiagnosticSummary">{summaries.map(([label, value, description]) => <div key={label} title={description}><dt>{label}</dt><dd>{value}</dd></div>)}</dl>
|
||||
<Frame_Timeline_Chart diagnostics={displayed} dimension={dimension} paused={paused} on_context_menu={open_context_menu}/>
|
||||
<section className="frameStagePanel"><header><div><strong title="互斥阶段来自同一条端到端流水线,各阶段占比之和约为 100%。">流水线阶段统计</strong><code>#{displayed.metadata.sequence} / 请求 {displayed.metadata.correlation_id}</code></div>
|
||||
@@ -847,7 +895,7 @@ function Frame_Diagnostics_View({diagnostics, dimension, on_reset}: {diagnostics
|
||||
<div className="stageSegmented" role="group" aria-label="显示单位">{(["value", "percentage"] as Stage_Unit[]).map(unit =>
|
||||
<button key={unit} title={unit === "value" ? "显示阶段耗时(毫秒)。" : "按当前统计口径归一化;所有互斥阶段合计约 100%。"} aria-pressed={stage_unit === unit} className={stage_unit === unit ? "active" : ""} onClick={() => set_stage_unit(unit)}>{unit === "value" ? "数值" : "百分比"}</button>)}</div>
|
||||
</div></header>
|
||||
<div className="pipelineDirection" aria-label={`${dimension} 帧流水线方向`}><strong>浏览器请求</strong>{definitions.map(([key, label, description]) => <span key={key} title={description}><i>→</i>{label}</span>)}<span><i>→</i><strong>浏览器呈现</strong></span></div>
|
||||
<div className="pipelineDirection" aria-label={`${dimension} 帧流水线方向`}><strong>浏览器请求</strong>{definitions.map(([key, label, description]) => <span key={key} title={description}><i>→</i>{label}</span>)}<span><i>→</i><strong>{pixel_delivery ? "浏览器呈现" : "浏览器收到诊断"}</strong></span></div>
|
||||
<dl>{stage_values.map(([key, label, description, value]) => <div key={key} title={description}><dt>{label}</dt><dd>{!Number.isFinite(value) ? "--"
|
||||
: stage_unit === "percentage" ? `${(total_value > 0 ? value / total_value * 100 : 0).toFixed(1)}%`
|
||||
: diagnostic_value(value, key)}</dd></div>)}</dl></section>
|
||||
@@ -924,7 +972,7 @@ function Frame_Policy_Pane({plot, analysis, busy, on_refresh, on_update, on_manu
|
||||
}) {
|
||||
const fields = analysis?.fields.filter(field => field.editable) ?? [];
|
||||
return <section className="workspacePane"><Workspace_Header plot={plot} label="采样与帧策略" count={fields.length} busy={busy} on_refresh={on_refresh}/>
|
||||
<div className="workspaceBody"><section className="analysisSection"><header><div><strong>持续采样控制</strong><span>采样独立于图形面板可见性;暂停统计图不会停止采样。</span></div>
|
||||
<div className="workspaceBody"><section className="analysisSection"><header><div><strong>{plot.dimension} 持续采样控制</strong><span>选中或可见时传输完整像素;离屏时继续真实渲染并只回传轻量诊断。2D 与 3D 使用各自的采样频率和阶段模型。</span></div>
|
||||
<div className="framePolicyActions"><button onClick={on_manual_frame}>手动生成一帧</button>{plot.dimension === "3D" ? <button onClick={on_camera_reset}>复位相机</button> : null}<button onClick={on_reset}>清空并从头统计</button></div></header>
|
||||
{analysis ? <div className="propGrid">{fields.map(field => <Property_Control key={field.key} field={field} on_commit={value => on_update(analysis, field, value)}/>)}</div> : <p className="muted">正在读取帧策略…</p>}
|
||||
</section></div></section>;
|
||||
@@ -938,15 +986,24 @@ function Data_Generation_Pane({plot, analysis, busy, on_refresh, on_generated}:
|
||||
|
||||
function Frame_Statistics_Pane({plot, diagnostics, busy, on_refresh, on_reset}: {plot: Plot; diagnostics: Frame_Diagnostics | null; busy: boolean; on_refresh: () => void; on_reset: () => void}) {
|
||||
return <section className="workspacePane"><Workspace_Header plot={plot} label={`${plot.dimension} 帧流水线统计`} count={diagnostics?.samples.length ?? 0} busy={busy} on_refresh={on_refresh}/>
|
||||
<div className="workspaceBody"><Frame_Diagnostics_View diagnostics={diagnostics} dimension={plot.dimension} on_reset={on_reset}/></div></section>;
|
||||
<div className="workspaceBody"><Frame_Diagnostics_View key={plot.id} diagnostics={diagnostics} dimension={plot.dimension} on_reset={on_reset}/></div></section>;
|
||||
}
|
||||
|
||||
const Plot_Card = memo(function Plot_Card({plot, selected, on_select}: {plot: Plot; selected: boolean; on_select: (plot: Plot) => void}) {
|
||||
const card_ref = useRef<HTMLElement>(null);
|
||||
const canvas_ref = useRef<HTMLCanvasElement>(null);
|
||||
const {status, metrics} = use_plot_stream(plot, canvas_ref);
|
||||
const [in_view, set_in_view] = useState(false);
|
||||
useEffect(() => {
|
||||
const card = card_ref.current;
|
||||
if (!card) return;
|
||||
const observer = new IntersectionObserver(entries => set_in_view(entries.some(entry => entry.isIntersecting)), {threshold: 0.05});
|
||||
observer.observe(card);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
const {status, metrics} = use_plot_stream(plot, canvas_ref, selected || in_view);
|
||||
const generated_time = metrics ? new Date(metrics.generated_time_unix_ms).toLocaleTimeString("zh-CN", {hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, fractionalSecondDigits: 3}) : "尚未生成帧";
|
||||
return <article className={`card${selected ? " selected" : ""}`} data-plot-id={plot.id} aria-current={selected ? "true" : undefined}
|
||||
onPointerDownCapture={() => on_select(plot)} onFocusCapture={() => on_select(plot)}><header className="cardDragHandle"><div><span className="eyebrow">绘图组件 · {plot.dimension}</span><h2>{plot_labels[plot.id] ?? plot.title}</h2></div><div className="cardRuntime" title={`帧序号 ${metrics?.sequence ?? 0} · 创建于 ${generated_time}`}><div><span className="status">{{CONNECTING: "重连中", LIVE: "实时", OFFLINE: "已离线"}[status]}</span><span className="framePolicy">{metrics ? enum_label(metrics.pacing_mode) : "等待策略"}</span></div><div className="frameMetrics"><span>{metrics ? metrics.frame_rate_fps.toFixed(1) : "--.-"} FPS</span><span>E2E {metrics ? metrics.request_to_pixels_ms.toFixed(1) : "--.-"} ms</span><span>P95 {metrics ? metrics.p95_request_to_pixels_ms.toFixed(1) : "--.-"} ms</span><span>P99 {metrics ? metrics.p99_request_to_pixels_ms.toFixed(1) : "--.-"} ms</span></div></div></header>
|
||||
return <article ref={card_ref} className={`card${selected ? " selected" : ""}`} data-plot-id={plot.id} aria-current={selected ? "true" : undefined}
|
||||
onPointerDownCapture={() => on_select(plot)} onFocusCapture={() => on_select(plot)}><header className="cardDragHandle"><div><span className="eyebrow">绘图组件 · {plot.dimension}</span><h2>{plot_labels[plot.id] ?? plot.title}</h2></div><div className="cardRuntime" title={`帧序号 ${metrics?.sequence ?? 0} · 创建于 ${generated_time}`}><div><span className="status">{{CONNECTING: "重连中", LIVE: "实时", OFFLINE: "已离线"}[status]}</span><span className="framePolicy">{metrics?.delivery === "diagnostics" ? "后台诊断" : metrics ? enum_label(metrics.pacing_mode) : "等待策略"}</span></div><div className="frameMetrics"><span>{metrics ? metrics.frame_rate_fps.toFixed(1) : "--.-"} FPS</span><span>E2E {metrics ? metrics.request_to_pixels_ms.toFixed(1) : "--.-"} ms</span><span>P95 {metrics ? metrics.p95_request_to_pixels_ms.toFixed(1) : "--.-"} ms</span><span>P99 {metrics ? metrics.p99_request_to_pixels_ms.toFixed(1) : "--.-"} ms</span></div></div></header>
|
||||
{plot.description ? <p>{plot.description}</p> : null}<div className="plotViewport"><canvas ref={canvas_ref} tabIndex={0}/></div></article>;
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user