diff --git a/EasyReflectometryApp/Backends/Py/helpers.py b/EasyReflectometryApp/Backends/Py/helpers.py index 104ce1cf..d86236db 100644 --- a/EasyReflectometryApp/Backends/Py/helpers.py +++ b/EasyReflectometryApp/Backends/Py/helpers.py @@ -5,6 +5,10 @@ import numpy as np from PySide6.QtCore import Qt from PySide6.QtCore import QUrl +from PySide6.QtGui import QOffscreenSurface +from PySide6.QtGui import QOpenGLContext +from PySide6.QtQuick import QQuickWindow +from PySide6.QtQuick import QSGRendererInterface from PySide6.QtWidgets import QApplication from uncertainties import ufloat @@ -104,3 +108,109 @@ def __init__(self, sysArgv): # The attribute covers both QtQuick.Dialogs and QtWidgets.QFileDialog. if sys.platform.startswith('linux'): self.setAttribute(Qt.AA_DontUseNativeDialogs) + + +class Rendering: + """ + Chooses how the GUI is rendered on Linux. + + In remote-desktop sessions (VISA, xrdp, VNC, X2Go, ...) OpenGL is provided by + Mesa's software rasteriser (llvmpipe). The Qt Quick scene graph copes with + that, but Qt WebEngine (the Bayesian plots) additionally runs Chromium's GPU + thread in-process on the same software GL, with contexts shared with the + scene graph; the window then stops repainting or freezes ("not responding"). + Telling Chromium to stay off the GPU (``--disable-gpu``) is enough: it + composites in software instead, and the rest of the GUI keeps using OpenGL. + + The Qt Quick *software* scene graph backend avoids OpenGL altogether. It is + heavier (every frame is painted on the CPU, ShaderEffect is unavailable) and + is only used when explicitly requested. + """ + + ENV_VAR = 'EASYREFLECTOMETRY_SOFTWARE_RENDERING' # '1' forces the software scene graph, '0' leaves Qt alone + CHROMIUM_FLAGS_ENV_VAR = 'QTWEBENGINE_CHROMIUM_FLAGS' + CHROMIUM_DISABLE_GPU = '--disable-gpu' + QT_BACKEND_ENV_VARS = ('QT_QUICK_BACKEND', 'QSG_RHI_BACKEND') + REMOTE_SESSION_ENV_VARS = ('XRDP_SESSION', 'VNCDESKTOP', 'X2GO_SESSION') + SOFTWARE_GL_RENDERERS = ('llvmpipe', 'softpipe', 'swrast', 'software rasterizer') + GL_RENDERER = 0x1F01 + + # Return values of configure() + DEFAULT = 'default' # nothing changed + WEBENGINE_SOFTWARE = 'webengine-software' # Qt Quick on OpenGL, Chromium without GPU + SOFTWARE = 'software' # Qt Quick software scene graph, Chromium without GPU + + @staticmethod + def override(environ): + """The user's choice from the environment: True/False, or None when not set.""" + value = environ.get(Rendering.ENV_VAR, '').strip().lower() + if value in ('1', 'true', 'yes', 'on'): + return True + if value in ('0', 'false', 'no', 'off'): + return False + return None + + @staticmethod + def isSoftwareGlRenderer(rendererName: str) -> bool: + rendererName = rendererName.lower() + return any(name in rendererName for name in Rendering.SOFTWARE_GL_RENDERERS) + + @staticmethod + def openGlRendererName() -> str: + """Name of the OpenGL renderer Qt would use, or '' if it cannot be queried.""" + context = QOpenGLContext() + if not context.create(): + return '' + surface = QOffscreenSurface() + surface.setFormat(context.format()) + surface.create() + if not context.makeCurrent(surface): + return '' + try: + return context.functions().glGetString(Rendering.GL_RENDERER) or '' + finally: + context.doneCurrent() + + @staticmethod + def usesSoftwareGl(environ, platform: str) -> bool: + """True when OpenGL on this Linux session is (very likely) software rendered.""" + if not platform.startswith('linux'): + return False + if any(environ.get(name) for name in Rendering.REMOTE_SESSION_ENV_VARS): + return True + return Rendering.isSoftwareGlRenderer(Rendering.openGlRendererName()) + + @staticmethod + def disableWebEngineGpu(environ) -> None: + """ + Keep Chromium off the GPU. Chromium reads the flags when the first + WebEngine view is created, so this has to run before the QML is loaded. + """ + flags = environ.get(Rendering.CHROMIUM_FLAGS_ENV_VAR, '') + if Rendering.CHROMIUM_DISABLE_GPU in flags.split(): + return + environ[Rendering.CHROMIUM_FLAGS_ENV_VAR] = f'{flags} {Rendering.CHROMIUM_DISABLE_GPU}'.strip() + + @staticmethod + def configure(forceSoftware: bool = False, environ=None, platform: str = None) -> str: + """ + Must be called after the QApplication is created and before the first + QQuickWindow (i.e. before the QML engine loads the main component). + :return: one of DEFAULT, WEBENGINE_SOFTWARE, SOFTWARE + """ + environ = os.environ if environ is None else environ + platform = sys.platform if platform is None else platform + + override = Rendering.override(environ) + if forceSoftware or override is True: + Rendering.disableWebEngineGpu(environ) + QQuickWindow.setGraphicsApi(QSGRendererInterface.GraphicsApi.Software) + return Rendering.SOFTWARE + if override is False: + return Rendering.DEFAULT + if any(environ.get(name) for name in Rendering.QT_BACKEND_ENV_VARS): + return Rendering.DEFAULT # the user already picked a backend; leave it alone + if Rendering.usesSoftwareGl(environ, platform): + Rendering.disableWebEngineGpu(environ) + return Rendering.WEBENGINE_SOFTWARE + return Rendering.DEFAULT diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Layout.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Layout.qml index 3ebb4ff6..3dbbe844 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/Layout.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/Layout.qml @@ -32,6 +32,10 @@ EaComponents.ContentPage { onStatusChanged: if (status === Loader.Ready) console.debug(`${source} loaded`) }, Loader { + // The Bayesian views are WebEngine (Chromium) based. Only start it once + // there is a result to show; on software OpenGL (remote desktops) an idle + // Chromium GPU thread is enough to freeze the window. + active: Globals.BackendWrapper.bayesianResultAvailable source: `MainContent/BayesianPosteriorView.qml` onStatusChanged: if (status === Loader.Ready) console.debug(`${source} loaded`) } diff --git a/EasyReflectometryApp/main.py b/EasyReflectometryApp/main.py index 93562d9a..0718005a 100644 --- a/EasyReflectometryApp/main.py +++ b/EasyReflectometryApp/main.py @@ -29,11 +29,13 @@ try: # Running locally from Backends.Py import PyBackend from Backends.Py.helpers import Application + from Backends.Py.helpers import Rendering INSTALLER = False except ImportError: # Running from installer from EasyReflectometryApp.Backends.Py import PyBackend from EasyReflectometryApp.Backends.Py.helpers import Application + from EasyReflectometryApp.Backends.Py.helpers import Rendering INSTALLER = True @@ -46,6 +48,12 @@ if __name__ == '__main__': parser = argparse.ArgumentParser() parser.add_argument('-t', '--testmode', action='store_true', help='run the application in test mode') + parser.add_argument( + '--software-rendering', + action='store_true', + help='render the whole GUI without OpenGL (Qt Quick software backend). By default only the ' + 'WebEngine views are kept off the GPU when a software OpenGL renderer is detected.', + ) args = parser.parse_args() qInstallMessageHandler(console.qmlMessageHandler) @@ -57,6 +65,10 @@ app = Application(sys.argv) # Create the QApplication (Not QGuiApplication) console.debug(f'Qt Application created {app}') + # Has to happen before the QML engine creates the first window + rendering = Rendering.configure(forceSoftware=args.software_rendering) + console.debug(f'Rendering mode: {rendering}') + engine = QQmlApplicationEngine() console.debug(f'QML application engine created {engine}') diff --git a/tests/test_py_helpers.py b/tests/test_py_helpers.py index 2086bf0f..0058a98b 100644 --- a/tests/test_py_helpers.py +++ b/tests/test_py_helpers.py @@ -52,3 +52,110 @@ def test_old_precision_formatter_still_returns_three_parts(): assert value_str assert std_dev_str assert '(' in combined and ')' in combined + + +class _GraphicsApiSpy: + """Records QQuickWindow.setGraphicsApi calls instead of touching Qt.""" + + def __init__(self, monkeypatch): + self.calls = [] + monkeypatch.setattr(helpers_module.QQuickWindow, 'setGraphicsApi', staticmethod(self.calls.append)) + self.use_renderer(monkeypatch, 'NVIDIA GeForce RTX 3060/PCIe/SSE2') + + def use_renderer(self, monkeypatch, name): + monkeypatch.setattr(helpers_module.Rendering, 'openGlRendererName', staticmethod(lambda: name)) + + +def test_rendering_env_override_forces_software_scene_graph_on_any_platform(monkeypatch): + rendering = helpers_module.Rendering + spy = _GraphicsApiSpy(monkeypatch) + environ = {rendering.ENV_VAR: '1'} + + assert rendering.configure(environ=environ, platform='win32') == rendering.SOFTWARE + assert spy.calls == [helpers_module.QSGRendererInterface.GraphicsApi.Software] + assert environ[rendering.CHROMIUM_FLAGS_ENV_VAR] == rendering.CHROMIUM_DISABLE_GPU + + +def test_rendering_command_line_flag_forces_software_scene_graph(monkeypatch): + rendering = helpers_module.Rendering + spy = _GraphicsApiSpy(monkeypatch) + environ = {} + + assert rendering.configure(forceSoftware=True, environ=environ, platform='darwin') == rendering.SOFTWARE + assert len(spy.calls) == 1 + assert rendering.CHROMIUM_DISABLE_GPU in environ[rendering.CHROMIUM_FLAGS_ENV_VAR] + + +def test_rendering_env_override_off_leaves_everything_alone(monkeypatch): + rendering = helpers_module.Rendering + spy = _GraphicsApiSpy(monkeypatch) + environ = {rendering.ENV_VAR: '0', 'XRDP_SESSION': '1'} + + assert rendering.configure(environ=environ, platform='linux') == rendering.DEFAULT + assert spy.calls == [] + assert rendering.CHROMIUM_FLAGS_ENV_VAR not in environ + + +def test_rendering_not_changed_outside_linux(monkeypatch): + rendering = helpers_module.Rendering + _GraphicsApiSpy(monkeypatch) + environ = {'VNCDESKTOP': 'host:1'} + + assert rendering.configure(environ=environ, platform='darwin') == rendering.DEFAULT + assert rendering.CHROMIUM_FLAGS_ENV_VAR not in environ + + +def test_rendering_remote_linux_session_only_disables_webengine_gpu(monkeypatch): + rendering = helpers_module.Rendering + spy = _GraphicsApiSpy(monkeypatch) + + for name in rendering.REMOTE_SESSION_ENV_VARS: + environ = {name: '1'} + assert rendering.configure(environ=environ, platform='linux') == rendering.WEBENGINE_SOFTWARE + assert environ[rendering.CHROMIUM_FLAGS_ENV_VAR] == rendering.CHROMIUM_DISABLE_GPU + assert spy.calls == [] + + +def test_rendering_respects_user_selected_qt_backend(monkeypatch): + rendering = helpers_module.Rendering + _GraphicsApiSpy(monkeypatch) + environ = {'QT_QUICK_BACKEND': 'rhi', 'XRDP_SESSION': '1'} + + assert rendering.configure(environ=environ, platform='linux') == rendering.DEFAULT + assert rendering.CHROMIUM_FLAGS_ENV_VAR not in environ + + +def test_rendering_probes_renderer_when_environment_is_inconclusive(monkeypatch): + rendering = helpers_module.Rendering + spy = _GraphicsApiSpy(monkeypatch) + + spy.use_renderer(monkeypatch, 'llvmpipe (LLVM 15.0.7, 256 bits)') + environ = {} + assert rendering.configure(environ=environ, platform='linux') == rendering.WEBENGINE_SOFTWARE + assert environ[rendering.CHROMIUM_FLAGS_ENV_VAR] == rendering.CHROMIUM_DISABLE_GPU + + spy.use_renderer(monkeypatch, 'NVIDIA GeForce RTX 3060/PCIe/SSE2') + environ = {} + assert rendering.configure(environ=environ, platform='linux') == rendering.DEFAULT + assert rendering.CHROMIUM_FLAGS_ENV_VAR not in environ + assert spy.calls == [] + + +def test_rendering_is_software_gl_renderer(): + rendering = helpers_module.Rendering + + assert rendering.isSoftwareGlRenderer('llvmpipe (LLVM 15.0.7, 256 bits)') is True + assert rendering.isSoftwareGlRenderer('Mesa Software Rasterizer') is True + assert rendering.isSoftwareGlRenderer('NVIDIA GeForce RTX 3060/PCIe/SSE2') is False + assert rendering.isSoftwareGlRenderer('') is False + + +def test_rendering_keeps_existing_chromium_flags_and_does_not_duplicate(): + rendering = helpers_module.Rendering + + environ = {rendering.CHROMIUM_FLAGS_ENV_VAR: '--no-sandbox'} + rendering.disableWebEngineGpu(environ) + assert environ[rendering.CHROMIUM_FLAGS_ENV_VAR] == '--no-sandbox --disable-gpu' + + rendering.disableWebEngineGpu(environ) + assert environ[rendering.CHROMIUM_FLAGS_ENV_VAR] == '--no-sandbox --disable-gpu'