212 lines
7.4 KiB
Python
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())
|