Files
orisys-app/tools/scene_assets/probe_gl.py
T
ykcheungab 5767b4bf11 阶段 1:平台 PoC —— 真实 GL 上跑通静态场景,并修掉它抓出的相机/上下文错误 - What: 按 docs/architecture/studio-3d-stages.md 阶段 1 落地图形栈。OrbitCamera 全部实现(target/distance/yaw/pitch 表示;fit 按包围球与较窄的那个视场轴定距离;orbit/pan/zoom 有界;set_preset 从 eye/target 反解角度使拖动可延续;matrices 输出只读 float32 view/projection;另加 depth_range_m() 便于断言)。gl_backend:GLDispatch.bind_current_context 真正绑定 PyOpenGL 并读 vendor/renderer/GL/GLSL/max texture、判软件渲染、profile 不达标时抛 ThreeDUnsupported 并带回能力;GLRenderer 实现 initialize(两个 program,desktop33/gles30 共用同一份着色器主体、只换头部)、upload_scene(每 part 一套 VAO/VBO/EBO,交错 layout position/normal/scalar/valid = 8 float)、update_surface(只对感知面 glBufferSubData,绝不重建索引)、draw(绑 Qt 的 defaultFramebufferObject、深度测试、按 role 选 program、housing 隐藏即跳过、scalar 经 LUT 纹理与 range uniform 上色,Python 不生成 RGB 数组)、release(幂等);纯函数 pack_part/pack_scene_part/estimate_gpu_bytes/rest_surface/shader_source/shader_header/lut_rgba/is_software_renderer/profile_from_renderable_type 都可在无 GPU 环境测试。Sensor3DWidget 实现 initializeGL/paintGL/resizeGL/release_resources/aboutToBeDestroyed/showEvent/hideEvent 与鼠标事件;加载场景时先采用资产自带的默认视角再 fit;一帧几何不匹配只"拒绝该帧"(计数 + 诊断 + 用 rest_surface 清掉上次测量)而不是把整个视图打成不可用。shared/scene3d/synthetic.py 提供 PoC 与测试共用的已知场景(20mm 感知板 + 外壳盒,米、sensor 坐标系、带默认三视角预设)。tools/scene_assets/probe_gl.py 真实现:真实窗口取 context 并输出 JSON 报告(平台/Qt/PyOpenGL/vendor/renderer/GL/GLSL/DPR/framebuffer/是否软件渲染),退出码即门禁(0 通过、3 软件渲染、4 无上下文);apps/studio/pyproject.toml 增加 [three-d] 可选依赖(仅 PyOpenGL;Open3D 不进运行时)。测试:apps/studio/tests/test_scene3d_stage1.py 24 项(相机数学、PoC 场景合规、打包布局、着色器两方言共用主体、LUT、软件渲染识别、renderableType 映射、GPU 预算、参考帧语义,全部无需 GPU)+ test_scene3d_gl_smoke.py 8 项(真实 context:能力、两个 part 都画出来、外壳可隐藏、参考帧中性 vs 色图有差异、动态更新不动 housing 缓冲、坏几何帧被拒且视图存活、resize 跟随、release 幂等且新 context 能重传场景、探针如实报告);offscreen 下后者自动 skip。docs/architecture/gl-report-windows-dev.json 为本机平台报告。 - Why: 用户要求"从骨架搭起→计划阶段→逐阶段实现与验收",并明确 ARM 暂缓。阶段 1 的产出本身就是一份能回答"这块板的图形栈能不能用"的报告,所以先把它做实、把 profile 冻结点定在真实 context 上,而不是先堆屏幕上的东西。 - Verification: 真实 context(QT_QPA_PLATFORM=windows)下 8 项 GL 验收全过;offscreen 下 24 项 CPU 项全过、GL 项按设计 skip;shared 全量 187 passed;studio 全量 116 passed(含新 32 项);ui_check 554 项 0 失败;probe_gl 输出 verdict=ok(NVIDIA RTX 4080 SUPER、GL 3.3 Core、GLSL 3.30、非软件渲染)。过程中这套验收抓出并修掉了 7 个真实缺陷:Qt renderableType 映射反了(NVIDIA 兼容 ES 着色器所以本机看不出,Intel/ARM 会直接失败)、观察相机是左手基导致整模型镜像(det=-1)、近平面过近使 NDC z 全挤在 0.99(深度精度归零、外壳与感知面会 z-fighting)、手动 makeCurrent 早于 Qt 首次绘制会把 GPU 对象建在被丢弃的 context 里(glBindVertexArray 报 INVALID_OPERATION)、context 重建后不重传场景、一帧几何不匹配把整个视图打死、以及 grabFramebuffer 是 BGRA+stride 导致颜色断言全部读错通道。 - Not in this change: 未接入 app_window/main.py(三维对现有应用仍完全惰性,故"开/关三维 CSV 行序一致"这条验收随阶段 4 做);阶段 2 的资产导入/坐标/包格式;ARM 平台实测(probe 已可在板上直接运行)。
2026-09-14 14:55:45 +08:00

