feat(studio-3d): 限速、真实耗时统计、无位移复用资产几何,以及保护包密钥注入(阶段 9 上半)
- What:
1. **显示限速真的生效**(R5):`ThreeDCoordinator.tick()` 用注入的单调时钟与
`display_period_s() = 1/max_fps` 限制**合成启动**频率(窗口定时器仍是 16 ms,但"显示帧率"是给操作者的
承诺,不是定时器的描述);声明边界(activate/begin_session/show_rest/隐藏)把预算清零,所以按下开始后的
第一帧立刻合成。生产者侧改为**按需**跳过:`Scene3DView.submit_packet()` 在
`coordinator.has_pending_frame()`(mailbox 里已有一帧等待)时不再构造快照——那是 O(H×W) 的拷贝,
而 mailbox 只保留最新一帧,两次拷贝里必然丢一个;被跳过的数量计入新的 `skipped_producer`,
与"显示跳帧"分开。**不丢任何本该显示的帧**(等待中的那一帧仍在)。
2. **真实统计**:`RenderFrame.compose_ms`(composer 在构造帧之前测量并写入,不再读会被下一任务改写的
`last_stats`);`GLRenderer` 的绘制计数改名为 `paint_calls`,`RenderStats.displayed_frames` 由
**widget 按帧身份**统计(相机旋转/色标改动只是重画,不再是"多显示了一帧");`render_totals()` 增
`compose_ms`/`latency_ms`(合成开始→被接受的单调耗时)/`paint_calls`/`skipped_producer`,并且
`shown`/`compose_ms` 改从 widget 读(无 GL 的机器也能报告,之前只有画过才有数)。
3. **无位移时复用资产几何**(R5 建议):`SceneComposer` 缓存 `asset_hash → (positions, normals)`;
变形时照旧从形变后的位置重算法线,不变形时直接用资产自己的(只读、零拷贝)数组。20k 顶点实测:
compose p50 **6.15 ms → 1.03 ms**(法线段 4.9 ms → 0.0001 ms,剩余是采样 1.01 ms);
真实发布包(1032 顶点)compose p50 **0.50 ms → 0.17 ms**。
4. **保护包密钥注入**(R4):新增 `scene3d/keys.py`——从 preference 读取
`scene3d_verify_key`(验签公钥 PEM)/`scene3d_content_key_file` + `scene3d_content_key_id`
(内容密钥,经 `protect.load_key_file`)/`scene3d_require_signature`(发布模式,拒绝无签名包),
组装成 `SceneKeys` 交给 `SceneAssetLoader` → `load_package` → `PackagedAssetLoader.load(keys=…,
verify_key_pem=…)`;`AppWindow._sync_3d_source()` 解析它并 `Mesh3dPanel.set_keys()`。
缺失/不可读的密钥是**状态而不是崩溃**(`describe()` 里带原因),密钥材料永不进日志;
`Mesh3dPanel.describe()` 的 scene 块记录 `keys`(布尔 + 路径)。
- Why: 审查报告 R5(`max_fps` 只被存储与显示、`draw()` 硬编码 `compose_ms=0.0`、`_displayed_frames` 把
重画也算新帧、`SceneComposer.last_stats` 被显示链路完全未读)与 R4(底层支持签名/加密包,Studio 加载
入口却从不传验签公钥与 KeyProvider,合法保护包打不开,只能发明文包或关掉校验)。R5 的另一半是
"当前只有曲面着色、没有 displacement 时,composer 仍每帧重算法线并重新打包位置"——缓存后 20k 顶点
降低到六分之一。
- Verification:
· `test_scene3d_coordinator.py` +6:15 fps 的假时钟下 1.32 s 内只启动约 20 次合成(60 fps 对照 ≥55 次)、
`display_period_s()` 跟随设置、结果延迟是真实单调差值、`has_pending_frame()` 随 mailbox 变化;
`test_scene3d_live.py` +3:无位移时发布帧直接**就是**资产自己的(只读)数组、force_deform 但仍无位移时
给出 `field_unavailable` 诊断、有标定位移时仍重算、`compose_ms` 属于该帧且不被下一次合成改写;
`test_scene3d_view.py` +2:等待中的帧让生产者跳过第二个快照且取出后立即恢复、`render_totals()` 的
compose 成本与延迟是真实值;新增 `test_scene3d_protected_package.py` 7 项:Studio 入口打开签名包、
用 preference 里的内容密钥打开加密包、缺验签公钥 → `asset_auth_failed`、密钥文件缺失/无 key id →
状态而非崩溃、发布模式拒绝无签名包而开发模式接受、`SceneAssetLoader` 把密钥交给 worker、面板携带密钥。
· 全量:shared+apps 同一次运行 **588 项 0 失败 15 跳过**;真 GL 冒烟(`QT_QPA_PLATFORM=windows`)
**14 项 0 失败**;ui_check 587 项 0 失败;locale_check 0 缺失。
· 顺带修掉两个**既有的顺序依赖**真 GL 用例(不是本次引入:在上一提交上单跑同样失败):
`test_the_producer_side_never_waits_for_the_gpu` 的收尾没有排空 mailbox(留下的一帧会被下一个测试
上传),`test_an_automatic_colour_scale_follows_the_frame` 在渲染之前就断言"范围来自本帧",
且用"不同颜色数"当自动量程的代理(对可见面积敏感)。现在前者排空并断言排空完成,后者先渲染再断言,
判据改为**平均亮度**(固定 0..1000 把 3..7 压进 LUT 最暗的一小段,自动量程铺满整条色带)。
· 性能报告(`.repro/perf-cpu-*-after-9.json`,`bench_scene.py --cpu-only`):合成 p50
合成 poc 0.016 ms / 20k 顶点 1.03 ms / 真实包 0.17 ms;对比已提交的 `perf-report-windows-dev.json`
(20k 6.15 ms、真实包 0.50 ms)。
- Not in this change: **画布布局第二批**(2D/3D 页签容器、参数抽屉、窄窗口回退、诊断抽屉)与曲线量程开关
仍未做;阶段 9 的门禁部分(`soak_scene.py --minutes 120` GUI 模式、Jetson Orin Nano 与 QCS6490 上的
`probe_gl/bench_scene/soak_scene` 报告)需要长时间运行与板卡,本轮未跑,结论不写"通过";
签名私钥仍只在构建机(本提交只引入"读"的一半)。
This commit is contained in:
@@ -71,6 +71,10 @@ class SceneComposer:
|
||||
def __init__(self, sampler: Optional[SurfaceSampler] = None) -> None:
|
||||
self._sampler = sampler or SurfaceSampler()
|
||||
self.last_stats = ComposeStats()
|
||||
#: Rest positions and normals, keyed by ``asset_hash``: with no displacement to
|
||||
#: draw, the picture is the asset's own geometry, and recomputing its normals
|
||||
#: every frame was pure cost (measured as the bulk of a 20k-vertex compose).
|
||||
self._rest_geometry: dict[str, tuple[np.ndarray, np.ndarray]] = {}
|
||||
|
||||
def compose(
|
||||
self,
|
||||
@@ -91,15 +95,16 @@ class SceneComposer:
|
||||
sampling_started = time.perf_counter()
|
||||
fields = self._sampler.sample(frame, assets.binding)
|
||||
self.last_stats.sample_ms = (time.perf_counter() - sampling_started) * 1000.0
|
||||
rest_positions = np.asarray(part.positions_sensor_m, dtype=np.float64)
|
||||
rest_positions, rest_normals = self._rest(assets)
|
||||
diagnostics: list[Diagnostic] = []
|
||||
|
||||
positions = rest_positions
|
||||
quality = frame.displacement_quality
|
||||
if settings.deform_enabled and fields.displacement_sensor_m is not None:
|
||||
deformed = bool(settings.deform_enabled and fields.displacement_sensor_m is not None)
|
||||
if deformed:
|
||||
gain = float(settings.display_gain)
|
||||
displacement = np.asarray(fields.displacement_sensor_m, dtype=np.float64)
|
||||
positions = rest_positions + gain * displacement
|
||||
positions = np.asarray(rest_positions, dtype=np.float64) + gain * displacement
|
||||
if quality != "calibrated":
|
||||
diagnostics.append(
|
||||
diagnostic(
|
||||
@@ -118,16 +123,33 @@ class SceneComposer:
|
||||
)
|
||||
)
|
||||
normals_started = time.perf_counter()
|
||||
normals = vertex_normals(positions, np.asarray(part.triangles, dtype=np.int64))
|
||||
# In place, the normals are the asset's own: the positions are untouched, so
|
||||
# recomputing them from the same triangles gives the same answer every frame.
|
||||
normals = (
|
||||
vertex_normals(positions, np.asarray(part.triangles, dtype=np.int64))
|
||||
if deformed
|
||||
else rest_normals
|
||||
)
|
||||
self.last_stats.normals_ms = (time.perf_counter() - normals_started) * 1000.0
|
||||
|
||||
# Measured before the frame is built, so the frame carries *its own* cost: the
|
||||
# shared ``last_stats`` object is rewritten by the next task.
|
||||
total_ms = (time.perf_counter() - started) * 1000.0
|
||||
self.last_stats.total_ms = total_ms
|
||||
|
||||
result = RenderFrame(
|
||||
identity=frame.identity,
|
||||
asset_hash=assets.asset_hash,
|
||||
geometry_id=assets.geometry_id,
|
||||
sensing_part_id=part.part_id,
|
||||
positions_sensor_m=freeze(positions, dtype=np.float32),
|
||||
normals_sensor=freeze(normals, dtype=np.float32),
|
||||
positions_sensor_m=(
|
||||
freeze(positions, dtype=np.float32)
|
||||
if deformed
|
||||
else rest_positions
|
||||
),
|
||||
normals_sensor=(
|
||||
freeze(normals, dtype=np.float32) if deformed else rest_normals
|
||||
),
|
||||
# The measurement, untouched by the display gain.
|
||||
scalar=fields.scalar,
|
||||
valid=fields.valid,
|
||||
@@ -136,11 +158,31 @@ class SceneComposer:
|
||||
# Carried through untouched: only the display side knows whether the frame
|
||||
# it is about to show is still the current one.
|
||||
computed_frame_id=frame.computed_frame_id,
|
||||
# What this frame cost to compose, carried *on the frame*: the shared
|
||||
# ``last_stats`` object is rewritten by the next task, so a reader on the
|
||||
# GUI thread would report another frame's cost.
|
||||
compose_ms=total_ms,
|
||||
)
|
||||
validate_render_frame(result, vertices=int(rest_positions.shape[0]))
|
||||
self.last_stats.total_ms = (time.perf_counter() - started) * 1000.0
|
||||
return result
|
||||
|
||||
def _rest(self, assets: SceneAssets) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""The asset's own positions and normals, computed once per asset.
|
||||
|
||||
``positions_sensor_m`` and ``normals_sensor`` are already frozen float32 arrays
|
||||
on the asset, so the cache hands them out by reference: nothing here may mutate
|
||||
them (they are read-only, which is what makes that safe).
|
||||
"""
|
||||
key = str(assets.asset_hash)
|
||||
cached = self._rest_geometry.get(key)
|
||||
if cached is None:
|
||||
part = assets.sensing_part()
|
||||
cached = (part.positions_sensor_m, part.normals_sensor)
|
||||
# One entry: the pane composes one asset at a time, and a stale cache would
|
||||
# be a wrong picture rather than a slow one.
|
||||
self._rest_geometry = {key: cached}
|
||||
return cached
|
||||
|
||||
def rest_frame(self, assets: SceneAssets, identity: FrameIdentity) -> RenderFrame:
|
||||
"""The static reference: nothing measured, so nothing coloured."""
|
||||
return build_rest_frame(assets, identity)
|
||||
|
||||
@@ -565,6 +565,9 @@ class RenderFrame:
|
||||
#: The acquisition frame the coloured values belong to, carried through from the
|
||||
#: field frame so the *display* side can re-check it before presenting.
|
||||
computed_frame_id: Optional[int] = None
|
||||
#: What composing this frame cost, measured where it was composed. Carried per frame
|
||||
#: rather than read from the composer's shared counter, which the next task rewrites.
|
||||
compose_ms: float = 0.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -588,8 +591,14 @@ class GLCapabilities:
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RenderStats:
|
||||
#: Frames that reached the screen, counted by *identity*: a repaint of the same
|
||||
#: measurement (a camera move, a colour-scale change) is not a new frame, and the
|
||||
#: panel's "shown" number was reading paint calls before this.
|
||||
displayed_frames: int = 0
|
||||
superseded_frames: int = 0
|
||||
#: How much every ``paintGL`` cost, so a slow camera move can be told from a slow
|
||||
#: pipeline. ``displayed_frames`` counts data; this counts drawing.
|
||||
paint_calls: int = 0
|
||||
compose_ms: float = 0.0
|
||||
upload_ms: float = 0.0
|
||||
paint_ms: float = 0.0
|
||||
|
||||
Reference in New Issue
Block a user