Files
orisys-app/tools/scene_assets/probe_gl.py
T
2026-09-21 09:34:56 +08:00

224 lines
8.1 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 PySide6.QtCore import QTimer
from PySide6.QtOpenGLWidgets import QOpenGLWidget
from PySide6.QtWidgets import QApplication
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()}",
},
# The facts a platform lock is built from (`compile_scene.py
# --lock-from-probe`), named as `PlatformInfo` names them so the two cannot
# drift: changing one of these keys changes what a release can be locked to.
"host": {
"os_family": platform.system().lower(),
"machine": (platform.machine() or "unknown").lower(),
"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 PySide6.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()}",
# PySide6 enums are not ints: `int(QSurfaceFormat.OpenGLES)` raises
# TypeError, so read `.value`. (PyQt5 exposed them as ints, which is
# why this worked before the migration.)
"renderable_type": int(fmt.renderableType().value),
"profile": int(fmt.profile().value),
"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())