212 lines
7.4 KiB
Python

"""Probe the real GL stack from inside a Qt window and write a diagnostic report.
Stage 1 deliverable (``docs/architecture/studio-3d-stages.md``).
It creates an **actual** Qt widget and reads the context that Qt actually
obtained — asking the system whether ``libGL`` exists proves nothing, and the
``offscreen`` platform cannot stand in for a board (measured: offscreen answers
"this plugin does not support createPlatformOpenGLContext").
Exit code is the gate: 0 only for a context that a GPU is really driving. A
software rasteriser (llvmpipe and friends) reports 3 unless ``--allow-software``
is passed, so a board cannot be signed off on one by accident.
Usage::
python tools/scene_assets/probe_gl.py --profile desktop33 --output gl-report.json
python tools/scene_assets/probe_gl.py --profile gles30 --windowed
"""
from __future__ import annotations
import argparse
import json
import os
import platform
import sys
import time
from pathlib import Path
#: Exit codes: distinct values so a CI script can tell the two failures apart.
EXIT_OK = 0
EXIT_SOFTWARE = 3
EXIT_NO_CONTEXT = 4
def _bootstrap_paths() -> None:
here = Path(__file__).resolve()
repo = here.parents[2]
for path in (repo / "apps" / "studio" / "src", repo / "shared" / "src"):
if path.is_dir() and str(path) not in sys.path:
sys.path.insert(0, str(path))
def collect(profile: str, *, keep_open_s: float) -> dict:
"""Create a real window, let Qt initialise it, and report what it got."""
from PyQt5.QtCore import QTimer
from PyQt5.QtWidgets import QApplication, QOpenGLWidget
from orisys_studio.scene3d.gl_backend import (
GLDispatch,
GLRenderer,
ThreeDUnsupported,
configure_surface_format,
lut_rgba,
)
configure_surface_format(profile) # type: ignore[arg-type]
app = QApplication.instance() or QApplication(sys.argv)
report: dict = {
"profile_requested": profile,
"platform": {
"qt_platform": app.platformName(),
"python": sys.version.split()[0],
"os": f"{platform.system()} {platform.release()}",
},
}
try:
from OpenGL import __version__ as pyopengl_version
report["platform"]["pyopengl"] = str(pyopengl_version)
except Exception: # noqa: BLE001
report["platform"]["pyopengl"] = "missing"
class Probe(QOpenGLWidget):
def __init__(self) -> None:
super().__init__()
self.renderer = GLRenderer()
self.dispatch = GLDispatch()
self.error: dict | None = None
self.ready = False
def initializeGL(self) -> None:
try:
capabilities = self.dispatch.bind_current_context(self.context())
self.renderer.initialize(capabilities)
except ThreeDUnsupported as exc:
caps = exc.capabilities
self.report_caps(caps)
self.error = {"code": exc.diagnostic.code, "message": exc.diagnostic.message}
except Exception as exc: # noqa: BLE001
self.error = {"code": "gl_unsupported", "message": f"{type(exc).__name__}: {exc}"}
else:
self.report_caps(capabilities)
self.ready = True
def report_caps(self, capabilities) -> None:
from PyQt5.QtGui import QSurfaceFormat
fmt: QSurfaceFormat = self.context().format() if self.context() else QSurfaceFormat()
ratio = float(self.devicePixelRatioF() or 1.0)
report["context"] = {
"valid": bool(self.context().isValid()) if self.context() else False,
"vendor": capabilities.vendor,
"renderer": capabilities.renderer,
"gl_version": capabilities.gl_version,
"glsl_version": capabilities.glsl_version,
"profile_used": capabilities.profile,
"software_renderer": capabilities.software_renderer,
"max_texture_size": capabilities.max_texture_size,
"qt_format": {
"version": f"{fmt.majorVersion()}.{fmt.minorVersion()}",
"renderable_type": int(fmt.renderableType()),
"profile": int(fmt.profile()),
"depth_buffer": int(fmt.depthBufferSize()),
"samples": int(fmt.samples()),
},
"device_pixel_ratio": ratio,
"framebuffer_px": [
int(round(self.width() * ratio)),
int(round(self.height() * ratio)),
],
"diagnostics": [
{"code": item.code, "severity": item.severity, "message": item.message}
for item in capabilities.diagnostics
],
}
report["lut_rgba_bytes"] = int(lut_rgba("sequential").nbytes)
report["renderer_initialized"] = bool(self.renderer.is_initialized)
widget = Probe()
widget.resize(320, 240)
if keep_open_s >= 0:
widget.show()
deadline = time.perf_counter() + max(keep_open_s, 0.3)
while time.perf_counter() < deadline and not widget.ready:
app.processEvents()
time.sleep(0.01)
app.processEvents()
if widget.error is not None:
report["error"] = widget.error
try:
widget.makeCurrent()
widget.renderer.release()
widget.doneCurrent()
except Exception as exc: # noqa: BLE001
report["release_error"] = f"{type(exc).__name__}: {exc}"
report["released"] = True
widget.deleteLater()
app.processEvents()
QTimer.singleShot(0, app.quit)
app.processEvents()
return report
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument(
"--profile",
choices=("desktop33", "gles30"),
default="desktop33",
help="the start-up profile to probe (one per process)",
)
parser.add_argument(
"--output",
type=Path,
default=None,
help="write the diagnostic report here as JSON",
)
parser.add_argument(
"--windowed",
action="store_true",
help="keep a visible window instead of a short-lived one",
)
parser.add_argument(
"--allow-software",
action="store_true",
help="treat a software rasteriser as a pass (for a development box only)",
)
args = parser.parse_args(argv)
_bootstrap_paths()
report = collect(args.profile, keep_open_s=0.6 if args.windowed else 0.3)
context = report.get("context")
if not context or not context.get("valid"):
report["verdict"] = "no_context"
code = EXIT_NO_CONTEXT
elif context.get("software_renderer") and not args.allow_software:
report["verdict"] = "software_renderer"
code = EXIT_SOFTWARE
elif not report.get("renderer_initialized"):
report["verdict"] = "shader_failed"
code = EXIT_NO_CONTEXT
else:
report["verdict"] = "ok"
code = EXIT_OK
text = json.dumps(report, indent=2, ensure_ascii=False)
if args.output is not None:
args.output.write_text(text + "\n", encoding="utf-8")
print(text)
print(
f"\nverdict={report['verdict']} profile={args.profile} "
f"renderer={(context or {}).get('renderer', '?')}",
file=sys.stderr,
)
return code
if __name__ == "__main__":
sys.exit(main())