Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions EasyReflectometryApp/Backends/Py/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
4 changes: 4 additions & 0 deletions EasyReflectometryApp/Gui/Pages/Analysis/Layout.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
}
Expand Down
12 changes: 12 additions & 0 deletions EasyReflectometryApp/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Expand All @@ -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}')

Expand Down
107 changes: 107 additions & 0 deletions tests/test_py_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Loading