diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 90ee2fb87..94551ac34 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -300,6 +300,12 @@ jobs:
- name: Build
run: cmake --build $BUILD_DIR --target install
+ - name: Verify WebGPU app artifacts
+ run: |
+ test -f "$INSTALL_DIR/webgpu_app.html"
+ test -f "$INSTALL_DIR/webgpu_app.js"
+ test -f "$INSTALL_DIR/webgpu_app.wasm"
+
- name: Create artifact
uses: actions/upload-artifact@v5
with:
@@ -360,13 +366,14 @@ jobs:
- name: Fix headers for wasm multithread
run: |
- cd $GITHUB_WORKSPACE/github_page/wasm_mt_lto
- wget https://raw.githubusercontent.com/gzuidhof/coi-serviceworker/master/coi-serviceworker.min.js
- sed -i -e 's#
##g' alpineapp.html
-
- cd $GITHUB_WORKSPACE/github_page/wasm_mt
- wget https://raw.githubusercontent.com/gzuidhof/coi-serviceworker/master/coi-serviceworker.min.js
- sed -i -e 's###g' alpineapp.html
+ for config in wasm_mt wasm_mt_debug wasm_mt_lto; do
+ cd "$GITHUB_WORKSPACE/github_page/$config"
+ wget -q https://raw.githubusercontent.com/gzuidhof/coi-serviceworker/master/coi-serviceworker.min.js
+ for html in alpineapp.html webgpu_app.html; do
+ test -f "$html"
+ sed -i -e 's###g' "$html"
+ done
+ done
- name: Generate Directory Listings
uses: jayanta525/github-pages-directory-listing@v4.0.0
diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml
index 47c991cb6..da5fe3e59 100644
--- a/.github/workflows/linux.yml
+++ b/.github/workflows/linux.yml
@@ -44,7 +44,7 @@ jobs:
- name: Install Linux Dependencies
run: |
sudo apt-get update
- sudo apt-get install -y build-essential ninja-build lld libgl1-mesa-dev libxcb-cursor-dev xorg-dev libxrandr-dev libxcursor-dev libudev-dev libopenal-dev libflac-dev libvorbis-dev libgl1-mesa-dev libegl1-mesa-dev libdrm-dev libgbm-dev xvfb libxcb-cursor0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-render-util0
+ sudo apt-get install -y build-essential ninja-build lld libgl1-mesa-dev libxcb-cursor-dev xorg-dev libx11-xcb-dev libxrandr-dev libxcursor-dev libudev-dev libopenal-dev libflac-dev libvorbis-dev libgl1-mesa-dev libegl1-mesa-dev libdrm-dev libgbm-dev xvfb libxcb-cursor0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-render-util0
- name: Install Clang 17
if: matrix.compiler == 'clang17'
@@ -86,8 +86,8 @@ jobs:
CXX: ${{ matrix.CXX }}
CMAKE_PREFIX_PATH: ${{env.QT_ROOT_DIR}}/lib/cmake
run: >
- cmake -G Ninja
- -DCMAKE_BUILD_TYPE=${{matrix.BUILD_TYPE}}
+ cmake -G Ninja
+ -DCMAKE_BUILD_TYPE=${{matrix.BUILD_TYPE}}
-DALP_ENABLE_ASSERTS=ON
-DALP_ENABLE_ADDRESS_SANITIZER=ON
-DALP_ENABLE_APP_SHUTDOWN_AFTER_60S=ON
@@ -115,7 +115,7 @@ jobs:
QT_QPA_PLATFORM: offscreen
DISPLAY: :1
LD_PRELOAD: ./libdlclose.so
- LSAN_OPTIONS: suppressions=./sanitizer_supressions/linux_leak.supp
+ LSAN_OPTIONS: suppressions=./misc/sanitizer_suppressions/linux_leak.supp
ASAN_OPTIONS: verify_asan_link_order=0
# QSG_RENDER_LOOP: basic
run: |
diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml
index 3201690a2..8ac3421c1 100644
--- a/.github/workflows/windows.yml
+++ b/.github/workflows/windows.yml
@@ -54,6 +54,7 @@ jobs:
install-deps: 'true'
modules: ${{ env.QT_MODULES }}
cache: true
+ aqtsource: 'git+https://github.com/miurahr/aqtinstall.git@bbfb1f7c0590b9eb3fa91356e75bb64fb15d3643'
- name: Configure
env:
diff --git a/.gitignore b/.gitignore
index 9db9daf94..0195b17cd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,12 @@
/CMakeLists.txt.user
/extern/*
-/doc/*
-/doc
/build/*
+/install/*
+.vs
+.vscode
+.qtcreator
+__pycache__
/.qtcreator/CMakeLists.txt.user
**/.qmlls.ini
+/docs/project_reports
+**/__pycache__/*.pyc
diff --git a/CMakeLists.txt b/CMakeLists.txt
index d3dcef8b9..1656ab854 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -1,6 +1,8 @@
#############################################################################
# Alpine Terrain Renderer
# Copyright (C) 2023 Adam Celarek
+# Copyright (C) 2024 Gerald Kimmersdorfer
+# Copyright (C) 2024 Patrick Komon
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@@ -17,9 +19,21 @@
#############################################################################
cmake_minimum_required(VERSION 3.25)
-project(alpine-renderer LANGUAGES CXX)
+project(alpine-renderer LANGUAGES C CXX)
option(ALP_UNITTESTS "include unit test targets in the buildsystem" ON)
+option(ALP_GL_ENGINE "include the gl engine in the buildsystem" OFF)
+set(ALP_WEBGPU_DEFAULT OFF)
+if (EMSCRIPTEN OR (UNIX AND NOT APPLE AND NOT ANDROID))
+ set(ALP_WEBGPU_DEFAULT ON)
+endif()
+option(ALP_WEBGPU_ENGINE "include the webgpu engine in the buildsystem" ${ALP_WEBGPU_DEFAULT})
+option(ALP_WEBGPU_APP "include the webgpu app in the buildsystem" ${ALP_WEBGPU_DEFAULT})
+option(ALP_PLAIN_RENDERER "include the plain renderer in the buildsystem" OFF)
+option(ALP_QML_APP "include the qml app in the buildsystem" OFF)
+
+option(ALP_WEBGPU_APP_ENABLE_COMPUTE "Build the webgpu_compute graph into the app" ON)
+
option(ALP_ENABLE_ADDRESS_SANITIZER "compiles atb with address sanitizer enabled (only debug, works only on g++ and clang)" OFF)
option(ALP_ENABLE_THREAD_SANITIZER "compiles atb with thread sanitizer enabled (only debug, works only on g++ and clang)" OFF)
option(ALP_ENABLE_ASSERTS "enable asserts (do not define NDEBUG)" ON)
@@ -41,14 +55,14 @@ if(ALP_ENABLE_TRACK_OBJECT_LIFECYCLE)
add_definitions(-DALP_ENABLE_TRACK_OBJECT_LIFECYCLE)
endif()
-if (EMSCRIPTEN)
- set(ALP_WWW_INSTALL_DIR "${CMAKE_CURRENT_BINARY_DIR}" CACHE PATH "path to the install directory (for webassembly files, i.e., www directory)")
- option(ALP_ENABLE_THREADING "Puts the scheduler into an extra thread." OFF)
- option(ALP_ENABLE_DEV_TOOLS "HotReload, Renderstats, .. (increases binary size)" OFF)
- option(ALP_ENABLE_POSITIONING "enable qt positioning (gnss / gps)" ON)
-elseif(ANDROID)
- option(ALP_ENABLE_THREADING "Puts the scheduler into an extra thread." ON)
- option(ALP_ENABLE_DEV_TOOLS "HotReload, Renderstats, .. (increases binary size)" OFF)
+if (EMSCRIPTEN)
+ set(ALP_WWW_INSTALL_DIR "${CMAKE_CURRENT_BINARY_DIR}" CACHE PATH "path to the install directory (for webassembly files, i.e., www directory)")
+ option(ALP_ENABLE_THREADING "Puts the scheduler into an extra thread." OFF)
+ option(ALP_ENABLE_DEV_TOOLS "HotReload, Renderstats, .. (increases binary size)" OFF)
+ option(ALP_ENABLE_POSITIONING "enable qt positioning (gnss / gps)" ON)
+elseif(ANDROID)
+ option(ALP_ENABLE_THREADING "Puts the scheduler into an extra thread." ON)
+ option(ALP_ENABLE_DEV_TOOLS "HotReload, Renderstats, .. (increases binary size)" OFF)
option(ALP_ENABLE_POSITIONING "enable qt positioning (gnss / gps)" ON)
else()
option(ALP_ENABLE_THREADING "Puts the scheduler into an extra thread." ON)
@@ -88,13 +102,18 @@ if (ALP_USE_LLVM_LINKER)
string(APPEND CMAKE_EXE_LINKER_FLAGS " -fuse-ld=lld")
endif()
+# Disable Qt debug output in release builds
+if(CMAKE_BUILD_TYPE STREQUAL "Release")
+ add_compile_definitions(QT_NO_DEBUG_OUTPUT)
+endif()
+
########################################### dependencies #################################################
find_package(Qt6 REQUIRED COMPONENTS Core Gui OpenGL Network Quick QuickControls2 LinguistTools)
-qt_standard_project_setup(REQUIRES 6.8)
+qt_standard_project_setup(REQUIRES 6.7)
alp_add_git_repository(renderer_static_data URL https://github.com/AlpineMapsOrg/renderer_static_data.git COMMITISH v23.11 DO_NOT_ADD_SUBPROJECT)
alp_add_git_repository(alpineapp_fonts URL https://github.com/AlpineMapsOrg/fonts.git COMMITISH v24.02 DO_NOT_ADD_SUBPROJECT)
-alp_add_git_repository(doc URL https://github.com/AlpineMapsOrg/documentation.git COMMITISH origin/main DO_NOT_ADD_SUBPROJECT DESTINATION_PATH doc)
+alp_add_git_repository(doc URL https://github.com/AlpineMapsOrg/documentation.git COMMITISH origin/main DO_NOT_ADD_SUBPROJECT DESTINATION_PATH docs/project_reports)
if (ANDROID)
@@ -115,6 +134,29 @@ if (ALP_ENABLE_GL_ENGINE)
add_subdirectory(app)
endif()
-if (ALP_UNITTESTS)
- add_subdirectory(unittests)
+if (ALP_GL_ENGINE)
+ add_subdirectory(gl_engine)
endif()
+if (ALP_PLAIN_RENDERER)
+ add_subdirectory(plain_renderer)
+endif()
+if (ALP_QML_APP)
+ add_subdirectory(app)
+endif()
+if (ALP_WEBGPU_APP OR ALP_WEBGPU_ENGINE)
+ add_subdirectory(webgpu/base)
+ if (ALP_WEBGPU_ENGINE)
+ add_subdirectory(webgpu/engine)
+ endif()
+ if (ALP_WEBGPU_APP)
+ if (ALP_WEBGPU_APP_ENABLE_COMPUTE)
+ add_subdirectory(webgpu/compute)
+ endif()
+ add_subdirectory(apps/webgpu_app)
+ endif()
+
+endif()
+
+if (ALP_UNITTESTS)
+ add_subdirectory(unittests)
+endif()
diff --git a/CMakePresets.json b/CMakePresets.json
new file mode 100644
index 000000000..a62545f98
--- /dev/null
+++ b/CMakePresets.json
@@ -0,0 +1,162 @@
+{
+ "version": 6,
+ "cmakeMinimumRequired": {
+ "major": 3,
+ "minor": 25,
+ "patch": 0
+ },
+ "configurePresets": [
+ {
+ "name": "alp-base",
+ "hidden": true,
+ "cacheVariables": {
+ "ALP_ENABLE_GL_ENGINE": "OFF",
+ "ALP_QML_APP": "OFF",
+ "ALP_ENABLE_LABELS": "OFF",
+ "ALP_UNITTESTS": "OFF",
+ "ALP_WEBGPU_ENGINE": "ON",
+ "ALP_WEBGPU_APP": "ON",
+ "ALP_ENABLE_THREADING": "ON",
+ "ALP_ENABLE_DEV_TOOLS": "ON"
+ }
+ },
+ {
+ "name": "msvc-base",
+ "hidden": true,
+ "inherits": "alp-base",
+ "generator": "Ninja",
+ "binaryDir": "${sourceDir}/build/${presetName}",
+ "installDir": "${sourceDir}/install/${presetName}",
+ "architecture": {
+ "value": "x64",
+ "strategy": "external"
+ },
+ "toolset": {
+ "value": "host=x64",
+ "strategy": "external"
+ },
+ "cacheVariables": {
+ "CMAKE_C_COMPILER": "cl.exe",
+ "CMAKE_CXX_COMPILER": "cl.exe",
+ "Qt6_DIR": "C:/Qt/6.10.1/msvc2022_64/lib/cmake/Qt6"
+ },
+ "condition": {
+ "type": "equals",
+ "lhs": "${hostSystemName}",
+ "rhs": "Windows"
+ }
+ },
+ {
+ "name": "msvc-debug",
+ "displayName": "MSVC Debug (Windows)",
+ "description": "Debug build using MSVC compiler",
+ "inherits": "msvc-base",
+ "cacheVariables": {
+ "CMAKE_BUILD_TYPE": "Debug",
+ "CMAKE_CXX_FLAGS": "/DQT_DEBUG"
+ }
+ },
+ {
+ "name": "msvc-release",
+ "displayName": "MSVC Release (Windows)",
+ "description": "Release build using MSVC compiler",
+ "inherits": "msvc-base",
+ "cacheVariables": {
+ "CMAKE_BUILD_TYPE": "Release"
+ }
+ },
+ {
+ "name": "msvc-debug-test",
+ "displayName": "MSVC Debug Tests (Windows)",
+ "description": "Debug build for unit tests using MSVC compiler",
+ "inherits": "msvc-debug",
+ "cacheVariables": {
+ "ALP_UNITTESTS": "ON",
+ "ALP_WEBGPU_APP": "OFF"
+ }
+ },
+ {
+ "name": "msvc-release-test",
+ "displayName": "MSVC Release Tests (Windows)",
+ "description": "Release build for unit tests using MSVC compiler",
+ "inherits": "msvc-release",
+ "cacheVariables": {
+ "ALP_UNITTESTS": "ON",
+ "ALP_WEBGPU_APP": "OFF"
+ }
+ },
+ {
+ "name": "emscripten-base",
+ "hidden": true,
+ "inherits": "alp-base",
+ "generator": "Ninja",
+ "binaryDir": "${sourceDir}/build/${presetName}",
+ "installDir": "${sourceDir}/install/${presetName}",
+ "toolchainFile": "C:/Qt/6.10.1/wasm_multithread/lib/cmake/Qt6/qt.toolchain.cmake",
+ "environment": {
+ "PATH": "C:/Qt/Tools/Ninja;$penv{PATH}",
+ "EMSDK": "C:/tmp/alpinemaps/emsdk"
+ }
+ },
+ {
+ "name": "wasm-debug",
+ "displayName": "WebAssembly Debug",
+ "description": "Debug build for WebAssembly using Emscripten",
+ "inherits": "emscripten-base",
+ "cacheVariables": {
+ "CMAKE_BUILD_TYPE": "Debug",
+ "CMAKE_CXX_FLAGS": "-DQT_DEBUG"
+ }
+ },
+ {
+ "name": "wasm-release",
+ "displayName": "WebAssembly Release",
+ "description": "Release build for WebAssembly using Emscripten",
+ "inherits": "emscripten-base",
+ "cacheVariables": {
+ "CMAKE_BUILD_TYPE": "Release"
+ }
+ },
+ {
+ "name": "wasm-publish",
+ "displayName": "WebAssembly Publish",
+ "description": "Production build for WebAssembly using Emscripten",
+ "inherits": "emscripten-base",
+ "cacheVariables": {
+ "CMAKE_BUILD_TYPE": "Release",
+ "CMAKE_CXX_FLAGS": "-DQT_NO_DEBUG_OUTPUT",
+ "ALP_ENABLE_WGSL_MINIFICATION": "ON"
+ }
+ }
+ ],
+ "buildPresets": [
+ {
+ "name": "msvc-debug",
+ "configurePreset": "msvc-debug"
+ },
+ {
+ "name": "msvc-release",
+ "configurePreset": "msvc-release"
+ },
+ {
+ "name": "msvc-debug-test",
+ "configurePreset": "msvc-debug-test"
+ },
+ {
+ "name": "msvc-release-test",
+ "configurePreset": "msvc-release-test"
+ },
+ {
+ "name": "wasm-debug",
+ "configurePreset": "wasm-debug"
+ },
+ {
+ "name": "wasm-release",
+ "configurePreset": "wasm-release"
+ },
+ {
+ "name": "wasm-publish",
+ "configurePreset": "wasm-publish"
+ }
+ ]
+}
diff --git a/README.md b/README.md
index ad0ab309a..9ac1b4011 100644
--- a/README.md
+++ b/README.md
@@ -1,57 +1,67 @@
-# AlpineMaps.org Renderer
-This is the software behind [alpinemaps.org](https://alpinemaps.org).
+#
AlpineMaps.org
-A developer version (trunk) is released [here](https://alpinemapsorg.github.io/renderer/), including APKs for android. Be aware that it can break at any time!
+ [](https://alpinemaps.org) [](https://webigeo.alpinemaps.org/) [](https://discord.gg/p8T9XzVwRa)
+
+This is a mono-repository containing the [AlpineMaps.org](https://alpinemaps.org) and [weBIGeo](https://webigeo.alpinemaps.org/) projects alongside their shared dependencies. Both are under active development and aim to provide state-of-the-art real-time rendering and processing for large-scale, tile-based geodata.
+
+###
[AlpineMaps.org (`app`)](docs/app.md)
+Qt Quick / OpenGL frontend, the original alpinemaps.org client.
+
+###
[weBIGeo (`webgpu_app`)](docs/webgpu_app.md)
+WebGPU rendering engine with ImGui UI and GPU compute graph.
[If looking at the issues, best to filter out projects!](https://github.com/AlpineMapsOrg/renderer/issues?q=is%3Aissue%20state%3Aopen%20no%3Aproject)
We are in discord, talk to us!
https://discord.gg/p8T9XzVwRa
-# Cloning and building
-`git clone git@github.com:AlpineMapsOrg/renderer.git`
-
-After that it should be a normal cmake project. That is, you run cmake to generate a project or build file and then run your favourite tool. All dependencies should be pulled automatically while you run CMake.
-We use Qt Creator (with mingw on Windows), which is the only tested setup atm and makes setup of Android and WebAssembly builds reasonably easy. If you have questions, please go to Discord.
-
-## Dependencies
-* Qt 6.11.1, or greater
-* g++ 12+, clang or msvc
-* OpenGL
-* Qt Positioning and Charts modules
-* Some other dependencies will be pulled automatically during building.
-
-## Building the native version
-* just run cmake and build
-
-## Building the android version
-* We are usually building with Qt Creator, because it works relatively out of the box. However, it should also work on the command line or other IDEs if you set it up correctly.
-* You need a Java JDK before you can do anything else. Not all Java versions work, and the error messages might be surprising (or non-existant). I'm running with Java 19, and I can compile for old devices. Iirc a newer version of Java caused issues. [Android documents the required Java version](https://developer.android.com/build/jdks), but as said, for me Java 19 works as well. It might change in the future.
-* Once you have Java, go to Qt Creator Preferences -> Devices -> Android. There click "Set Up SDK" to automatically download and install an Android SDK.
-* Finally, you might need to click on SDK Manager to install a fitting SDK Platform (take the newest, it also works for older devices), and ndk (newest as well).
-* Then Google the internet to find out how to enable the developer mode on Android.
-* On linux, you'll have to setup some udev rules. Run `Android/SDK/platform-tools/adb devices` and you should get instructions.
-* If there are problems, check out the [documentation from Qt](https://doc.qt.io/qt-6/android-getting-started.html)
-* Finally, you are welcome to ask in discord if something is not working!
-
-## Building the WebAssembly version:
-* [The Qt documentation is quite good on how to get it to run](https://doc-snapshots.qt.io/qt6-dev/wasm.html#installing-emscripten).
-* Be aware that only specific versions of emscripten work for specific versions of Qt, and the error messages are not helpfull.
-* [More info on building and getting Hotreload to work](https://github.com/AlpineMapsOrg/documentation/blob/main/WebAssembly_local_build.md)
-
-# Code style
+
+## Architecture
+
+```mermaid
+graph TD
+ app["AlpineMaps.org [app]"]
+ webgpu_app["weBIGeo [webgpu_app]"]
+ gl_engine(["gl_engine"])
+ webgpu_compute(["webgpu_compute"])
+ webgpu(["webgpu"])
+ nucleus(["nucleus"])
+ webgpu_engine(["webgpu_engine"])
+
+ app ---> gl_engine
+ webgpu_app --> webgpu_engine
+ webgpu_app --> webgpu_compute
+ gl_engine --> nucleus
+ webgpu_engine --> webgpu
+ webgpu_compute --> webgpu
+ webgpu --> nucleus
+
+
+```
+
+*...only top-level dependencies are shown.*
+
+| Module | Description |
+|--------|-------------|
+| `nucleus` | Shared core: tile management, camera, data structures |
+| [`webgpu`](docs/webgpu_base.md) | RAII WebGPU wrappers (device, pipelines, buffers), WGSL preprocessor and GPU resource registry |
+| `gl_engine` | OpenGL rendering engine (used by the QML app) |
+| [`webgpu_engine`](docs/webgpu_engine.md) | WebGPU rendering engine (used by the webgpu_app) |
+| `webgpu_compute` | CPU/GPU compute node graph library |
+
+## Code style
* class names are CamelCase, method, function and variable names are snake_case.
* class attributes have an m_ prefix and are usually private, struct attributes don't and are usually public.
-* use `void set_attribute(int value)` and `int attribute() const` for setters and getters (that is, avoid the get_). Use [the Qt recommendations](https://wiki.qt.io/API_Design_Principles#Naming_Boolean_Getters,_Setters,_and_Properties) for naming boolean getters.
+* "use `void set_attribute(int value)` and `int attribute() const` for setters and getters (that is, avoid the get_)." Use Qt recommendations for naming boolean getters.
* structs are usually small, simple, and have no or only few methods. they never have inheritance.
* files are CamelCase if the content is a CamelCase class. otherwise they are snake_case, and have a snake_case namespace with stuff.
* the folder/structure.h is reflected in namespace folder::structure{ .. }
* indent with space only, indent 4 spaces
* ideally, use the clang-format file provided with the project
(in case you use Qt Creator, go to Preferences -> C++ -> Code Style: Formatting mode: Full, Format while typing, Format edited code on file save, don't override formatting)
-* follow the [Qt recommendations](https://wiki.qt.io/API_Design_Principles) and the [c++ core guidelines](https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines) everywhere else.
+* follow the Qt recommendations and the C++ core guidelines everywhere else.
-# Developer workflow
+## Developer workflow
* Fork this repository.
* Enable github pages from actions (Repository Settings -> Pages -> Source -> GitHub Actions)
* Work in branches or your main.
@@ -59,4 +69,4 @@ We use Qt Creator (with mingw on Windows), which is the only tested setup atm an
* Github Actions will run the unit tests and create packages for the browser and Android and deploy them to your_username.github.io/your_clone_name/.
* Make sure that the unit tests run through.
* We will also look at the browser version during the pull request.
-* Ideally you'll also setup the signing keys for Android packages ([instructions](https://github.com/AlpineMapsOrg/renderer/blob/main/creating_apk_keys.md)).
+* Ideally you'll also setup the signing keys for Android packages.
diff --git a/app/TerrainRendererItem.cpp b/app/TerrainRendererItem.cpp
index fa369b991..68c861ceb 100644
--- a/app/TerrainRendererItem.cpp
+++ b/app/TerrainRendererItem.cpp
@@ -164,6 +164,7 @@ QQuickFramebufferObject::Renderer* TerrainRendererItem::createRenderer() const
return r;
}
+
void TerrainRendererItem::touchEvent(QTouchEvent* e)
{
this->setFocus(true);
diff --git a/apps/webgpu_app/App.cpp b/apps/webgpu_app/App.cpp
new file mode 100644
index 000000000..e1a19a291
--- /dev/null
+++ b/apps/webgpu_app/App.cpp
@@ -0,0 +1,658 @@
+/*****************************************************************************
+ * weBIGeo
+ * Copyright (C) 2024 Patrick Komon
+ * Copyright (C) 2024 Gerald Kimmersdorfer
+ * Copyright (C) 2026 Wendelin Muth
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *****************************************************************************/
+
+#include "App.h"
+
+#include "webgpu/engine/Window.h"
+#include
+#include
+#include
+#include //TODO maybe only for threading enabled?
+#include
+
+#ifdef __EMSCRIPTEN__
+#include "util/WebInterop.h"
+#include
+#else
+#include "nucleus/utils/image_loader.h"
+#if defined(_WIN32) || defined(_WIN64)
+#pragma comment(lib, "dwmapi.lib")
+#endif
+#endif
+
+#include "util/dark_mode.h"
+
+#include "imgui.h"
+#include "util/error_logging.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace webgpu_app {
+
+App::App()
+{
+#ifdef __EMSCRIPTEN__
+ // execute on window resize when canvas size changes
+ QObject::connect(&WebInterop::instance(), &WebInterop::body_size_changed, this, &App::set_window_size);
+
+ m_surface_presentmode = WGPUPresentMode_Fifo; // chrome does not want other present modes
+#endif
+}
+
+void App::init_window()
+{
+ // Initializes SDL2 video subsystem
+ SDL_SetMainReady();
+ if (SDL_InitSubSystem(SDL_INIT_VIDEO) != 0) {
+ qFatal("Could not initialize SDL2 video subsystem! SDL_Error: %s", SDL_GetError());
+ }
+
+#ifdef __EMSCRIPTEN__
+ // Fetch size of the webpage
+ m_viewport_size = WebInterop::instance().get_body_size();
+#endif
+ m_sdl_window = SDL_CreateWindow("weBIGeo - Geospatial Visualization Tool", // Window title
+ SDL_WINDOWPOS_CENTERED, // Window position x
+ SDL_WINDOWPOS_CENTERED, // Window position y
+ m_viewport_size.x, // Window width
+ m_viewport_size.y, // Window height
+#ifdef __EMSCRIPTEN__
+ SDL_WINDOW_RESIZABLE);
+#else
+ SDL_WINDOW_RESIZABLE | SDL_WINDOW_MAXIMIZED);
+#endif
+
+ if (!m_sdl_window) {
+ SDL_Quit();
+ qFatal("Could not create SDL window! SDL_Error: %s", SDL_GetError());
+ }
+
+ util::enable_darkmode_on_windows(m_sdl_window);
+
+#ifndef __EMSCRIPTEN__
+ // Load icon using the existing image loader
+ auto icon = nucleus::utils::image_loader::rgba8(":/icons/logo32.png").value();
+ // Create SDL_Surface from the raw image data
+ SDL_Surface* iconSurface = SDL_CreateRGBSurfaceFrom((void*)icon.bytes(), // Pixel data
+ icon.width(), // Image width
+ icon.height(), // Image height
+ 32, // Bits per pixel (RGBA = 32 bits)
+ icon.width() * 4, // Pitch (width * 4 bytes per pixel)
+ 0x000000ff, // Red mask
+ 0x0000ff00, // Green mask
+ 0x00ff0000, // Blue mask
+ 0xff000000 // Alpha mask
+ );
+
+ if (iconSurface) {
+ SDL_SetWindowIcon(m_sdl_window, iconSurface); // Set the window icon
+ SDL_FreeSurface(iconSurface); // Free the surface after setting the icon
+ } else {
+ qWarning("Could not create SDL surface for window icon. SDL_Error: %s", SDL_GetError());
+ }
+#endif
+}
+
+void App::render_gui()
+{
+ static bool vsync_enabled = (m_surface_presentmode == WGPUPresentMode::WGPUPresentMode_Fifo);
+ if (ImGui::Checkbox("VSync", &vsync_enabled)) {
+ m_surface_presentmode = vsync_enabled ? WGPUPresentMode::WGPUPresentMode_Fifo : WGPUPresentMode::WGPUPresentMode_Immediate;
+ // Reconfigure surface
+ m_force_repaint_once = true;
+ this->on_window_resize(m_viewport_size.x, m_viewport_size.y);
+ }
+ ImGui::Checkbox("Repaint each frame", &m_force_repaint);
+ ImGui::Text("Repaint-Counter: %d", m_repaint_count);
+
+ if (ImGui::Button("Reload shaders [F5]", ImVec2(350, 0))) {
+ m_webgpu_window->reload_shaders();
+ }
+}
+
+void App::poll_events()
+{
+ // TODO hack, makes animations work
+ m_camera_controller->advance_camera();
+
+ // NOTE: The following line is not strictly necessary, we discovered that SDL somehow
+ // triggers the processing of qt events. On the web we assume that Qt attaches itself to
+ // the emscripten event loop.
+ QCoreApplication::processEvents();
+ // Poll SDL events and handle them.
+ // (contrary to GLFW, close event is not automatically managed, and there
+ // is no callback mechanism by default.)
+ static SDL_Event events[15]; // Only allocate memory once (11 is the max events at once i witnessed)
+ bool events_contain_touch = false;
+ int event_count = 0;
+ static SDL_Event event;
+ while (SDL_PollEvent(&event)) {
+ m_gui_manager->on_sdl_event(event);
+ if (event.type == SDL_QUIT) {
+ m_window_open = false;
+ } else if (event.type == SDL_WINDOWEVENT) {
+ if (event.window.event == SDL_WINDOWEVENT_RESIZED) {
+ on_window_resize(event.window.data1, event.window.data2);
+ }
+ } else {
+ events[event_count++] = event;
+ if (event.type == SDL_FINGERDOWN || event.type == SDL_FINGERUP || event.type == SDL_FINGERMOTION) {
+ events_contain_touch = true;
+ }
+ }
+ }
+
+ // IMPORTANT: SDL seems to emulate touch events as mouse events aswell. In order to avoid this
+ // we need to filter out the mouse events if there are touch events. Meaning we priortize touch over mouse.
+ for (int i = 0; i < event_count; i++) {
+ if (events_contain_touch && (events[i].type == SDL_MOUSEMOTION || events[i].type == SDL_MOUSEBUTTONDOWN || events[i].type == SDL_MOUSEBUTTONUP)) {
+ continue;
+ }
+ m_input_mapper->on_sdl_event(events[i]);
+ }
+
+ wgpuInstanceProcessEvents(m_instance);
+}
+
+void App::render()
+{
+ // Do nothing, this checks for ongoing asynchronous operations and call their callbacks
+
+ WGPUSurfaceTexture surface_texture;
+ wgpuSurfaceGetCurrentTexture(m_surface, &surface_texture);
+
+ m_cputimer->start();
+
+ if (surface_texture.status != WGPUSurfaceGetCurrentTextureStatus_SuccessOptimal
+ && surface_texture.status != WGPUSurfaceGetCurrentTextureStatus_SuccessSuboptimal) {
+ // skip frame (?)
+ qDebug() << "Could not get current surface texture: surface_texture.status=" << surface_texture.status;
+ return;
+ }
+
+ WGPUTextureViewDescriptor viewDescriptor {};
+ viewDescriptor.nextInChain = nullptr;
+ viewDescriptor.label = WGPUStringView { .data = "Surface texture view", .length = WGPU_STRLEN };
+ viewDescriptor.format = wgpuTextureGetFormat(surface_texture.texture);
+ viewDescriptor.dimension = WGPUTextureViewDimension_2D;
+ viewDescriptor.baseMipLevel = 0;
+ viewDescriptor.mipLevelCount = 1;
+ viewDescriptor.baseArrayLayer = 0;
+ viewDescriptor.arrayLayerCount = 1;
+ viewDescriptor.aspect = WGPUTextureAspect_All;
+ WGPUTextureView surface_texture_view = wgpuTextureCreateView(surface_texture.texture, &viewDescriptor);
+
+ if (!surface_texture_view) {
+ qFatal("Cannot acquire next surface texture");
+ }
+
+ WGPUCommandEncoderDescriptor command_encoder_desc {};
+ command_encoder_desc.label = WGPUStringView { .data = "Command Encoder", .length = WGPU_STRLEN };
+ WGPUCommandEncoder encoder = wgpuDeviceCreateCommandEncoder(m_device, &command_encoder_desc);
+
+ if (webgpu::isTimingSupported())
+ m_gputimer->start(encoder);
+
+ m_frame_count++;
+ if (m_webgpu_window->needs_redraw() || m_force_repaint || m_force_repaint_once) {
+ m_webgpu_window->paint(m_framebuffer.get(), encoder);
+ m_repaint_count++;
+ m_force_repaint_once = false;
+ }
+
+ {
+ webgpu::raii::RenderPassEncoder render_pass(encoder, surface_texture_view, nullptr);
+ wgpuRenderPassEncoderSetPipeline(render_pass.handle(), m_gui_pipeline.get()->pipeline().handle());
+ wgpuRenderPassEncoderSetBindGroup(render_pass.handle(), 0, m_gui_bind_group->handle(), 0, nullptr);
+ wgpuRenderPassEncoderDraw(render_pass.handle(), 3, 1, 0, 0);
+
+ // We add the GUI drawing commands to the render pass
+ m_gui_manager->render(render_pass.handle());
+ }
+
+ if (webgpu::isTimingSupported())
+ m_gputimer->stop(encoder);
+
+ wgpuTextureViewRelease(surface_texture_view);
+
+ WGPUCommandBufferDescriptor cmd_buffer_descriptor {};
+ cmd_buffer_descriptor.label = WGPUStringView { .data = "Command buffer", .length = WGPU_STRLEN };
+ WGPUCommandBuffer command = wgpuCommandEncoderFinish(encoder, &cmd_buffer_descriptor);
+ wgpuCommandEncoderRelease(encoder);
+ wgpuQueueSubmit(m_queue, 1, &command);
+ wgpuCommandBufferRelease(command);
+
+ if (webgpu::isTimingSupported())
+ m_gputimer->resolve();
+
+ m_cputimer->stop();
+
+#ifndef __EMSCRIPTEN__
+ // Surface present in the WEB is handled by the browser!
+ wgpuSurfacePresent(m_surface);
+ wgpuDeviceTick(m_device);
+#endif
+}
+
+void App::start()
+{
+ init_window();
+
+ webgpu_create_context();
+
+ m_context = std::make_unique();
+ configure_surface(m_viewport_size.x, m_viewport_size.y);
+ m_webgpu_ctx.set_surface_texture_format(m_surface_texture_format);
+ m_context->initialize(m_webgpu_ctx);
+
+ m_camera_controller = std::make_unique(
+ nucleus::camera::PositionStorage::instance()->get("grossglockner"), m_webgpu_window.get(), m_context->data_querier());
+
+ // clang-format off
+ // NOTICE ME!!!! READ THIS, IF YOU HAVE TROUBLES WITH SIGNALS NOT REACHING THE QML RENDERING THREAD!!!!111elevenone
+ // In Qt/QML the rendering thread goes to sleep (at least until Qt 6.5, See RenderThreadNotifier).
+ // At the time of writing, an additional connection from tile_ready and tile_expired to the notifier is made.
+ // this only works if ALP_ENABLE_THREADING is on, i.e., the tile scheduler is on an extra thread. -> potential issue on webassembly
+ connect(m_camera_controller.get(), &nucleus::camera::Controller::definition_changed, m_context->geometry_scheduler(), &nucleus::tile::Scheduler::update_camera);
+ connect(m_camera_controller.get(), &nucleus::camera::Controller::definition_changed, m_context->ortho_scheduler(), &nucleus::tile::Scheduler::update_camera);
+ connect(m_camera_controller.get(), &nucleus::camera::Controller::definition_changed, m_context->cloud_scheduler(), &nucleus::tile::Scheduler::update_camera);
+ connect(m_camera_controller.get(), &nucleus::camera::Controller::definition_changed, m_webgpu_window.get(), &webgpu_engine::Window::update_camera);
+
+ connect(m_context->geometry_scheduler(), &nucleus::tile::GeometryScheduler::gpu_tiles_updated, m_webgpu_window.get(), &webgpu_engine::Window::update_requested);
+ connect(m_context->ortho_scheduler(), &nucleus::tile::TextureScheduler::gpu_tiles_updated, m_webgpu_window.get(), &webgpu_engine::Window::update_requested);
+ connect(m_context->cloud_scheduler(), &nucleus::tile::Texture3DScheduler::gpu_tiles_updated, m_webgpu_window.get(), &webgpu_engine::Window::update_requested);
+ connect(m_context->clouds_manager(), &clouds::Manager::shadow_texture_ready, m_webgpu_window.get(), &webgpu_engine::Window::on_shadow_texture_updated);
+ // clang-format on
+
+ m_gui_manager = std::make_unique(this);
+
+ m_input_mapper = std::make_unique(this, m_camera_controller.get(), m_gui_manager.get(), [this]() { return m_viewport_size; });
+
+ // TODO connect this (is used from ImGuiManager to update camera when settings are changed)
+ // connect(this, &App::update_camera_requested, camera_controller, &nucleus::camera::Controller::update_camera_request);
+ connect(m_webgpu_window.get(),
+ &webgpu_engine::Window::set_camera_definition_requested,
+ m_camera_controller.get(),
+ &nucleus::camera::Controller::set_model_matrix);
+
+ connect(m_webgpu_window.get(), &nucleus::AbstractRenderWindow::update_requested, this, &App::schedule_update);
+ connect(m_input_mapper.get(), &InputMapper::key_pressed, this, &App::handle_shortcuts);
+
+ m_webgpu_window->set_context(m_context->engine_context());
+ m_webgpu_window->initialise_gpu();
+
+ // Configures surface
+ this->on_window_resize(m_viewport_size.x, m_viewport_size.y);
+
+ { // load first camera definition without changing preset in nucleus
+ auto new_definition = nucleus::camera::stored_positions::grossglockner();
+ new_definition.set_viewport_size(m_viewport_size);
+ m_camera_controller->set_model_matrix(new_definition);
+ }
+
+ qDebug() << "Create GUI Pipeline...";
+ m_gui_ubo = std::make_unique>(m_device, WGPUBufferUsage_Uniform | WGPUBufferUsage_CopyDst, 1, "gui ubo");
+ m_gui_ubo->write(m_queue, &m_gui_ubo_data);
+
+ webgpu::FramebufferFormat format {};
+ format.color_formats.emplace_back(m_surface_texture_format);
+
+ WGPUBindGroupLayoutEntry backbuffer_texture_entry {};
+ backbuffer_texture_entry.binding = 0;
+ backbuffer_texture_entry.visibility = WGPUShaderStage_Fragment;
+ backbuffer_texture_entry.texture.sampleType = WGPUTextureSampleType_Float;
+ backbuffer_texture_entry.texture.viewDimension = WGPUTextureViewDimension_2D;
+
+ WGPUBindGroupLayoutEntry gui_ubo_entry = {};
+ gui_ubo_entry.binding = 1;
+ gui_ubo_entry.visibility = WGPUShaderStage_Fragment;
+ gui_ubo_entry.buffer.type = WGPUBufferBindingType_Uniform;
+ gui_ubo_entry.buffer.minBindingSize = sizeof(App::GuiPipelineUBO);
+
+ m_gui_bind_group_layout = std::make_unique(
+ m_device, std::vector { backbuffer_texture_entry, gui_ubo_entry }, "gui bind group layout");
+
+ const char preprocessed_code[] = R"(
+ @group(0) @binding(0) var backbuffer_texture : texture_2d;
+ @group(0) @binding(1) var gui_ubo : vec2f;
+
+ struct VertexOut {
+ @builtin(position) position : vec4f,
+ @location(0) texcoords : vec2f
+ }
+
+ @vertex
+ fn vertexMain(@builtin(vertex_index) vertex_index : u32) -> VertexOut {
+ const VERTICES = array(vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0));
+ var vertex_out : VertexOut;
+ vertex_out.position = vec4(VERTICES[vertex_index], 0.0, 1.0);
+ vertex_out.texcoords = vec2(0.5, -0.5) * vertex_out.position.xy + vec2(0.5);
+ return vertex_out;
+ }
+
+ @fragment
+ fn fragmentMain(vertex_out : VertexOut) -> @location(0) vec4f {
+ let tci : vec2 = vec2u(vertex_out.texcoords * gui_ubo);
+ var backbuffer_color = textureLoad(backbuffer_texture, tci, 0);
+ return backbuffer_color;
+ }
+ )";
+
+ WGPUShaderSourceWGSL wgsl_desc {};
+ wgsl_desc.chain.next = nullptr;
+ wgsl_desc.chain.sType = WGPUSType_ShaderSourceWGSL;
+ wgsl_desc.code = WGPUStringView {
+ .data = preprocessed_code,
+ .length = WGPU_STRLEN,
+ };
+ WGPUShaderModuleDescriptor shader_module_desc {};
+ shader_module_desc.label = WGPUStringView { .data = "Gui Shader Module", .length = WGPU_STRLEN };
+ shader_module_desc.nextInChain = &wgsl_desc.chain;
+ auto shader_module = std::make_unique(m_device, shader_module_desc);
+
+ m_gui_pipeline = std::make_unique(m_device,
+ *shader_module,
+ *shader_module,
+ std::vector {},
+ format,
+ std::vector { m_gui_bind_group_layout.get() });
+
+ m_gui_bind_group = std::make_unique(m_device,
+ *m_gui_bind_group_layout.get(),
+ std::initializer_list { m_framebuffer->color_texture_view(0).create_bind_group_entry(0), m_gui_ubo->create_bind_group_entry(1) });
+
+ m_timer_manager = std::make_unique();
+ m_gui_manager->init(m_sdl_window, m_device, m_surface_texture_format, WGPUTextureFormat_Undefined);
+
+ m_cputimer = std::make_shared(120);
+ m_timer_manager->add_timer(m_cputimer, "CPU Timer", "Renderer");
+ if (webgpu::isTimingSupported()) {
+ m_gputimer = std::make_shared(m_device, 3, 120);
+ m_timer_manager->add_timer(m_gputimer, "GPU Timer", "Renderer");
+ }
+
+ this->on_window_resize(m_viewport_size.x, m_viewport_size.y);
+ m_initialized = true;
+
+ m_webgpu_window->ready();
+
+ m_gui_manager->ready();
+ // IMPORTANT: Don't delete: its a hook for the shell
+ qInfo() << "webgpu_app ready";
+
+#if defined(__EMSCRIPTEN__)
+ emscripten_set_main_loop_arg(
+ [](void* userData) {
+ App& renderer = *reinterpret_cast(userData);
+ renderer.poll_events();
+ renderer.render();
+ },
+ (void*)this,
+ 0,
+ true);
+#else
+ while (m_window_open) {
+ poll_events();
+ render();
+ }
+#endif
+
+ // NOTE: Ressources are freed by the browser when the page is closed. Also keep in mind
+ // that this part of code will be executed immediately since the main loop is not blocking.
+#ifndef __EMSCRIPTEN__
+ m_gui_manager->shutdown();
+ webgpu_release_context();
+ m_webgpu_window->destroy();
+ m_context->destroy();
+
+ SDL_DestroyWindow(m_sdl_window);
+ SDL_Quit();
+ m_initialized = false;
+#endif
+}
+
+void App::set_window_size(glm::uvec2 size)
+{
+ if (m_viewport_size == size)
+ return;
+ m_viewport_size = size;
+ if (m_initialized) {
+ SDL_SetWindowSize(m_sdl_window, size.x, size.y);
+ on_window_resize(size.x, size.y);
+ }
+}
+
+void App::handle_shortcuts(QKeyCombination key)
+{
+ if (key.key() == Qt::Key_F5) {
+ m_webgpu_window->reload_shaders();
+ } else if (key.key() == Qt::Key_H) {
+ m_gui_manager->set_gui_visibility(!m_gui_manager->get_gui_visibility());
+ }
+}
+
+void App::schedule_update() { m_force_repaint_once = true; }
+
+void App::create_framebuffer(uint32_t width, uint32_t height)
+{
+ qDebug() << "creating framebuffer textures for size " << width << "x" << height;
+
+ webgpu::FramebufferFormat format { .size = { width, height }, .depth_format = m_depth_texture_format, .color_formats = { m_surface_texture_format } };
+ m_framebuffer = std::make_unique(m_device, format);
+
+ if (m_gui_bind_group) {
+ m_gui_bind_group = std::make_unique(m_device,
+ *m_gui_bind_group_layout.get(),
+ std::initializer_list {
+ m_framebuffer->color_texture_view(0).create_bind_group_entry(0), m_gui_ubo->create_bind_group_entry(1) });
+ }
+
+ if (m_gui_ubo) {
+ m_gui_ubo_data.resolution = glm::vec2(m_viewport_size);
+ m_gui_ubo->write(m_queue, &m_gui_ubo_data);
+ }
+}
+
+void App::configure_surface(uint32_t width, uint32_t height)
+{
+ qDebug() << "configuring surface...";
+
+ // from Learn WebGPU C++ tutorial
+
+ WGPUSurfaceCapabilities surface_capabilities {};
+ wgpuSurfaceGetCapabilities(m_surface, m_adapter, &surface_capabilities);
+ if (surface_capabilities.formatCount < 1) {
+ qFatal() << "WebGPU surface formatCount is 0 - must support at least one format";
+ }
+
+ m_surface_texture_format = surface_capabilities.formats[0];
+ WGPUSurfaceConfiguration config = {};
+ config.nextInChain = nullptr;
+ config.width = width;
+ config.height = height;
+ config.format = m_surface_texture_format;
+ config.viewFormatCount = 0;
+ config.viewFormats = nullptr;
+ config.usage = WGPUTextureUsage_RenderAttachment;
+ config.device = m_device;
+ config.presentMode = m_surface_presentmode;
+ config.alphaMode = WGPUCompositeAlphaMode_Auto;
+
+ qInfo() << "trying to configure surface with size " << width << "x" << height << "alpha mode=" << config.alphaMode
+ << ", present mode=" << m_surface_presentmode;
+ wgpuSurfaceConfigure(m_surface, &config);
+ qInfo() << "configured surface with size " << width << "x" << height << ", present mode=" << m_surface_presentmode;
+}
+
+void App::update_camera() { emit update_camera_requested(); }
+
+void App::on_window_resize(int width, int height)
+{
+ m_viewport_size = { width, height };
+
+ configure_surface(width, height);
+ create_framebuffer(width, height);
+
+ m_webgpu_window->resize_framebuffer(m_viewport_size.x, m_viewport_size.y);
+ m_camera_controller->set_viewport(m_viewport_size);
+}
+
+void App::webgpu_create_context()
+{
+ qDebug() << "Creating WebGPU instance...";
+ m_instance_desc = {};
+ m_instance_desc.nextInChain = nullptr;
+
+#ifndef __EMSCRIPTEN__
+ WGPUDawnTogglesDescriptor dawnToggles;
+ dawnToggles.chain.next = nullptr;
+ dawnToggles.chain.sType = WGPUSType_DawnTogglesDescriptor;
+
+ std::vector enabledToggles = { "allow_unsafe_apis" };
+#if defined(QT_DEBUG)
+ // TODO: Figure out why this doesnt work
+ enabledToggles.push_back("use_user_defined_labels_in_backend");
+ enabledToggles.push_back("enable_vulkan_validation");
+ enabledToggles.push_back("disable_symbol_renaming");
+#endif
+
+ QStringList toggleList;
+ for (const auto& toggle : enabledToggles)
+ toggleList << QString::fromStdString(toggle);
+ qDebug() << "Dawn toggles:" << toggleList.join(", ");
+
+ dawnToggles.enabledToggles = enabledToggles.data();
+ dawnToggles.enabledToggleCount = enabledToggles.size();
+ dawnToggles.disabledToggleCount = 0;
+#endif
+
+ const auto timed_wait_feature = WGPUInstanceFeatureName_TimedWaitAny;
+ m_instance_desc.requiredFeatureCount = 1;
+ m_instance_desc.requiredFeatures = &timed_wait_feature;
+
+ m_instance = wgpuCreateInstance(&m_instance_desc);
+
+ if (!m_instance) {
+ qFatal("Could not initialize WebGPU Instance!");
+ }
+ qInfo() << "Got instance: " << m_instance;
+
+ qDebug() << "Requesting surface...";
+ m_surface = SDL_GetWGPUSurface(m_instance, m_sdl_window);
+ if (!m_surface) {
+ qFatal("Could not create surface!");
+ }
+ qInfo() << "Got surface: " << m_surface;
+
+ qDebug() << "Requesting adapter...";
+ WGPURequestAdapterOptions adapter_opts {};
+ adapter_opts.powerPreference = WGPUPowerPreference_HighPerformance;
+ adapter_opts.compatibleSurface = m_surface;
+ m_adapter = webgpu::requestAdapterSync(m_instance, adapter_opts);
+ if (!m_adapter) {
+ qFatal("Could not get adapter!");
+ }
+ qInfo() << "Got adapter: " << m_adapter;
+
+ m_webgpu_window = std::make_unique();
+
+ qDebug() << "Requesting device...";
+ WGPULimits required_limits {};
+ WGPULimits supported_limits {};
+ wgpuAdapterGetLimits(m_adapter, &supported_limits);
+
+ // irrelevant for us, but needs to be set
+ required_limits.minStorageBufferOffsetAlignment = supported_limits.minStorageBufferOffsetAlignment;
+ required_limits.minUniformBufferOffsetAlignment = supported_limits.minUniformBufferOffsetAlignment;
+ required_limits.maxInterStageShaderVariables = WGPU_LIMIT_U32_UNDEFINED; // required for current version of Chrome Canary (2025-04-03)
+ constexpr uint64_t desired_max_buffer_size = 2 * 1073741824ull; // 2 GiB
+ if (supported_limits.maxBufferSize < desired_max_buffer_size)
+ qWarning() << "Adapter maxBufferSize" << supported_limits.maxBufferSize << "is below the desired" << desired_max_buffer_size
+ << ". cloud rendering might fail.";
+ required_limits.maxBufferSize = std::min(supported_limits.maxBufferSize, desired_max_buffer_size);
+
+ // Let the engine change the required limits
+ m_webgpu_window->update_required_gpu_limits(required_limits, supported_limits);
+
+ std::vector requiredFeatures;
+ requiredFeatures.push_back(WGPUFeatureName_TimestampQuery);
+ requiredFeatures.push_back(WGPUFeatureName_TextureCompressionBC);
+ requiredFeatures.push_back(WGPUFeatureName_TextureCompressionBCSliced3D);
+
+ WGPUDeviceDescriptor device_desc {};
+ device_desc.label = WGPUStringView { .data = "webigeo device", .length = WGPU_STRLEN };
+ device_desc.requiredFeatures = requiredFeatures.data();
+ device_desc.requiredFeatureCount = (uint32_t)requiredFeatures.size();
+ device_desc.requiredLimits = &required_limits;
+ device_desc.defaultQueue.label = WGPUStringView { .data = "webigeo queue", .length = WGPU_STRLEN };
+ device_desc.uncapturedErrorCallbackInfo = WGPUUncapturedErrorCallbackInfo {
+ .nextInChain = nullptr,
+ .callback = webgpu_device_error_callback,
+ .userdata1 = nullptr,
+ .userdata2 = nullptr,
+ };
+ device_desc.deviceLostCallbackInfo = WGPUDeviceLostCallbackInfo {
+ .nextInChain = nullptr,
+ .mode = WGPUCallbackMode_AllowProcessEvents,
+ .callback = webgpu_device_lost_callback,
+ .userdata1 = nullptr,
+ .userdata2 = nullptr,
+ };
+
+#ifndef __EMSCRIPTEN__
+ device_desc.nextInChain = &dawnToggles.chain;
+#endif
+
+ m_device = webgpu::requestDeviceSync(m_instance, m_adapter, device_desc);
+ if (!m_device) {
+ qFatal("Could not get device!");
+ }
+ qInfo() << "Got device: " << m_device;
+
+ webgpu::checkForTimingSupport(m_adapter, m_device);
+
+ qDebug() << "Requesting queue...";
+ m_queue = wgpuDeviceGetQueue(m_device);
+ if (!m_queue) {
+ qFatal("Could not get queue!");
+ }
+ qInfo() << "Got queue: " << m_queue;
+
+ m_webgpu_ctx.init(m_instance, m_device, m_adapter, m_surface, m_queue);
+}
+
+void App::webgpu_release_context()
+{
+ qDebug() << "Releasing WebGPU context...";
+ wgpuSurfaceUnconfigure(m_surface);
+ wgpuQueueRelease(m_queue);
+ wgpuSurfaceRelease(m_surface);
+ wgpuDeviceRelease(m_device);
+ wgpuAdapterRelease(m_adapter);
+ wgpuInstanceRelease(m_instance);
+}
+
+} // namespace webgpu_app
diff --git a/apps/webgpu_app/App.h b/apps/webgpu_app/App.h
new file mode 100644
index 000000000..07b80de5e
--- /dev/null
+++ b/apps/webgpu_app/App.h
@@ -0,0 +1,135 @@
+/*****************************************************************************
+ * weBIGeo
+ * Copyright (C) 2024 Patrick Komon
+ * Copyright (C) 2024 Gerald Kimmersdorfer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *****************************************************************************/
+
+#pragma once
+
+#include "ImGuiManager.h"
+#include "util/InputMapper.h"
+#include
+#include
+
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+
+#include "webgpu/engine/Context.h"
+#include "webgpu/engine/Window.h"
+
+#include "RenderingContext.h"
+
+namespace webgpu_app {
+
+class App : public QObject {
+ Q_OBJECT
+
+public:
+ App();
+ ~App() = default;
+
+ struct GuiPipelineUBO {
+ glm::vec2 resolution;
+ };
+
+ void init_window();
+ void start();
+ void poll_events();
+ void render();
+ void render_gui();
+ void on_window_resize(int width, int height);
+ void update_camera();
+
+ [[nodiscard]] InputMapper* get_input_mapper() { return m_input_mapper.get(); }
+ [[nodiscard]] ImGuiManager* get_gui_manager() { return m_gui_manager.get(); }
+ [[nodiscard]] webgpu::timing::GuiTimerManager* get_timer_manager() { return m_timer_manager.get(); }
+ [[nodiscard]] webgpu_engine::Window* get_webgpu_window() { return m_webgpu_window.get(); }
+ [[nodiscard]] RenderingContext* get_rendering_context() { return m_context.get(); }
+ [[nodiscard]] nucleus::camera::Controller* get_camera_controller() { return m_camera_controller.get(); }
+
+signals:
+ void update_camera_requested();
+
+private slots:
+ void set_window_size(glm::uvec2 size);
+ void handle_shortcuts(QKeyCombination key);
+ void schedule_update();
+
+private:
+ SDL_Window* m_sdl_window;
+ std::unique_ptr m_webgpu_window;
+ std::unique_ptr m_camera_controller;
+ std::unique_ptr m_context;
+
+ std::unique_ptr m_input_mapper;
+ std::unique_ptr m_gui_manager;
+ std::unique_ptr m_timer_manager;
+
+ WGPUInstanceDescriptor m_instance_desc;
+
+ webgpu::Context m_webgpu_ctx;
+
+ WGPUInstance m_instance = nullptr;
+ WGPUSurface m_surface = nullptr;
+ WGPUAdapter m_adapter = nullptr;
+ WGPUDevice m_device = nullptr;
+ WGPUQueue m_queue = nullptr;
+ WGPUTextureFormat m_surface_texture_format = WGPUTextureFormat::WGPUTextureFormat_Undefined; // Will be replaced at swapchain creation
+ WGPUTextureFormat m_depth_texture_format = WGPUTextureFormat::WGPUTextureFormat_Depth24Plus;
+
+ glm::uvec2 m_viewport_size = glm::uvec2(1280u, 1024u);
+ bool m_initialized = false;
+ GuiPipelineUBO m_gui_ubo_data = { glm::vec2(1280.0, 1024.0) };
+
+ std::unique_ptr m_framebuffer;
+ void create_framebuffer(uint32_t width, uint32_t height);
+ void configure_surface(uint32_t width, uint32_t height);
+
+ std::unique_ptr m_gui_pipeline;
+ std::unique_ptr m_gui_bind_group_layout;
+ std::unique_ptr m_gui_bind_group;
+ std::unique_ptr> m_gui_ubo;
+
+ WGPUQuerySetDescriptor m_timestamp_query_desc;
+ WGPUQuerySet m_timestamp_queries;
+ WGPUPassTimestampWrites m_timestamp_writes;
+ std::unique_ptr> m_timestamp_resolve;
+ std::unique_ptr> m_timestamp_result;
+
+ std::shared_ptr m_gputimer;
+ std::shared_ptr m_cputimer;
+
+ bool m_force_repaint = false;
+ bool m_force_repaint_once = false;
+ uint32_t m_repaint_count = 0;
+ uint32_t m_frame_count = 0;
+ WGPUPresentMode m_surface_presentmode = WGPUPresentMode_Fifo; // WGPUPresentMode_Immediate;
+
+ // Flag to exit the rendering loop
+ bool m_window_open = true;
+
+ void webgpu_create_context();
+ void webgpu_release_context();
+};
+
+} // namespace webgpu_app
diff --git a/apps/webgpu_app/CMakeLists.txt b/apps/webgpu_app/CMakeLists.txt
new file mode 100644
index 000000000..acd62046a
--- /dev/null
+++ b/apps/webgpu_app/CMakeLists.txt
@@ -0,0 +1,236 @@
+#############################################################################
+# weBIGeo
+# Copyright (C) 2024 Adam Celarek
+# Copyright (C) 2024 Gerald Kimmersdorfer
+# Copyright (C) 2024 Patrick Komon
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+#############################################################################
+
+project(alpine-renderer-webgpu_app LANGUAGES C CXX)
+
+
+set(SOURCES
+ main.cpp
+ App.h App.cpp
+ RenderingContext.h RenderingContext.cpp
+ ImGuiManager.h ImGuiManager.cpp
+
+ util/error_logging.h util/error_logging.cpp
+ util/dark_mode.h util/dark_mode.cpp
+ util/InputMapper.h util/InputMapper.cpp
+ util/SearchService.h util/SearchService.cpp
+
+ cloud/CloudsManager.h cloud/CloudsManager.cpp
+
+ ui/ImGuiPanel.h
+ ui/TimingPanel.h ui/TimingPanel.cpp
+ ui/CameraPanel.h ui/CameraPanel.cpp
+ ui/AppPanel.h ui/AppPanel.cpp
+ ui/ShadingPanel.h ui/ShadingPanel.cpp
+ ui/AboutPanel.h ui/AboutPanel.cpp
+ ui/CompassPanel.h ui/CompassPanel.cpp
+ ui/LogoPanel.h ui/LogoPanel.cpp
+ ui/SearchPanel.h ui/SearchPanel.cpp
+
+ atmosphere/AtmospherePanel.h atmosphere/AtmospherePanel.cpp
+ cloud/CloudPanel.h cloud/CloudPanel.cpp
+ track/TrackPanel.h track/TrackPanel.cpp
+
+ overlay/OverlaysPanel.h overlay/OverlaysPanel.cpp
+ overlay/OverlayImGuiRenderer.h overlay/OverlayImGuiRenderer.cpp
+ overlay/OverlayImGuiRendererFactory.h overlay/OverlayImGuiRendererFactory.cpp
+ overlay/HeightLinesOverlayImGuiRenderer.h overlay/HeightLinesOverlayImGuiRenderer.cpp
+ overlay/ScreenSpaceSnowOverlayImGuiRenderer.h overlay/ScreenSpaceSnowOverlayImGuiRenderer.cpp
+ overlay/TextureOverlayImGuiRenderer.h overlay/TextureOverlayImGuiRenderer.cpp
+ overlay/TileDebugOverlayImGuiRenderer.h overlay/TileDebugOverlayImGuiRenderer.cpp
+)
+
+if (ALP_WEBGPU_APP_ENABLE_COMPUTE)
+ list(APPEND SOURCES
+ compute/OverlayRenderNode.h compute/OverlayRenderNode.cpp
+ compute/NodeGraphPanel.h compute/NodeGraphPanel.cpp
+ compute/nodes/NodeRenderer.h compute/nodes/NodeRenderer.cpp
+ compute/nodes/NodeRendererFactory.h compute/nodes/NodeRendererFactory.cpp
+ compute/nodes/ExportNodeRenderer.h compute/nodes/ExportNodeRenderer.cpp
+ compute/nodes/OverlayNodeRenderer.h compute/nodes/OverlayNodeRenderer.cpp
+ compute/nodes/BufferToTextureNodeRenderer.h compute/nodes/BufferToTextureNodeRenderer.cpp
+ compute/nodes/ComputeAvalancheTrajectoriesNodeRenderer.h compute/nodes/ComputeAvalancheTrajectoriesNodeRenderer.cpp
+ compute/nodes/ComputeReleasePointsNodeRenderer.h compute/nodes/ComputeReleasePointsNodeRenderer.cpp
+ compute/nodes/ComputeSnowNodeRenderer.h compute/nodes/ComputeSnowNodeRenderer.cpp
+ compute/nodes/RequestTilesNodeRenderer.h compute/nodes/RequestTilesNodeRenderer.cpp
+ compute/nodes/SelectTilesNodeRenderer.h compute/nodes/SelectTilesNodeRenderer.cpp
+ compute/nodes/GPXTrackNodeRenderer.h compute/nodes/GPXTrackNodeRenderer.cpp
+ )
+endif()
+
+if (EMSCRIPTEN)
+ list(APPEND SOURCES util/WebInterop.h util/WebInterop.cpp)
+endif()
+
+qt_add_executable(webgpu_app
+ resources.qrc
+ ${SOURCES}
+)
+
+
+set_target_properties(webgpu_app PROPERTIES
+ WIN32_EXECUTABLE FALSE # Set to FALSE to keep console attached on Windows
+ MACOSX_BUNDLE TRUE
+)
+
+target_link_libraries(webgpu_app PUBLIC webgpu webgpu_engine Qt::Core Qt::Network)
+target_include_directories(webgpu_app PRIVATE .)
+
+if (ALP_WEBGPU_APP_ENABLE_COMPUTE)
+ target_link_libraries(webgpu_app PUBLIC webgpu_compute)
+ target_compile_definitions(webgpu_app PRIVATE ALP_WEBGPU_APP_ENABLE_COMPUTE)
+endif()
+
+# For Font Awesome Icon Headers:
+ alp_add_git_repository(iconfontcppheaders URL https://github.com/juliettef/IconFontCppHeaders.git COMMITISH f30b1e73b2d71eb331d77619c3f1de34199afc38 DO_NOT_ADD_SUBPROJECT)
+ target_include_directories(webgpu_app PRIVATE ${CMAKE_SOURCE_DIR}/extern/iconfontcppheaders)
+
+ alp_add_git_repository(imgui URL https://github.com/AlpineMapsOrgDependencies/imgui_slim.git COMMITISH 4d8ecbf58ebf32e757ff56a60f1fb0446033edb8)
+
+ set(IMNODES_IMGUI_TARGET_NAME imgui)
+ set(BUILD_SHARED_LIBS_SAVED ${BUILD_SHARED_LIBS})
+ set(BUILD_SHARED_LIBS OFF)
+ alp_add_git_repository(imnodes URL https://github.com/AlpineMapsOrgDependencies/imnodes_slim.git COMMITISH 324355e64ed8b5bea02fe1439cdcb5c7773b4436)
+ set(BUILD_SHARED_LIBS ${BUILD_SHARED_LIBS_SAVED})
+
+ target_link_libraries(webgpu_app PRIVATE imgui imnodes)
+
+if (NOT EMSCRIPTEN)
+ alp_add_git_repository(ImGuiFileDialog URL https://github.com/AlpineMapsOrgDependencies/ImGuiFileDialog_slim.git COMMITISH 39f98eb131ee00d476f1002336fc7e9e1795ccc5)
+ target_link_libraries(ImGuiFileDialog PRIVATE imgui)
+ target_link_libraries(webgpu_app PRIVATE ImGuiFileDialog)
+endif()
+
+# Copy necessary DLLs to the output directory on Windows
+if (WIN32 AND NOT EMSCRIPTEN)
+ include("${CMAKE_SOURCE_DIR}/cmake/alp_provide_dawn_dxc.cmake")
+
+ # Copy SDL2 DLL
+ if (EXISTS "${CMAKE_SOURCE_DIR}/${ALP_EXTERN_DIR}/sdl/bin/SDL2.dll")
+ add_custom_command(TARGET webgpu_app POST_BUILD
+ COMMAND ${CMAKE_COMMAND} -E copy_if_different
+ "${CMAKE_SOURCE_DIR}/${ALP_EXTERN_DIR}/sdl/bin/SDL2.dll"
+ "$"
+ COMMENT "Copying SDL2.dll"
+ )
+ endif()
+
+ alp_provide_dawn_dxc_dlls(ALP_DAWN_DXC_DLLS)
+ foreach(ALP_DAWN_DXC_DLL IN LISTS ALP_DAWN_DXC_DLLS)
+ get_filename_component(ALP_DAWN_DXC_DLL_NAME "${ALP_DAWN_DXC_DLL}" NAME)
+ add_custom_command(TARGET webgpu_app POST_BUILD
+ COMMAND ${CMAKE_COMMAND} -E copy_if_different
+ "${ALP_DAWN_DXC_DLL}"
+ "$"
+ COMMENT "Copying ${ALP_DAWN_DXC_DLL_NAME}"
+ )
+ endforeach()
+
+ # Use Qt's windeployqt to copy all necessary Qt dependencies
+ get_target_property(QMAKE_EXECUTABLE Qt6::qmake IMPORTED_LOCATION)
+ get_filename_component(QT_BIN_DIR "${QMAKE_EXECUTABLE}" DIRECTORY)
+ find_program(WINDEPLOYQT_EXECUTABLE windeployqt HINTS "${QT_BIN_DIR}")
+
+ if(WINDEPLOYQT_EXECUTABLE)
+ add_custom_command(TARGET webgpu_app POST_BUILD
+ COMMAND "${WINDEPLOYQT_EXECUTABLE}" --no-translations "$"
+ COMMENT "Running windeployqt for webgpu_app"
+ )
+ endif()
+
+
+ # === INSTALL CONFIGURATION FOR NATIVE WINDOWS BUILD ===
+ install(TARGETS webgpu_app RUNTIME DESTINATION . BUNDLE DESTINATION .)
+ if (EXISTS "${CMAKE_SOURCE_DIR}/${ALP_EXTERN_DIR}/sdl/bin/SDL2.dll")
+ install(FILES "${CMAKE_SOURCE_DIR}/${ALP_EXTERN_DIR}/sdl/bin/SDL2.dll" DESTINATION .)
+ endif()
+ install(FILES ${ALP_DAWN_DXC_DLLS} DESTINATION .)
+ if(WINDEPLOYQT_EXECUTABLE)
+ install(CODE "
+ execute_process(
+ COMMAND \"${WINDEPLOYQT_EXECUTABLE}\" --no-translations \"\${CMAKE_INSTALL_PREFIX}/webgpu_app.exe\"
+ )
+ ")
+ endif()
+ # ================================================
+endif()
+
+if (EMSCRIPTEN)
+
+ target_link_options(webgpu_app PRIVATE
+ --use-port=sdl2 # Use emscripten SDL2 implementation
+ #--preload-file ${CMAKE_CURRENT_SOURCE_DIR}/resources # DONT USE RESOURCES PLS VIA QT RESOURCE SYSTEM
+ #--shell-file "${CMAKE_CURRENT_SOURCE_DIR}/shell/${SHELL_FILE}" # DOESNT WORK AS QT WILL OVERWRITE IT
+ -msimd128 #enables auto-vectorization and simd support
+ -lembind #enable embind
+ #-gsource-map #generate source maps
+
+ # FOLLOWING IS SET BY Qt6.10
+ #-s EXPORTED_RUNTIME_METHODS=UTF16ToString,stringToUTF16,JSEvents,specialHTMLTargets,FS,callMain
+ #-s EXPORTED_FUNCTIONS=_main,__embind_initialize_bindings
+ #-s PTHREAD_POOL_SIZE=4
+ #-s INITIAL_MEMORY=50MB
+ #-s MAXIMUM_MEMORY=4GB
+ #-s MAX_WEBGL_VERSION=2
+ #-s WASM_BIGINT=1
+ #-s STACK_SIZE=5MB
+ #-pthread
+ #-s ALLOW_MEMORY_GROWTH
+ #--profiling-funcs
+ #-sERROR_ON_UNDEFINED_SYMBOLS=1
+ #-sFETCH
+ #-sASYNCIFY=1
+ )
+
+ # enables to append to EXPORTED_RUNTIME_METHODS which is overridden by Qt
+ # see issue (https://bugreports.qt.io/browse/QTBUG-104882) and fix (https://codereview.qt-project.org/c/qt/qtbase/+/421733)
+ set_target_properties(webgpu_app PROPERTIES QT_WASM_EXTRA_EXPORTED_METHODS "ccall,cwrap")
+
+ # Copy custom shell (name of file == name of target)
+ # IMPORTANT: THIS WILL ONLY BE EXECUTED WHEN THE TARGET IS ACTUALLY REBUILT
+ # SO THERE HAVE TO BE CHANGES IN THE C++ CODE.
+ file(GLOB_RECURSE SHELL_FILES "${CMAKE_CURRENT_SOURCE_DIR}/shell/*")
+ message(STATUS "SHELL_FILES: ${SHELL_FILES}")
+ # now create a custom command to move all those files to the build directory
+ foreach(SHELL_FILE ${SHELL_FILES})
+ get_filename_component(SHELL_FILE_NAME ${SHELL_FILE} NAME)
+ add_custom_command(TARGET webgpu_app POST_BUILD
+ COMMAND ${CMAKE_COMMAND} -E remove -f
+ ${CMAKE_CURRENT_BINARY_DIR}/${SHELL_FILE_NAME}
+ COMMAND ${CMAKE_COMMAND} -E copy
+ ${SHELL_FILE}
+ ${CMAKE_CURRENT_BINARY_DIR}/${SHELL_FILE_NAME}
+ )
+ endforeach()
+
+ # === INSTALL CONFIGURATION FOR WASM BUILD ===
+ set(WASM_OUTPUT_FILES
+ ${CMAKE_CURRENT_BINARY_DIR}/webgpu_app.html
+ ${CMAKE_CURRENT_BINARY_DIR}/webgpu_app.js
+ ${CMAKE_CURRENT_BINARY_DIR}/webgpu_app.wasm
+ ${CMAKE_CURRENT_BINARY_DIR}/qtloader.js
+ )
+ foreach(SHELL_FILE ${SHELL_FILES})
+ get_filename_component(SHELL_FILE_NAME ${SHELL_FILE} NAME)
+ list(APPEND WASM_OUTPUT_FILES ${CMAKE_CURRENT_BINARY_DIR}/${SHELL_FILE_NAME})
+ endforeach()
+ install(FILES ${WASM_OUTPUT_FILES} DESTINATION ${ALP_WWW_INSTALL_DIR})
+ # ================================================
+endif()
diff --git a/apps/webgpu_app/ImGuiManager.cpp b/apps/webgpu_app/ImGuiManager.cpp
new file mode 100644
index 000000000..d4fd67ad0
--- /dev/null
+++ b/apps/webgpu_app/ImGuiManager.cpp
@@ -0,0 +1,364 @@
+/*****************************************************************************
+ * weBIGeo
+ * Copyright (C) 2026 Gerald Kimmersdorfer
+ * Copyright (C) 2025 Patrick Komon
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *****************************************************************************/
+
+#include "ImGuiManager.h"
+#include "App.h"
+#include "RenderingContext.h"
+
+#include "ui/ImGuiPanel.h"
+
+#ifdef __EMSCRIPTEN__
+#include "util/WebInterop.h"
+#else
+#include
+#include
+#endif
+
+#include "atmosphere/AtmospherePanel.h"
+#include "backends/imgui_impl_sdl2.h"
+#include "backends/imgui_impl_wgpu.h"
+#include "cloud/CloudPanel.h"
+#include "overlay/OverlaysPanel.h"
+#include "track/TrackPanel.h"
+#include "ui/AboutPanel.h"
+#include "ui/AppPanel.h"
+#include "ui/CameraPanel.h"
+#include "ui/CompassPanel.h"
+#include "ui/LogoPanel.h"
+#include "ui/SearchPanel.h"
+#include "ui/ShadingPanel.h"
+#include "ui/TimingPanel.h"
+#ifdef ALP_WEBGPU_APP_ENABLE_COMPUTE
+#include "compute/NodeGraphPanel.h"
+#endif
+#include
+#include
+#include
+
+#include "util/dark_mode.h"
+#include
+#include
+
+namespace webgpu_app {
+
+ImGuiManager::ImGuiManager(App* terrain_renderer)
+ : m_terrain_renderer(terrain_renderer)
+{
+}
+
+void ImGuiManager::init(
+ SDL_Window* window, WGPUDevice device, [[maybe_unused]] WGPUTextureFormat swapchainFormat, [[maybe_unused]] WGPUTextureFormat depthTextureFormat)
+{
+ qDebug() << "Setup ImGuiManager...";
+ m_window = window;
+ m_device = device;
+
+ // Setup Dear ImGui context
+ IMGUI_CHECKVERSION();
+ ImGui::CreateContext();
+
+ // Setup ImNodes
+ ImNodes::CreateContext();
+
+ // Setup Platform/Renderer backends
+ ImGui_ImplSDL2_InitForOther(m_window);
+ ImGui_ImplWGPU_InitInfo init_info = {};
+ init_info.Device = m_device;
+ init_info.RenderTargetFormat = swapchainFormat;
+ init_info.DepthStencilFormat = depthTextureFormat;
+ init_info.NumFramesInFlight = 3;
+ ImGui_ImplWGPU_Init(&init_info);
+
+ webgpu_app::util::setup_darkmode_imgui_style();
+ install_fonts();
+
+ auto* rc = m_terrain_renderer->get_rendering_context();
+ auto* engine_ctx = rc->engine_context();
+
+ m_panels.push_back(std::make_unique(m_device));
+ m_panels.push_back(std::make_unique(m_terrain_renderer));
+ m_panels.push_back(std::make_unique());
+ m_panels.push_back(std::make_unique(m_terrain_renderer));
+ SearchPanel& search_panel = static_cast(**(m_panels.end() - 1));
+ m_panels.push_back(std::make_unique(m_terrain_renderer));
+ m_panels.push_back(std::make_unique(m_terrain_renderer));
+ m_panels.push_back(std::make_unique(m_terrain_renderer));
+ m_panels.push_back(std::make_unique(engine_ctx, rc->clouds_manager(), engine_ctx->cloud_renderer()));
+ m_panels.push_back(std::make_unique(engine_ctx));
+ m_panels.push_back(std::make_unique(engine_ctx));
+ m_panels.push_back(std::make_unique(engine_ctx, m_terrain_renderer));
+#ifdef ALP_WEBGPU_APP_ENABLE_COMPUTE
+ m_panels.push_back(std::make_unique(engine_ctx));
+#endif
+ m_panels.push_back(std::make_unique(engine_ctx));
+
+ connect(&search_panel, &SearchPanel::search_requested, rc->search_service(), &SearchService::search);
+ connect(&search_panel,
+ &SearchPanel::search_result_selected,
+ m_terrain_renderer->get_camera_controller(),
+ &nucleus::camera::Controller::fly_to_latitude_longitude);
+ connect(rc->search_service(), &SearchService::search_results_arrived, &search_panel, &SearchPanel::display_search_results);
+
+#ifdef __EMSCRIPTEN__
+ connect(&WebInterop::instance(), &WebInterop::file_uploaded, this, &ImGuiManager::on_file_uploaded);
+#endif
+}
+
+void ImGuiManager::ready()
+{
+ for (auto& panel : m_panels)
+ panel->ready();
+}
+
+void ImGuiManager::install_fonts()
+{
+ ImGuiIO& io = ImGui::GetIO();
+
+ float baseFontSize = 16.0f;
+ float iconFontSize = 14.0f;
+ float smallFontSize = 14.0f;
+ float smallIconFontSize = 12.0f;
+
+ QByteArray robotoData;
+ {
+ QFile file(":/fonts/Roboto-Regular.ttf");
+ if (!file.open(QIODevice::ReadOnly)) {
+ throw std::runtime_error("Failed to open Main Font.");
+ }
+ robotoData = file.readAll();
+ file.close();
+ }
+
+ QByteArray faData;
+ {
+ QFile file(":/fonts/fa5-solid-900.ttf");
+ if (!file.open(QIODevice::ReadOnly)) {
+ throw std::runtime_error("Failed to open glyph font.");
+ }
+ faData = file.readAll();
+ file.close();
+ }
+
+ static const ImWchar icons_ranges[] = { ICON_MIN_FA, ICON_MAX_16_FA, 0 };
+
+ // Default UI font (16 px Roboto + FA icons)
+ {
+ ImFontConfig font_cfg;
+ font_cfg.FontDataOwnedByAtlas = false;
+ io.Fonts->AddFontFromMemoryTTF(robotoData.data(), robotoData.size(), baseFontSize, &font_cfg);
+
+ ImFontConfig icons_config;
+ icons_config.MergeMode = true;
+ icons_config.PixelSnapH = true;
+ icons_config.GlyphMinAdvanceX = iconFontSize;
+ icons_config.FontDataOwnedByAtlas = false;
+ io.Fonts->AddFontFromMemoryTTF(faData.data(), faData.size(), iconFontSize, &icons_config, icons_ranges);
+ }
+
+ // Small font (14 px Roboto + FA icons)
+ {
+ ImFontConfig font_cfg;
+ font_cfg.FontDataOwnedByAtlas = false;
+ s_node_font = io.Fonts->AddFontFromMemoryTTF(robotoData.data(), robotoData.size(), smallFontSize, &font_cfg);
+
+ ImFontConfig icons_config;
+ icons_config.MergeMode = true;
+ icons_config.PixelSnapH = true;
+ icons_config.GlyphMinAdvanceX = smallIconFontSize;
+ icons_config.FontDataOwnedByAtlas = false;
+ io.Fonts->AddFontFromMemoryTTF(faData.data(), faData.size(), smallIconFontSize, &icons_config, icons_ranges);
+ }
+}
+
+void ImGuiManager::render([[maybe_unused]] WGPURenderPassEncoder renderPass)
+{
+ ImGui_ImplWGPU_NewFrame();
+ ImGui_ImplSDL2_NewFrame();
+ ImGui::NewFrame();
+
+ draw();
+
+ ImGui::Render();
+ ImGui_ImplWGPU_RenderDrawData(ImGui::GetDrawData(), renderPass);
+}
+
+void ImGuiManager::shutdown()
+{
+ qDebug() << "Releasing ImGuiManager...";
+ ImGui_ImplWGPU_Shutdown();
+ ImGui_ImplSDL2_Shutdown();
+ ImNodes::DestroyContext();
+ ImGui::DestroyContext();
+}
+
+bool ImGuiManager::want_capture_keyboard() { return ImGui::GetIO().WantCaptureKeyboard; }
+
+bool ImGuiManager::want_capture_mouse() { return ImGui::GetIO().WantCaptureMouse; }
+
+void ImGuiManager::on_sdl_event(SDL_Event& event) { ImGui_ImplSDL2_ProcessEvent(&event); }
+
+void ImGuiManager::set_gui_visibility(bool visible) { m_gui_visible = visible; }
+
+bool ImGuiManager::get_gui_visibility() const { return m_gui_visible; }
+
+float ImGuiManager::s_tool_button_y = 0.0f;
+ImFont* ImGuiManager::s_node_font = nullptr;
+std::unordered_map ImGuiManager::s_picker_states;
+
+#ifdef __EMSCRIPTEN__
+void ImGuiManager::on_file_uploaded(const std::string& filename, const std::string& tag)
+{
+ auto it = s_picker_states.find(tag);
+ if (it != s_picker_states.end() && it->second.is_open)
+ it->second.pending.push_back(filename);
+}
+#endif
+
+bool ImGuiManager::FilePicker(const char* dialog_id,
+ const char* title,
+ const char* filters,
+ bool wants_open,
+ std::vector& out_paths,
+ bool allow_multiple,
+ const char* initial_path,
+ FilePickerMode mode,
+ const char* default_filename)
+{
+#ifdef __EMSCRIPTEN__
+ if (mode == FilePickerMode::Save) {
+ // Web: no file-system dialog for saves; return a synthetic MEMFS path immediately.
+ if (wants_open) {
+ out_paths.push_back(std::string("/download/") + default_filename);
+ return true;
+ }
+ return false;
+ }
+ auto& state = s_picker_states[dialog_id];
+ if (wants_open) {
+ state.is_open = true;
+ state.pending.clear();
+ WebInterop::instance().open_file_dialog(filters, dialog_id, allow_multiple);
+ }
+ if (state.is_open && !state.pending.empty()) {
+ out_paths = std::move(state.pending);
+ state.pending.clear();
+ state.is_open = false;
+ return true;
+ }
+ return false;
+#else
+ if (wants_open) {
+ IGFD::FileDialogConfig config;
+ config.path = initial_path;
+ config.flags = ImGuiFileDialogFlags_Modal;
+ if (mode == FilePickerMode::Save) {
+ config.countSelectionMax = 1;
+ config.flags |= ImGuiFileDialogFlags_ConfirmOverwrite;
+ config.fileName = default_filename;
+ } else {
+ config.countSelectionMax = allow_multiple ? 0 : 1;
+ }
+ ImGuiFileDialog::Instance()->OpenDialog(dialog_id, title, filters, config);
+ }
+ const ImVec2 center = ImGui::GetMainViewport()->GetCenter();
+ ImGui::SetNextWindowPos(center, ImGuiCond_Always, ImVec2(0.5f, 0.5f));
+ const ImVec2 vp = ImGui::GetMainViewport()->Size;
+ const ImVec2 dialog_size(vp.x < 1000.0f ? vp.x * 0.9f : vp.x * 0.5f, vp.y < 1000.0f ? vp.y * 0.9f : vp.y * 0.5f);
+ if (ImGuiFileDialog::Instance()->Display(dialog_id, ImGuiWindowFlags_NoCollapse, dialog_size, dialog_size)) {
+ if (ImGuiFileDialog::Instance()->IsOk()) {
+ if (mode == FilePickerMode::Save) {
+ out_paths.push_back(ImGuiFileDialog::Instance()->GetFilePathName());
+ } else {
+ for (auto& [name, path] : ImGuiFileDialog::Instance()->GetSelection())
+ out_paths.push_back(path);
+ }
+ }
+ ImGuiFileDialog::Instance()->Close();
+ return !out_paths.empty();
+ }
+ return false;
+#endif
+}
+
+void ImGuiManager::finalize_save(const std::string& path)
+{
+#ifdef __EMSCRIPTEN__
+ WebInterop::download_file(path);
+#else
+ (void)path;
+#endif
+}
+
+bool ImGuiManager::FloatingToggleButton(const char* id, const char* icon, const char* tooltip, uint32_t* enabled)
+{
+ const bool on = *enabled != 0u;
+
+ // Claim the next floating tool-button slot (bottom-left, stacking upward).
+ ImVec2 button_pos(10, s_tool_button_y);
+ s_tool_button_y -= 48 + 10;
+ ImGui::SetNextWindowPos(button_pos, ImGuiCond_Always);
+ ImGui::SetNextWindowBgAlpha(on ? 0.5f : 0.2f); // fade the background when disabled
+ ImGui::SetNextWindowSize(ImVec2(48, 48));
+
+ ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
+ ImGui::Begin(id, nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_AlwaysAutoResize);
+ ImGui::BringWindowToDisplayFront(ImGui::GetCurrentWindow());
+
+ ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.0f, 0.0f, 0.0f, 0.0f)); // fully transparent
+ ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.0f, 0.0f, 0.0f, 0.2f));
+ ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.0f, 0.0f, 0.0f, 0.2f));
+ ImGui::PushStyleColor(ImGuiCol_Text, on ? ImVec4(1.0f, 1.0f, 1.0f, 1.0f) : ImVec4(1.0f, 1.0f, 1.0f, 0.5f)); // fade the icon when disabled
+
+ const bool clicked = ImGui::Button(icon, ImVec2(48, 48));
+ if (clicked)
+ *enabled = on ? 0u : 1u;
+ const bool hovered = ImGui::IsItemHovered();
+
+ ImGui::PopStyleColor(4);
+ ImGui::End();
+ ImGui::PopStyleVar();
+
+ if (hovered)
+ ImGui::SetTooltip("%s", tooltip);
+ return clicked;
+}
+
+void ImGuiManager::draw()
+{
+ if (!m_gui_visible)
+ return;
+
+ // Reset the floating tool-button stack for this frame (bottom-left, stacking upward).
+ s_tool_button_y = ImGui::GetIO().DisplaySize.y - 48.0f - 40.0f;
+
+ // Main sidebar window with CollapsingHeader sections.
+ ImGui::SetNextWindowPos(ImVec2(ImGui::GetIO().DisplaySize.x - 430, 0)); // Set position to top-right corner
+ ImGui::SetNextWindowSize(ImVec2(430, ImGui::GetIO().DisplaySize.y)); // Set height to full screen height, width as desired
+ ImGui::Begin("weBIGeo", nullptr, ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoTitleBar);
+
+ for (auto& panel : m_panels)
+ panel->draw_panel();
+
+ ImGui::End();
+
+ for (auto& panel : m_panels)
+ panel->draw();
+}
+
+} // namespace webgpu_app
diff --git a/apps/webgpu_app/ImGuiManager.h b/apps/webgpu_app/ImGuiManager.h
new file mode 100644
index 000000000..2cc9b62ff
--- /dev/null
+++ b/apps/webgpu_app/ImGuiManager.h
@@ -0,0 +1,105 @@
+/*****************************************************************************
+ * weBIGeo
+ * Copyright (C) 2026 Gerald Kimmersdorfer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *****************************************************************************/
+
+#pragma once
+
+#include
+#include
+
+struct ImFont;
+#include
+#include
+#include
+#include
+#include
+
+#include "ui/ImGuiPanel.h"
+
+namespace webgpu_app {
+
+class App;
+
+class ImGuiManager : public QObject {
+ Q_OBJECT
+public:
+ explicit ImGuiManager(App* terrain_renderer);
+
+ void init(SDL_Window* window, WGPUDevice device, WGPUTextureFormat swapchainFormat, WGPUTextureFormat depthTextureFormat);
+ void ready();
+ void render(WGPURenderPassEncoder renderPass);
+ void shutdown();
+
+ bool want_capture_keyboard();
+ bool want_capture_mouse();
+ void on_sdl_event(SDL_Event& event);
+
+ void set_gui_visibility(bool visible);
+ bool get_gui_visibility() const;
+
+ // Top-left Y for the next floating tool button
+ static float s_tool_button_y;
+
+ // Smaller font for compact UIs like the node graph editor (12 px)
+ static ImFont* s_node_font;
+
+ // Draws a floating 48x48 icon tool button at the next bottom-left stack slot (claims s_tool_button_y).
+ static bool FloatingToggleButton(const char* id, const char* icon, const char* tooltip, uint32_t* enabled);
+
+ enum class FilePickerMode { Open, Save };
+
+ // ImGui-style file picker. Call every frame inside an ImGui frame. wants_open=true triggers the dialog to open.
+ // Returns true (once) when the user has confirmed a selection; out_paths is filled with selected paths.
+ // In Save mode on web, returns true immediately with a synthetic /download/ path.
+ // NOTE: Uses WebInterop to be platform independent
+ static bool FilePicker(const char* dialog_id,
+ const char* title,
+ const char* filters,
+ bool wants_open,
+ std::vector& out_paths,
+ bool allow_multiple = false,
+ const char* initial_path = ".",
+ FilePickerMode mode = FilePickerMode::Open,
+ const char* default_filename = "");
+
+ // No-op on native; triggers a browser file download on web for the given MEMFS path.
+ static void finalize_save(const std::string& path);
+
+#ifdef __EMSCRIPTEN__
+private slots:
+ void on_file_uploaded(const std::string& filename, const std::string& tag);
+#endif
+
+private:
+ struct FilePickerState {
+ bool is_open = false;
+ std::vector pending;
+ };
+ static std::unordered_map s_picker_states;
+
+ SDL_Window* m_window = nullptr;
+ WGPUDevice m_device = {};
+ App* m_terrain_renderer = nullptr;
+ bool m_gui_visible = true;
+
+ std::vector> m_panels;
+
+ void draw();
+ void install_fonts();
+};
+
+} // namespace webgpu_app
diff --git a/apps/webgpu_app/RenderingContext.cpp b/apps/webgpu_app/RenderingContext.cpp
new file mode 100644
index 000000000..1d15aae0b
--- /dev/null
+++ b/apps/webgpu_app/RenderingContext.cpp
@@ -0,0 +1,233 @@
+/*****************************************************************************
+ * weBIGeo
+ * Copyright (C) 2024 Adam Celarek
+ * Copyright (C) 2025 Patrick Komon
+ * Copyright (C) 2025 Gerald Kimmersdorfer
+ * Copyright (C) 2026 Wendelin Muth
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *****************************************************************************/
+
+#include "RenderingContext.h"
+
+#include "nucleus/DataQuerier.h"
+#include "nucleus/tile/SchedulerDirector.h"
+#include "nucleus/tile/Texture3DScheduler.h"
+#include "nucleus/tile/TileLoadService.h"
+#include "nucleus/tile/setup.h"
+#include "webgpu/engine/Context.h"
+#include "webgpu/engine/cloud/CloudRenderer.h"
+#include "webgpu/engine/overlay/HeightLinesOverlay.h"
+#include "webgpu/engine/overlay/OverlayRenderer.h"
+#include "webgpu/engine/overlay/TextureOverlay.h"
+#include "webgpu/engine/tile_mesh/TileMeshRenderer.h"
+
+#ifdef ALP_WEBGPU_APP_ENABLE_COMPUTE
+#include "compute/OverlayRenderNode.h"
+#include "webgpu/compute/NodeRegistry.h"
+#endif
+
+namespace webgpu_app {
+
+RenderingContext::RenderingContext()
+{
+
+ using TilePattern = nucleus::tile::TileLoadService::UrlPattern;
+ assert(QThread::currentThread() == QCoreApplication::instance()->thread());
+
+#ifdef ALP_ENABLE_THREADING
+ m_scheduler_thread = std::make_unique();
+ m_scheduler_thread->setObjectName("scheduler_thread");
+#endif
+
+ m_scheduler_director = std::make_unique();
+
+ // m->ortho_service.reset(new TileLoadService("https://tiles.bergfex.at/styles/bergfex-osm/", TileLoadService::UrlPattern::ZXY_yPointingSouth,
+ // ".jpeg")); m->ortho_service.reset(new TileLoadService("https://alpinemaps.cg.tuwien.ac.at/tiles/ortho/",
+ // TileLoadService::UrlPattern::ZYX_yPointingSouth, ".jpeg"));
+ // m->ortho_service.reset(new TileLoadService("https://maps%1.wien.gv.at/basemap/bmaporthofoto30cm/normal/google3857/",
+ // TileLoadService::UrlPattern::ZYX_yPointingSouth,
+ // ".jpeg",
+ // {"", "1", "2", "3", "4"}));
+ m_aabb_decorator = nucleus::tile::setup::aabb_decorator();
+ {
+ auto geometry_service
+ = std::make_unique("https://alpinemaps.cg.tuwien.ac.at/tiles/alpine_png/", TilePattern::ZXY, ".png");
+ m_geometry_scheduler_holder = nucleus::tile::setup::geometry_scheduler(std::move(geometry_service), m_aabb_decorator, m_scheduler_thread.get());
+ m_geometry_scheduler_holder.scheduler->set_gpu_quad_limit(256); // TODO
+ m_scheduler_director->check_in("geometry", m_geometry_scheduler_holder.scheduler);
+ m_data_querier = std::make_shared(&m_geometry_scheduler_holder.scheduler->ram_cache());
+ auto ortho_service
+ = std::make_unique("https://gataki.cg.tuwien.ac.at/raw/basemap/tiles/", TilePattern::ZYX_yPointingSouth, ".jpeg");
+ // auto ortho_service = std::make_unique("https://maps.wien.gv.at/basemap/bmaporthofoto30cm/normal/google3857/",
+ // TilePattern::ZYX_yPointingSouth, ".jpeg"); auto ortho_service =
+ // std::make_unique("https://mapsneu.wien.gv.at/basemap/bmapgelaende/grau/google3857/", TilePattern::ZYX_yPointingSouth,
+ // ".jpeg");
+ m_ortho_scheduler_holder = nucleus::tile::setup::texture_scheduler(std::move(ortho_service), m_aabb_decorator, m_scheduler_thread.get());
+ m_ortho_scheduler_holder.scheduler->set_gpu_quad_limit(256); // TODO
+ m_scheduler_director->check_in("ortho", m_ortho_scheduler_holder.scheduler);
+
+ auto cloud_service = std::make_unique("", TilePattern::ZXY, ".ktx2");
+ m_cloud_scheduler_holder = nucleus::tile::setup::texture_scheduler_3d(std::move(cloud_service),
+ m_aabb_decorator,
+ m_scheduler_thread.get(),
+ { .tile_resolution = webgpu_engine::clouds::TILE_RESOLUTION_XY, .max_zoom_level = 10, .gpu_quad_limit = 1024 });
+ m_cloud_scheduler_holder.scheduler->set_gpu_quad_limit(webgpu_engine::clouds::LOADED_TILE_LIMIT);
+ m_scheduler_director->check_in("cloud", m_cloud_scheduler_holder.scheduler);
+ }
+ m_geometry_scheduler_holder.scheduler->set_dataquerier(m_data_querier);
+
+ if (QNetworkInformation::loadDefaultBackend() && QNetworkInformation::instance()) {
+ QNetworkInformation* n = QNetworkInformation::instance();
+ m_geometry_scheduler_holder.scheduler->set_network_reachability(n->reachability());
+ m_ortho_scheduler_holder.scheduler->set_network_reachability(n->reachability());
+ m_cloud_scheduler_holder.scheduler->set_network_reachability(n->reachability());
+ // clang-format off
+ connect(n, &QNetworkInformation::reachabilityChanged, m_geometry_scheduler_holder.scheduler.get(), &nucleus::tile::Scheduler::set_network_reachability);
+ connect(n, &QNetworkInformation::reachabilityChanged, m_ortho_scheduler_holder.scheduler.get(), &nucleus::tile::Scheduler::set_network_reachability);
+ connect(n, &QNetworkInformation::reachabilityChanged, m_cloud_scheduler_holder.scheduler.get(), &nucleus::tile::Scheduler::set_network_reachability);
+ // clang-format on
+ }
+#ifdef ALP_ENABLE_THREADING
+ qDebug() << "Scheduler thread: " << m_scheduler_thread.get();
+ m_scheduler_thread->start();
+#endif
+
+ m_clous_manager = std::make_unique();
+ connect(m_clous_manager.get(), &clouds::Manager::slot_ready, this, [this](const clouds::TileSetInfo& slot) {
+ QString new_url = m_clous_manager->server_url() + "/" + slot.folder + "/tiles/";
+ nucleus::utils::thread::async_call(
+ m_cloud_scheduler_holder.tile_service.get(), [this, new_url]() { m_cloud_scheduler_holder.tile_service->set_base_url(new_url); });
+ nucleus::utils::thread::async_call(m_cloud_scheduler_holder.scheduler.get(), [this]() {
+ m_cloud_scheduler_holder.scheduler->clear_full_cache();
+ m_cloud_scheduler_holder.scheduler->set_enabled(true);
+ });
+ });
+
+ m_search_service = std::make_unique();
+}
+
+void RenderingContext::initialize(webgpu::Context& ctx)
+{
+ auto tile_mesh_renderer = std::make_shared(65, 512);
+ tile_mesh_renderer->set_tile_limit(1024);
+ auto cloud_renderer = std::make_shared();
+ // This doesn't really make any sense if you think about it
+ cloud_renderer->set_tile_limit(webgpu_engine::clouds::LOADED_TILE_LIMIT);
+
+ m_engine_context = std::make_unique();
+ m_engine_context->set_webgpu_ctx(ctx);
+ m_engine_context->set_aabb_decorator(m_aabb_decorator);
+ m_engine_context->set_tile_mesh_renderer(tile_mesh_renderer);
+ m_engine_context->set_cloud_renderer(cloud_renderer);
+ auto overlay_renderer = std::make_shared();
+ m_engine_context->set_overlay_renderer(overlay_renderer);
+ auto track_renderer = std::make_shared();
+ m_engine_context->set_track_renderer(track_renderer);
+ auto atmosphere_renderer = std::make_shared();
+ m_engine_context->set_atmosphere_renderer(atmosphere_renderer);
+
+ connect(m_geometry_scheduler_holder.scheduler.get(),
+ &nucleus::tile::GeometryScheduler::gpu_tiles_updated,
+ m_engine_context->tile_mesh_renderer(),
+ &webgpu_engine::TileMeshRenderer::update_gpu_tiles_height);
+ connect(m_ortho_scheduler_holder.scheduler.get(),
+ &nucleus::tile::TextureScheduler::gpu_tiles_updated,
+ m_engine_context->tile_mesh_renderer(),
+ &webgpu_engine::TileMeshRenderer::update_gpu_tiles_ortho);
+ connect(m_cloud_scheduler_holder.scheduler.get(),
+ &nucleus::tile::Texture3DScheduler::gpu_tiles_updated,
+ m_engine_context->cloud_renderer(),
+ &webgpu_engine::CloudRenderer::update_gpu_tiles_cloud);
+ nucleus::utils::thread::async_call(m_geometry_scheduler_holder.scheduler.get(), [this]() { m_geometry_scheduler_holder.scheduler->set_enabled(true); });
+
+ // TODO: texture compression
+ nucleus::utils::thread::async_call(m_ortho_scheduler_holder.scheduler.get(), [this]() {
+ m_ortho_scheduler_holder.scheduler->set_texture_compression_algorithm(nucleus::utils::ColourTexture::Format::Uncompressed_RGBA);
+ m_ortho_scheduler_holder.scheduler->set_enabled(true);
+ });
+
+ // TODO do we need to connect some destroy signals? in gl app we do this:
+
+ // clang-format off
+ //connect(QOpenGLContext::currentContext(), &QOpenGLContext::aboutToBeDestroyed, m->engine_context.get(), &nucleus::EngineContext::destroy);
+ //connect(QOpenGLContext::currentContext(), &QOpenGLContext::aboutToBeDestroyed, this, &RenderingContext::destroy);
+ //connect(QCoreApplication::instance(), &QCoreApplication::aboutToQuit, this, &RenderingContext::destroy);
+ // clang-format on
+
+ m_engine_context->initialise();
+
+ // ToDo: Maybe the Compute should get its own context to do the following:
+#ifdef ALP_WEBGPU_APP_ENABLE_COMPUTE
+ // Compute shaders are bundled by the webgpu_compute target; register its local source dir for hot-reload.
+ ctx.resource_registry().set_local_shader_path("webgpu_compute", ALP_SHADER_DIR_WEBGPU_COMPUTE);
+
+ // The terminal node that forwards compute-graph results onto the overlay renderer needs to be registered
+ webgpu_compute::NodeRegistry::instance().register_node(
+ "OverlayRenderNode", [ctx = m_engine_context.get()](webgpu::Context&) { return std::make_unique(*ctx); });
+#endif
+
+ nucleus::utils::thread::async_call(this, [this]() { emit this->initialised(); });
+}
+
+void RenderingContext::destroy()
+{
+ if (!m_geometry_scheduler_holder.scheduler)
+ return;
+
+ if (m_engine_context)
+ m_engine_context->destroy();
+
+ if (m_scheduler_thread) {
+ nucleus::utils::thread::sync_call(m_geometry_scheduler_holder.scheduler.get(), [this]() {
+ m_geometry_scheduler_holder.scheduler.reset();
+ m_ortho_scheduler_holder.scheduler.reset();
+ m_cloud_scheduler_holder.scheduler.reset();
+ });
+ nucleus::utils::thread::sync_call(m_geometry_scheduler_holder.tile_service.get(), [this]() {
+ m_geometry_scheduler_holder.tile_service.reset();
+ m_ortho_scheduler_holder.tile_service.reset();
+ m_cloud_scheduler_holder.tile_service.reset();
+ });
+ m_scheduler_thread->quit();
+ m_scheduler_thread->wait(500); // msec
+ m_scheduler_thread.reset();
+ }
+}
+
+webgpu_engine::Context* RenderingContext::engine_context() { return m_engine_context.get(); }
+
+nucleus::tile::utils::AabbDecorator* RenderingContext::aabb_decorator() { return m_aabb_decorator.get(); }
+
+nucleus::DataQuerier* RenderingContext::data_querier() { return m_data_querier.get(); }
+
+nucleus::tile::GeometryScheduler* RenderingContext::geometry_scheduler() { return m_geometry_scheduler_holder.scheduler.get(); }
+
+nucleus::tile::TileLoadService* RenderingContext::geometry_tile_load_service() { return m_geometry_scheduler_holder.tile_service.get(); }
+
+nucleus::tile::TextureScheduler* RenderingContext::ortho_scheduler() { return m_ortho_scheduler_holder.scheduler.get(); }
+
+nucleus::tile::Texture3DScheduler* RenderingContext::cloud_scheduler() { return m_cloud_scheduler_holder.scheduler.get(); }
+
+nucleus::tile::SchedulerDirector* RenderingContext::scheduler_director() { return m_scheduler_director.get(); }
+
+nucleus::tile::TileLoadService* RenderingContext::ortho_tile_load_service() { return m_ortho_scheduler_holder.tile_service.get(); }
+
+nucleus::tile::TileLoadService* RenderingContext::cloud_tile_load_service() { return m_cloud_scheduler_holder.tile_service.get(); }
+
+clouds::Manager* RenderingContext::clouds_manager() { return m_clous_manager.get(); }
+
+SearchService* RenderingContext::search_service() { return m_search_service.get(); }
+
+} // namespace webgpu_app
diff --git a/apps/webgpu_app/RenderingContext.h b/apps/webgpu_app/RenderingContext.h
new file mode 100644
index 000000000..b47658375
--- /dev/null
+++ b/apps/webgpu_app/RenderingContext.h
@@ -0,0 +1,97 @@
+/*****************************************************************************
+ * weBIGeo
+ * Copyright (C) 2024 Adam Celarek
+ * Copyright (C) 2025 Patrick Komon
+ * Copyright (C) 2025 Gerald Kimmersdorfer
+ * Copyright (C) 2026 Wendelin Muth
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *****************************************************************************/
+
+#pragma once
+
+#include
+#include
+
+#include "cloud/CloudsManager.h"
+#include "nucleus/tile/SchedulerDirector.h"
+#include "nucleus/tile/setup.h"
+#include "util/SearchService.h"
+#include "webgpu/webgpu.h"
+
+namespace webgpu {
+class Context;
+}
+
+namespace webgpu_engine {
+class Context;
+}
+
+namespace nucleus {
+class DataQuerier;
+}
+
+namespace nucleus::tile {
+class Texture3DScheduler;
+class GeometryScheduler;
+class TextureScheduler;
+class SchedulerDirector;
+} // namespace nucleus::tile
+
+namespace nucleus::tile::utils {
+class AabbDecorator;
+}
+
+namespace webgpu_app {
+
+class RenderingContext : public QObject {
+ Q_OBJECT
+public:
+ RenderingContext();
+
+ void initialize(webgpu::Context& ctx);
+ void destroy();
+
+ webgpu_engine::Context* engine_context();
+ nucleus::tile::utils::AabbDecorator* aabb_decorator();
+ nucleus::DataQuerier* data_querier();
+ nucleus::tile::GeometryScheduler* geometry_scheduler();
+ nucleus::tile::TileLoadService* geometry_tile_load_service();
+ nucleus::tile::TextureScheduler* ortho_scheduler();
+ nucleus::tile::Texture3DScheduler* cloud_scheduler();
+ nucleus::tile::SchedulerDirector* scheduler_director();
+ nucleus::tile::TileLoadService* ortho_tile_load_service();
+ nucleus::tile::TileLoadService* cloud_tile_load_service();
+ clouds::Manager* clouds_manager();
+ SearchService* search_service();
+
+signals:
+ void initialised();
+
+private:
+ std::unique_ptr m_engine_context;
+
+ std::shared_ptr m_aabb_decorator;
+ std::shared_ptr m_data_querier;
+ nucleus::tile::setup::GeometrySchedulerHolder m_geometry_scheduler_holder;
+ nucleus::tile::setup::TextureSchedulerHolder m_ortho_scheduler_holder;
+ nucleus::tile::setup::Texture3DSchedulerHolder m_cloud_scheduler_holder;
+ std::unique_ptr m_scheduler_director;
+ std::unique_ptr m_clous_manager;
+ std::unique_ptr m_search_service;
+
+ std::unique_ptr m_scheduler_thread;
+};
+
+} // namespace webgpu_app
diff --git a/apps/webgpu_app/atmosphere/AtmospherePanel.cpp b/apps/webgpu_app/atmosphere/AtmospherePanel.cpp
new file mode 100644
index 000000000..06863d4f0
--- /dev/null
+++ b/apps/webgpu_app/atmosphere/AtmospherePanel.cpp
@@ -0,0 +1,40 @@
+/*****************************************************************************
+ * weBIGeo
+ * Copyright (C) 2026 Gerald Kimmersdorfer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *****************************************************************************/
+
+#include "AtmospherePanel.h"
+
+#include "ImGuiManager.h"
+#include
+
+#include
+
+namespace webgpu_app {
+
+AtmospherePanel::AtmospherePanel(webgpu_engine::Context* context)
+ : m_context(context)
+{
+}
+
+void AtmospherePanel::draw()
+{
+ auto& cfg = m_context->shared_config();
+ if (ImGuiManager::FloatingToggleButton("ToggleAtmosphereButton", ICON_FA_GLOBE, "Atmosphere", &cfg.m_atmosphere_enabled))
+ m_context->request_redraw();
+}
+
+} // namespace webgpu_app
diff --git a/apps/webgpu_app/atmosphere/AtmospherePanel.h b/apps/webgpu_app/atmosphere/AtmospherePanel.h
new file mode 100644
index 000000000..92a3d0eb3
--- /dev/null
+++ b/apps/webgpu_app/atmosphere/AtmospherePanel.h
@@ -0,0 +1,38 @@
+/*****************************************************************************
+ * weBIGeo
+ * Copyright (C) 2026 Gerald Kimmersdorfer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *****************************************************************************/
+
+#pragma once
+
+#include "ui/ImGuiPanel.h"
+
+namespace webgpu_engine {
+class Context;
+}
+
+namespace webgpu_app {
+
+class AtmospherePanel : public ImGuiPanel {
+public:
+ explicit AtmospherePanel(webgpu_engine::Context* context);
+ void draw() override;
+
+private:
+ webgpu_engine::Context* m_context;
+};
+
+} // namespace webgpu_app
diff --git a/apps/webgpu_app/cloud/CloudPanel.cpp b/apps/webgpu_app/cloud/CloudPanel.cpp
new file mode 100644
index 000000000..d08790141
--- /dev/null
+++ b/apps/webgpu_app/cloud/CloudPanel.cpp
@@ -0,0 +1,136 @@
+/*****************************************************************************
+ * weBIGeo
+ * Copyright (C) 2026 Gerald Kimmersdorfer
+ * Copyright (C) 2026 Wendelin Muth
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *****************************************************************************/
+
+#include "CloudPanel.h"
+
+#include "ImGuiManager.h"
+#include
+#include
+
+#include "cloud/CloudsManager.h"
+#include
+#include
+
+namespace webgpu_app {
+
+CloudPanel::CloudPanel(webgpu_engine::Context* context, clouds::Manager* clouds_manager, webgpu_engine::CloudRenderer* cloud_renderer)
+ : m_context(context)
+ , m_clouds_manager(clouds_manager)
+ , m_cloud_renderer(cloud_renderer)
+{
+}
+
+void CloudPanel::draw()
+{
+ auto& cfg = m_context->shared_config();
+ if (ImGuiManager::FloatingToggleButton("ToggleCloudsButton", ICON_FA_CLOUD, "Clouds", &cfg.m_clouds_enabled))
+ m_context->request_redraw();
+}
+
+void CloudPanel::draw_panel()
+{
+ if (!m_context->shared_config().m_clouds_enabled)
+ return;
+
+ const auto& tilesets = m_clouds_manager->get_slots();
+ auto selected_slot = m_clouds_manager->selected_time_slot();
+
+ if (ImGui::CollapsingHeader(ICON_FA_CLOUD " Clouds")) {
+
+ ImGui::SeparatorText("Data");
+
+ if (tilesets.empty()) {
+ if (m_clouds_manager->is_loading()) {
+ ImGui::Text("Loading cloud data...");
+ } else {
+ ImGui::Text("No cloud data available.");
+ ImGui::SameLine();
+ if (ImGui::Button(ICON_FA_SYNC "##reload_clouds")) {
+ m_clouds_manager->refresh_tileset_list();
+ }
+ }
+ } else {
+ std::string preview_str = "Select time";
+ if (!selected_slot.id.isEmpty()) {
+ preview_str = selected_slot.format_string();
+ }
+ if (ImGui::BeginCombo("(UTC)", preview_str.c_str())) {
+ for (int n = 0; n < (int)tilesets.size(); n++) {
+ const auto& slot = tilesets[n];
+ ImGui::PushID(slot.id.toStdString().c_str());
+ const bool is_selected = slot.id == selected_slot.id;
+ std::string label = slot.format_string();
+ if (ImGui::Selectable(label.c_str(), is_selected)) {
+ m_clouds_manager->select_time_slot(tilesets[n]);
+ }
+ if (is_selected)
+ ImGui::SetItemDefaultFocus();
+ ImGui::PopID();
+ }
+ ImGui::EndCombo();
+ }
+
+ ImGui::SameLine();
+ const bool loading = m_clouds_manager->is_loading();
+ if (loading)
+ ImGui::BeginDisabled();
+ if (ImGui::Button(ICON_FA_SYNC "##reload_clouds")) {
+ m_clouds_manager->refresh_tileset_list();
+ }
+ if (loading)
+ ImGui::EndDisabled();
+ }
+
+ ImGui::SeparatorText("Shading");
+ auto& shader_params = m_cloud_renderer->shader_params;
+ ImGui::Text("Step Size");
+ ImGui::Indent();
+ ImGui::DragFloat("Minimum", &shader_params.step_size_min, 1.0f, 0.0f, 10000.0f);
+ float inv_dist_fact = 1.0f / shader_params.step_size_distance_factor;
+ if (ImGui::DragFloat("Distance Factor", &inv_dist_fact, 1.0f, 0.0f, 10000.0f)) {
+ shader_params.step_size_distance_factor = 1.0f / inv_dist_fact;
+ }
+ ImGui::DragFloat("Horizon Factor", &shader_params.step_size_horizon_factor, 1.0f, 0.0f, 10000.0f);
+ ImGui::Unindent();
+ ImGui::Text("Scattering");
+ ImGui::Indent();
+ ImGui::SliderFloat("Scattering Coeff", &shader_params.scattering_coeff, -1.0f, 1.0f);
+ ImGui::SliderFloat("Extinction Coeff", &shader_params.extinction_coeff, 0.0f, 1.0f, "%.5f");
+ ImGui::SliderFloat("Albedo", &shader_params.albedo, 0.0f, 1.0f);
+ ImGui::Unindent();
+ ImGui::Text("Lighting");
+ ImGui::Indent();
+ ImGui::DragFloat("Sun Light Scale", &shader_params.sun_light_scale, 1.0f, 0.0f, 10000.0f);
+ ImGui::DragFloat("Ambient Light Scale", &shader_params.ambient_light_scale, 0.01f, 0.0f, 10000.0f);
+ ImGui::DragFloat("Atmospheric Light Scale", &shader_params.atmospheric_light_scale, 0.01f, 0.0f, 10000.0f);
+ ImGui::DragFloat("Shadow Extinction Scale", &shader_params.shadow_extinction_scale, 0.01f, 0.0f, 10000.0f);
+ ImGui::SliderFloat("Powder Effect Scale", &shader_params.powder_scale, 0.0f, 1.0f);
+ ImGui::Unindent();
+ ImGui::Text("Visibility");
+ ImGui::Indent();
+ ImGui::SliderFloat("Fade", &shader_params.fade_factor, 0.001f, 1.0f);
+ ImGui::Unindent();
+ ImGui::Text("Accumulation");
+ ImGui::Indent();
+ ImGui::SliderInt("Stable Frames Limit", &shader_params.stable_frames_limit, 1, 256);
+ ImGui::Unindent();
+ }
+}
+
+} // namespace webgpu_app
diff --git a/apps/webgpu_app/cloud/CloudPanel.h b/apps/webgpu_app/cloud/CloudPanel.h
new file mode 100644
index 000000000..570d09ea0
--- /dev/null
+++ b/apps/webgpu_app/cloud/CloudPanel.h
@@ -0,0 +1,48 @@
+/*****************************************************************************
+ * weBIGeo
+ * Copyright (C) 2026 Gerald Kimmersdorfer
+ * Copyright (C) 2026 Wendelin Muth
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *****************************************************************************/
+
+#pragma once
+
+#include "ui/ImGuiPanel.h"
+
+namespace webgpu_engine {
+class CloudRenderer;
+class Context;
+} // namespace webgpu_engine
+
+namespace webgpu_app {
+
+namespace clouds {
+ class Manager;
+}
+
+class CloudPanel : public ImGuiPanel {
+public:
+ CloudPanel(webgpu_engine::Context* context, clouds::Manager* clouds_manager, webgpu_engine::CloudRenderer* cloud_renderer);
+
+ void draw() override;
+ void draw_panel() override;
+
+private:
+ webgpu_engine::Context* m_context;
+ clouds::Manager* m_clouds_manager;
+ webgpu_engine::CloudRenderer* m_cloud_renderer;
+};
+
+} // namespace webgpu_app
diff --git a/apps/webgpu_app/cloud/CloudsManager.cpp b/apps/webgpu_app/cloud/CloudsManager.cpp
new file mode 100644
index 000000000..a638c230b
--- /dev/null
+++ b/apps/webgpu_app/cloud/CloudsManager.cpp
@@ -0,0 +1,195 @@
+/*****************************************************************************
+ * weBIGeo
+ * Copyright (C) 2026 Wendelin Muth
+ * Copyright (C) 2026 Gerald Kimmersdorfer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *****************************************************************************/
+
+#include "CloudsManager.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace webgpu_app::clouds {
+
+std::string TileSetInfo::format_string() const
+{
+ if (id.isEmpty())
+ return "invalid";
+ double mib = size / (1024.0 * 1024.0);
+ char buf[64];
+ std::snprintf(buf, sizeof(buf), "%02d.%02d.%04d %02d:00 (+%02d, %.0f MiB)", date.day, date.month, date.year, date.hour, step, mib);
+ return std::string(buf);
+}
+
+APIService::APIService(QObject* parent)
+ : QObject(parent)
+ , m_network_manager(new QNetworkAccessManager(this))
+{
+}
+
+const QVector& APIService::get_slots() const { return m_slots; }
+
+const QHash& APIService::get_slots_map() const { return m_id_to_index; }
+
+TileSetInfo APIService::get_slot(const QString& id) const
+{
+ if (!m_id_to_index.contains(id))
+ return {};
+ return m_slots[m_id_to_index[id]];
+}
+
+DateComponents APIService::parse_timestamp_id(const QString& id)
+{
+ // ID Format: YYYYMMDDHH (10 chars)
+ if (id.length() != 10)
+ return { 0, 0, 0, 0 };
+ return { id.mid(0, 4).toInt(), id.mid(4, 2).toInt(), id.mid(6, 2).toInt(), id.mid(8, 2).toInt() };
+}
+
+void APIService::refresh_tileset_list()
+{
+ qDebug() << "[CloudAPI] Fetching tileset list...";
+ QNetworkRequest request(QUrl(m_server_url + "/tilesets?status=ready"));
+ QNetworkReply* reply = m_network_manager->get(request);
+
+ connect(reply, &QNetworkReply::finished, this, [this, reply]() {
+ reply->deleteLater();
+ if (reply->error() != QNetworkReply::NoError) {
+ qWarning() << "[CloudAPI] Failed to fetch tilesets:" << reply->errorString();
+ emit tileset_list_loaded(false);
+ return;
+ }
+
+ QJsonDocument doc = QJsonDocument::fromJson(reply->readAll());
+ if (!doc.isObject()) {
+ emit tileset_list_loaded(false);
+ return;
+ }
+
+ m_slots.clear();
+ m_id_to_index.clear();
+
+ QJsonArray items = doc.object()["items"].toArray();
+ for (const auto& val : items) {
+ QJsonObject item = val.toObject();
+ QString id = item["id"].toString();
+ if (id.length() != 10)
+ continue;
+
+ TileSetInfo slot;
+ slot.id = id;
+ slot.date = parse_timestamp_id(id);
+ slot.folder = item["folder"].toString();
+ slot.size = item["size"].toVariant().toLongLong();
+
+ int underscore = slot.folder.lastIndexOf('_');
+ if (underscore >= 0)
+ slot.step = slot.folder.mid(underscore + 1).toInt();
+
+ m_id_to_index[id] = m_slots.size();
+ m_slots.push_back(slot);
+ }
+
+ qDebug() << "[CloudAPI] Loaded" << m_slots.size() << "ready tilesets.";
+ emit tileset_list_loaded(true);
+ });
+}
+
+void APIService::fetch_shadow_texture(const QString& id)
+{
+ if (!m_id_to_index.contains(id)) {
+ qWarning() << "[CloudAPI] fetch_shadow_texture called with unknown ID:" << id;
+ return;
+ }
+
+ TileSetInfo slot = m_slots[m_id_to_index[id]]; // copy to avoid dangling ref in lambda
+
+ if (slot.folder.isEmpty()) {
+ qWarning() << "[CloudAPI] Slot folder is empty for:" << id;
+ return;
+ }
+
+ qDebug() << "[CloudAPI] Fetching shadow texture for" << id << "from" << slot.folder;
+ QString full_url = m_server_url + "/" + slot.folder + "/shadow.ktx2";
+ QNetworkRequest request(full_url);
+ QNetworkReply* reply = m_network_manager->get(request);
+
+ connect(reply, &QNetworkReply::finished, this, [this, reply, slot]() {
+ reply->deleteLater();
+ if (reply->error() != QNetworkReply::NoError) {
+ qWarning() << "[CloudAPI] Shadow texture download failed:" << reply->errorString();
+ return;
+ }
+ emit shadow_texture_loaded(slot, reply->readAll());
+ });
+}
+
+Manager::Manager(QObject* parent)
+ : QObject(parent)
+ , m_api_service(std::make_unique())
+{
+ connect(m_api_service.get(), &APIService::shadow_texture_loaded, this, [this](const TileSetInfo& slot, const QByteArray& data) {
+ if (m_selected_slot_id == slot.id) {
+ emit shadow_texture_ready(data);
+ }
+ });
+
+ connect(m_api_service.get(), &APIService::tileset_list_loaded, this, [this](bool ok) {
+ m_loading = false;
+ if (!ok)
+ return;
+
+ const auto& tilesets = m_api_service->get_slots();
+ auto current_date = QDateTime::currentDateTimeUtc();
+ auto ymd = QCalendar().partsFromDate(current_date.date());
+ int hour = current_date.time().hour();
+ for (const auto& slot : tilesets) {
+ if (slot.date.year == ymd.year && slot.date.month == ymd.month && slot.date.day == ymd.day && slot.date.hour == hour) {
+ select_time_slot(slot);
+ break;
+ }
+ }
+ });
+
+ m_api_service->refresh_tileset_list();
+}
+
+void Manager::select_time_slot(const TileSetInfo& slot)
+{
+ if (m_selected_slot_id == slot.id)
+ return;
+ m_selected_slot_id = slot.id;
+ m_api_service->fetch_shadow_texture(slot.id);
+ emit slot_ready(slot);
+}
+
+void Manager::refresh_tileset_list()
+{
+ m_loading = true;
+ m_api_service->refresh_tileset_list();
+}
+
+TileSetInfo Manager::selected_time_slot() const { return m_api_service->get_slot(m_selected_slot_id); }
+
+const QVector& Manager::get_slots() const { return m_api_service->get_slots(); }
+
+const QString& Manager::server_url() const { return m_api_service->server_url(); }
+
+} // namespace webgpu_app::clouds
diff --git a/apps/webgpu_app/cloud/CloudsManager.h b/apps/webgpu_app/cloud/CloudsManager.h
new file mode 100644
index 000000000..d76c77bd1
--- /dev/null
+++ b/apps/webgpu_app/cloud/CloudsManager.h
@@ -0,0 +1,102 @@
+/*****************************************************************************
+ * weBIGeo
+ * Copyright (C) 2026 Wendelin Muth
+ * Copyright (C) 2026 Gerald Kimmersdorfer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *****************************************************************************/
+
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+
+namespace webgpu_app::clouds {
+struct DateComponents {
+ int year;
+ int month;
+ int day;
+ int hour;
+};
+
+struct TileSetInfo {
+ QString id; // "2026040812" (YYYYMMDDHH, target hour)
+ DateComponents date;
+ QString folder; // "2026040809_003" (on-disk folder name)
+ int step = 0; // extracted from folder suffix (_SSS)
+ qint64 size = 0; // bytes
+
+ [[nodiscard]] std::string format_string() const;
+};
+
+class APIService : public QObject {
+ Q_OBJECT
+public:
+ explicit APIService(QObject* parent = nullptr);
+
+ void refresh_tileset_list();
+
+ [[nodiscard]] const QVector& get_slots() const;
+ [[nodiscard]] const QHash& get_slots_map() const;
+ [[nodiscard]] TileSetInfo get_slot(const QString& id) const;
+
+ [[nodiscard]] const QString& server_url() const { return m_server_url; }
+
+ void fetch_shadow_texture(const QString& id);
+
+signals:
+ // fired once after refresh_tileset_list() completes; ok=false on network/parse error
+ void tileset_list_loaded(bool ok);
+ // fired when the shadow.ktx2 binary for slot has been fully downloaded
+ void shadow_texture_loaded(const TileSetInfo& slot, const QByteArray& data);
+
+private:
+ static DateComponents parse_timestamp_id(const QString& id);
+
+ QNetworkAccessManager* m_network_manager;
+
+ // ordered list of ready tile sets (ascending target time)
+ QVector m_slots;
+ // maps TileSetInfo::id -> index into m_slots
+ QHash m_id_to_index;
+
+ const QString m_server_url = "https://atlas.cg.tuwien.ac.at/webigeo-clouds/v2"; // http://localhost:8000/v2, https://atlas.cg.tuwien.ac.at/webigeo-clouds/v2
+};
+
+class Manager : public QObject {
+ Q_OBJECT
+public:
+ explicit Manager(QObject* parent = nullptr);
+
+ void select_time_slot(const TileSetInfo& slot);
+ void refresh_tileset_list();
+
+ [[nodiscard]] TileSetInfo selected_time_slot() const;
+ [[nodiscard]] const QVector& get_slots() const;
+ [[nodiscard]] bool is_loading() const { return m_loading; }
+ [[nodiscard]] const QString& server_url() const;
+
+signals:
+ void slot_ready(const TileSetInfo& slot);
+ void shadow_texture_ready(const QByteArray& data);
+
+private:
+ std::unique_ptr m_api_service;
+ QString m_selected_slot_id = "";
+ bool m_loading = true;
+};
+} // namespace webgpu_app::clouds
diff --git a/apps/webgpu_app/compute/NodeGraphPanel.cpp b/apps/webgpu_app/compute/NodeGraphPanel.cpp
new file mode 100644
index 000000000..6522f64f0
--- /dev/null
+++ b/apps/webgpu_app/compute/NodeGraphPanel.cpp
@@ -0,0 +1,895 @@
+/*****************************************************************************
+ * weBIGeo
+ * Copyright (C) 2025 Patrick Komon
+ * Copyright (C) 2025 Gerald Kimmersdorfer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *****************************************************************************/
+
+#include "NodeGraphPanel.h"
+
+#include "ImGuiManager.h"
+#include "nodes/NodeRendererFactory.h"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace webgpu_app {
+namespace nodes = webgpu_compute::nodes;
+
+NodeGraphPanel::NodeGraphPanel(webgpu_engine::Context* context)
+ : m_context(context)
+ , m_presets({
+ { "Snow", ":/graphs/snow.json" },
+ { "Avalanche simulation", ":/graphs/avalanche_simulation.json" },
+ { "Avalanche simulation (with exports)", ":/graphs/avalanche_simulation_with_exports.json" },
+ { "Iterative simulation (WIP)", ":/graphs/iterative_simulation_wip.json" },
+ })
+{
+}
+
+void NodeGraphPanel::ready() { load_preset(":/graphs/avalanche_simulation.json"); }
+
+void NodeGraphPanel::attach_graph(std::unique_ptr graph)
+{
+ m_owned_graph = std::move(graph);
+ m_node_graph = m_owned_graph.get();
+
+ QObject::connect(m_node_graph, &nodes::NodeGraph::run_completed, m_context, [this](webgpu_compute::GraphRunContext) {
+ if (!m_pending_first_run_notice.empty()) {
+ m_notice_state = { true, std::move(m_pending_first_run_notice) };
+ m_pending_first_run_notice.clear();
+ }
+ m_context->request_redraw();
+ });
+ QObject::connect(m_node_graph, &nodes::NodeGraph::run_failed, m_context, [this](nodes::GraphRunFailureInfo info) {
+ qWarning() << "graph run failed. " << info.node_name() << ": " << info.node_run_failure_info().message();
+ m_error_state.text = "Execution of pipeline failed.\n\nNode \"" + info.node_name() + "\" reported \"" + info.node_run_failure_info().message() + "\"";
+ m_error_state.should_open = true;
+ m_context->request_redraw();
+ });
+
+ init(*m_node_graph);
+}
+
+void NodeGraphPanel::load_preset(const std::string& resource_path)
+{
+ QFile file(QString::fromStdString(resource_path));
+ if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
+ m_error_state.text = "Cannot open preset resource:\n" + resource_path;
+ m_error_state.should_open = true;
+ return;
+ }
+ const QByteArray data = file.readAll();
+ file.close();
+ import_graph_json(data, resource_path);
+}
+
+void NodeGraphPanel::new_graph() { attach_graph(std::make_unique()); }
+
+void NodeGraphPanel::import_graph_json(const QByteArray& data, const std::string& source_name)
+{
+ QJsonParseError parse_err;
+ const QJsonDocument doc = QJsonDocument::fromJson(data, &parse_err);
+ if (doc.isNull()) {
+ m_error_state.text = "Failed to parse \"" + source_name + "\":\n" + parse_err.errorString().toStdString();
+ m_error_state.should_open = true;
+ return;
+ }
+ if (!doc.isObject()) {
+ m_error_state.text = "Invalid graph file \"" + source_name + "\": JSON root is not an object";
+ m_error_state.should_open = true;
+ return;
+ }
+
+ auto result = webgpu_compute::nodes::deserialize_node_graph(doc.object(), m_context->webgpu_ctx());
+ if (!result) {
+ m_error_state.text = "Failed to load \"" + source_name + "\":\n" + result.error();
+ m_error_state.should_open = true;
+ return;
+ }
+
+ const QJsonObject root = doc.object();
+ const QJsonObject ui_nodes = root["ui"].toObject()["nodes"].toObject();
+
+ const QString notice = root["first_run_notice"].toString();
+ m_pending_first_run_notice = notice.isEmpty() ? std::string{} : notice.toStdString();
+
+ attach_graph(std::move(*result));
+
+ if (!ui_nodes.isEmpty()) {
+ for (auto& [name, renderer] : m_node_renderers) {
+ const QString key = QString::fromStdString(name);
+ if (ui_nodes.contains(key))
+ renderer->deserialize_ui(ui_nodes[key].toObject());
+ }
+ m_force_node_positions_on_next_frame = true;
+ } else {
+ const ImVec2 center(m_window_size.x * 0.5f, m_window_size.y * 0.5f);
+ for (auto& [nodePtr, nr] : m_node_renderers_by_node)
+ nr->set_position(center);
+ m_force_node_positions_on_next_frame = true;
+ m_pending_auto_layout = true;
+ }
+}
+
+void NodeGraphPanel::render_open_dialog()
+{
+ std::vector open_paths;
+ if (ImGuiManager::FilePicker("open_graph_dialog", "Load Graph", "Graph files{.json}", m_open_dialog_wants_open, open_paths)) {
+ QFile file(QString::fromStdString(open_paths[0]));
+ if (file.open(QIODevice::ReadOnly | QIODevice::Text)) {
+ const QByteArray data = file.readAll();
+ file.close();
+ import_graph_json(data, open_paths[0]);
+ } else {
+ m_error_state.text = "Cannot open file:\n" + open_paths[0];
+ m_error_state.should_open = true;
+ }
+ }
+ m_open_dialog_wants_open = false;
+}
+
+void NodeGraphPanel::init(nodes::NodeGraph& node_graph)
+{
+ m_node_renderers.clear();
+ m_node_renderers_by_node.clear();
+ m_links.clear();
+
+ m_node_graph = &node_graph;
+ auto& nodes = m_node_graph->get_nodes();
+ for (auto& [name, node] : nodes) {
+ auto renderer = NodeRendererFactory::create(name, *node.get());
+ m_node_renderers.emplace(name, std::move(renderer));
+ m_node_renderers_by_node.emplace(node.get(), m_node_renderers.at(name).get());
+ }
+
+ rebuild_socket_id_maps();
+ rebuild_links();
+}
+
+static std::string type_to_default_name(const std::string& type_name)
+{
+ // "ComputeSnowNode" -> "compute_snow_node"
+ std::string result;
+ for (size_t i = 0; i < type_name.size(); ++i) {
+ if (i > 0 && std::isupper((unsigned char)type_name[i]))
+ result += '_';
+ result += (char)std::tolower((unsigned char)type_name[i]);
+ }
+ return result;
+}
+
+static std::string generate_node_name(const std::string& type_base, const webgpu_compute::nodes::NodeGraph* graph)
+{
+ for (int n = 1;; ++n) {
+ std::string candidate = type_base + "_" + std::to_string(n);
+ if (!graph->exists_node(candidate))
+ return candidate;
+ }
+}
+
+void NodeGraphPanel::render_add_node_popup()
+{
+ static const char* POPUP_ID = "Add Node";
+
+ if (m_open_add_node_request) {
+ if (m_registered_node_types.empty())
+ m_registered_node_types = webgpu_compute::NodeRegistry::instance().get_registered_types();
+ ImGui::OpenPopup(POPUP_ID);
+ m_open_add_node_request = false;
+ }
+
+ if (!m_open_add_node_modal)
+ ImGui::SetNextWindowPos(m_add_node_popup_pos, ImGuiCond_Appearing);
+ const bool open = m_open_add_node_modal ? ImGui::BeginPopupModal(POPUP_ID, nullptr, ImGuiWindowFlags_AlwaysAutoResize) : ImGui::BeginPopup(POPUP_ID);
+ if (!open)
+ return;
+
+ const auto& types = m_registered_node_types;
+ ImGui::SetNextItemWidth(260.0f);
+ if (ImGui::BeginCombo("Type", types[m_add_node_selected_idx].c_str())) {
+ for (int i = 0; i < (int)types.size(); ++i) {
+ const bool sel = (i == m_add_node_selected_idx);
+ if (ImGui::Selectable(types[i].c_str(), sel))
+ m_add_node_selected_idx = i;
+ if (sel)
+ ImGui::SetItemDefaultFocus();
+ }
+ ImGui::EndCombo();
+ }
+
+ const bool add_pressed = ImGui::Button("Add") || ImGui::IsKeyPressed(ImGuiKey_Enter, false);
+ if (m_open_add_node_modal) {
+ ImGui::SameLine();
+ if (ImGui::Button("Cancel"))
+ ImGui::CloseCurrentPopup();
+ }
+
+ if (add_pressed) {
+ const std::string type_name = types[m_add_node_selected_idx];
+ const std::string name = generate_node_name(type_to_default_name(type_name), m_node_graph);
+ auto node = webgpu_compute::NodeRegistry::instance().try_create(type_name, m_context->webgpu_ctx());
+ if (node) {
+ m_node_graph->add_node(name, std::move(node));
+ auto renderer = NodeRendererFactory::create(name, m_node_graph->get_node(name));
+ ImVec2 pan = ImNodes::EditorContextGetPanning();
+ ImVec2 pos;
+ if (!m_open_add_node_modal)
+ pos = ImVec2(m_add_node_popup_pos.x - m_canvas_origin.x - pan.x, m_add_node_popup_pos.y - m_canvas_origin.y - pan.y);
+ else
+ pos = ImVec2(m_window_size.x * 0.5f - m_canvas_origin.x - pan.x, m_window_size.y * 0.5f - m_canvas_origin.y - pan.y);
+ renderer->set_position(pos);
+ m_node_renderers_by_node.emplace(&m_node_graph->get_node(name), renderer.get());
+ m_node_renderers.emplace(name, std::move(renderer));
+ rebuild_socket_id_maps();
+ m_force_node_positions_on_next_frame = true;
+ }
+ ImGui::CloseCurrentPopup();
+ }
+
+ ImGui::EndPopup();
+ if (!ImGui::IsPopupOpen(POPUP_ID))
+ m_open_add_node_modal = false;
+}
+
+QByteArray NodeGraphPanel::export_graph_json() const
+{
+ QJsonObject root = webgpu_compute::nodes::serialize_node_graph(*m_node_graph);
+
+ QJsonObject ui_nodes;
+ for (const auto& [name, renderer] : m_node_renderers)
+ ui_nodes[QString::fromStdString(name)] = renderer->serialize_ui();
+ QJsonObject ui;
+ ui["nodes"] = ui_nodes;
+ root["ui"] = ui;
+
+ return QJsonDocument(root).toJson(QJsonDocument::Indented);
+}
+
+void NodeGraphPanel::render_save_dialog()
+{
+ std::vector save_paths;
+ if (ImGuiManager::FilePicker("save_graph_dialog",
+ "Save Graph",
+ "Graph files{.json}",
+ m_save_dialog_wants_open,
+ save_paths,
+ false,
+ ".",
+ ImGuiManager::FilePickerMode::Save,
+ "graph.json")) {
+ QFile file(QString::fromStdString(save_paths[0]));
+ if (file.open(QIODevice::WriteOnly | QIODevice::Text)) {
+ file.write(export_graph_json());
+ file.close();
+ ImGuiManager::finalize_save(save_paths[0]);
+ } else {
+ m_error_state.text = "Failed to open file for writing:\n" + save_paths[0];
+ m_error_state.should_open = true;
+ }
+ }
+ m_save_dialog_wants_open = false;
+}
+
+void NodeGraphPanel::calculate_window_size()
+{
+ if (!ImGui::GetCurrentContext()) {
+ m_window_size = ImVec2(0.0f, 0.0f);
+ return;
+ }
+ m_window_size = ImGui::GetIO().DisplaySize;
+ m_window_size.x -= 430;
+}
+
+void NodeGraphPanel::calculate_auto_layout()
+{
+ m_target_layout.clear();
+
+ // Step 1: Identify root nodes (no inputs) and enqueue them at x = 0
+ std::queue> queue;
+ for (auto& [name, nr] : m_node_renderers) {
+ nodes::Node* node = nr->get_node();
+ if (node->input_sockets().empty()) {
+ int x = 0;
+ m_target_layout[node] = ImVec2(x, 0);
+ queue.emplace(nr.get(), x);
+ }
+ }
+
+ // Step 2: Perform BFS to assign x-layer positions based on the maximum distance from root nodes
+ while (!queue.empty()) {
+ auto [nr, current_x] = queue.front();
+ queue.pop();
+
+ nodes::Node* current_node = nr->get_node();
+ const auto& outputs = current_node->output_sockets();
+ for (const auto& os : outputs) {
+ for (auto* conn : os.connected_sockets()) {
+ nodes::Node* target_node = &conn->node();
+ NodeRenderer* target = m_node_renderers_by_node[target_node];
+
+ int x = current_x + 1;
+ m_target_layout[target_node] = ImVec2(x, 0);
+ queue.emplace(target, x);
+ }
+ }
+ }
+
+ // Step 3: Group nodes by x-layer and assign sequential y-indices
+ std::unordered_map> x_buckets;
+ x_buckets.reserve(m_node_renderers.size());
+
+ for (auto& [name, nr] : m_node_renderers) {
+ int x = (int)m_target_layout[nr->get_node()].x;
+ x_buckets[x].push_back(nr.get());
+ }
+
+ for (auto& [x, vec] : x_buckets) {
+ int y = 0;
+ for (auto* r : vec)
+ m_target_layout[r->get_node()] = ImVec2((float)x, (float)y++);
+ }
+
+ // Step 4: Compute pixel layout. Determine column widths, x-offsets, and center vertically
+ std::vector cols;
+ cols.reserve(x_buckets.size());
+ for (auto& kv : x_buckets)
+ cols.push_back(kv.first);
+ std::sort(cols.begin(), cols.end());
+
+ std::unordered_map col_width;
+ col_width.reserve(cols.size());
+
+ for (int cx : cols) {
+ float wmax = 0.f;
+ for (NodeRenderer* r : x_buckets[cx]) {
+ ImVec2 sz = r->get_size();
+ wmax = std::max(wmax, sz.x);
+ }
+ col_width[cx] = wmax;
+ }
+
+ std::unordered_map col_x_offset;
+ col_x_offset.reserve(cols.size());
+
+ float x_cursor = 0.f;
+ for (size_t i = 0; i < cols.size(); ++i) {
+ int cx = cols[i];
+ if (i == 0)
+ col_x_offset[cx] = x_cursor;
+ else
+ col_x_offset[cx] = (x_cursor += col_width[cols[i - 1]] + m_initial_node_spacing.x);
+ }
+
+ float frame_height = 0.f;
+ for (int cx : cols) {
+ float hsum = 0.f;
+ for (NodeRenderer* r : x_buckets[cx]) {
+ ImVec2 sz = r->get_size();
+ hsum += sz.y + m_initial_node_spacing.y;
+ }
+ if (!x_buckets[cx].empty())
+ hsum -= m_initial_node_spacing.y;
+ frame_height = std::max(frame_height, hsum);
+ }
+
+ for (int cx : cols) {
+ float column_height = 0.f;
+ for (NodeRenderer* r : x_buckets[cx])
+ column_height += r->get_size().y + m_initial_node_spacing.y;
+
+ if (!x_buckets[cx].empty())
+ column_height -= m_initial_node_spacing.y;
+
+ float y_cursor = (frame_height - column_height) * 0.5f;
+
+ for (NodeRenderer* r : x_buckets[cx]) {
+ ImVec2 sz = r->get_size();
+ m_target_layout[r->get_node()] = ImVec2(col_x_offset[cx], y_cursor);
+ y_cursor += sz.y + m_initial_node_spacing.y;
+ }
+ }
+
+ center_target_layout();
+}
+
+void NodeGraphPanel::recenter_graph()
+{
+ m_target_layout.clear();
+ for (auto& [nodePtr, nr] : m_node_renderers_by_node)
+ m_target_layout[nodePtr] = nr->get_position();
+ center_target_layout();
+ for (auto& [nodePtr, pos] : m_target_layout)
+ m_node_renderers_by_node[nodePtr]->set_position(pos);
+ m_force_node_positions_on_next_frame = true;
+}
+
+void NodeGraphPanel::reset_graph_layout()
+{
+ calculate_auto_layout();
+ for (auto& [nodePtr, pos] : m_target_layout)
+ m_node_renderers_by_node[nodePtr]->set_position(pos);
+ m_force_node_positions_on_next_frame = true;
+}
+
+void NodeGraphPanel::center_target_layout()
+{
+ // Get AABB of target layout
+ ImVec4 aabb(FLT_MAX, FLT_MAX, -FLT_MAX, -FLT_MAX);
+ for (auto& [nodePtr, pos] : m_target_layout) {
+ ImVec2 s = m_node_renderers_by_node[nodePtr]->get_size();
+ aabb.x = std::min(aabb.x, pos.x); // minX
+ aabb.y = std::min(aabb.y, pos.y); // minY
+ aabb.z = std::max(aabb.z, pos.x + s.x); // maxX
+ aabb.w = std::max(aabb.w, pos.y + s.y); // maxY
+ }
+ float graph_width = aabb.z - aabb.x;
+ float graph_height = aabb.w - aabb.y;
+ float offset_x = (m_window_size.x - graph_width) * 0.5f - aabb.x;
+ float offset_y = (m_window_size.y - graph_height) * 0.5f - aabb.y;
+ // Apply offset to target layout
+ for (auto& [nodePtr, pos] : m_target_layout) {
+ pos.x += offset_x;
+ pos.y += offset_y;
+ }
+}
+
+void NodeGraphPanel::push_style()
+{
+ // Always use transparent grid background
+ ImNodes::PushColorStyle(ImNodesCol_GridBackground, IM_COL32(50, 50, 50, 0));
+
+ if (m_render_mode == GraphRenderingMode::Default) {
+ ImNodes::PushColorStyle(ImNodesCol_GridLine, IM_COL32(200, 200, 200, 40)); // light gray
+ ImGui::PushStyleColor(ImGuiCol_WindowBg, ImGui::GetStyleColorVec4(ImGuiCol_WindowBg)); // default ImGui bg
+
+ } else if (m_render_mode == GraphRenderingMode::Transparent) {
+ ImNodes::PushColorStyle(ImNodesCol_GridLine, IM_COL32(200, 200, 200, 40));
+ ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.0f, 0.0f, 0.0f, 0.0f)); // fully transparent
+
+ } else if (m_render_mode == GraphRenderingMode::White) {
+ ImNodes::PushColorStyle(ImNodesCol_GridLine, IM_COL32(200, 200, 200, 40));
+ ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(1.0f, 1.0f, 1.0f, 1.0f));
+ }
+}
+
+void NodeGraphPanel::pop_style()
+{
+ ImGui::PopStyleColor(); // ImGui window background
+ ImNodes::PopColorStyle(); // Grid line
+ ImNodes::PopColorStyle(); // Grid background
+}
+
+void NodeGraphPanel::draw()
+{
+ if (m_pending_preset_path) {
+ load_preset(*m_pending_preset_path);
+ m_pending_preset_path.reset();
+ m_context->request_redraw();
+ }
+
+ ImGuiManager::FloatingToggleButton("###ToggleGraphRenderer", ICON_FA_NETWORK_WIRED, "Toggle compute graph editor", &m_editor_visible);
+ render_error_modal();
+ render_first_run_notice_modal();
+ render_save_dialog();
+ render_open_dialog();
+
+ if (!m_editor_visible)
+ return;
+
+ calculate_window_size();
+
+ if (m_pending_auto_layout) {
+ const bool all_measured = std::all_of(m_node_renderers.begin(), m_node_renderers.end(), [](const auto& kv) { return kv.second->is_size_known(); });
+ if (all_measured) {
+ calculate_auto_layout();
+ for (auto& [nodePtr, pos] : m_target_layout)
+ m_node_renderers_by_node[nodePtr]->set_position(pos);
+ m_force_node_positions_on_next_frame = true;
+ m_pending_auto_layout = false;
+ }
+ }
+
+ push_style();
+
+ ImGui::SetNextWindowPos(ImVec2(0, 0), ImGuiCond_Always);
+ ImGui::SetNextWindowSize(m_window_size, ImGuiCond_Always);
+
+ ImGui::Begin("Compute Graph Editor",
+ nullptr,
+ ImGuiWindowFlags_MenuBar | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoBringToFrontOnFocus
+ | ImGuiWindowFlags_NoTitleBar);
+
+ render_menu();
+
+ m_canvas_origin = ImGui::GetCursorScreenPos();
+ if (m_use_small_font && ImGuiManager::s_node_font)
+ ImGui::PushFont(ImGuiManager::s_node_font);
+ ImNodes::BeginNodeEditor();
+
+ // NOTE: This is a hack to disable interactions when the cursor is inside the settings panel.
+ // which is sadly necessary because imnodes doesnt respect the Imguis z order
+ {
+ ImGuiWindow* settings_win = ImGui::FindWindowByName("Node Settings");
+ if (settings_win && settings_win->Rect().Contains(ImGui::GetIO().MousePos)) {
+ GImNodes->MousePos = ImVec2(-FLT_MAX, -FLT_MAX);
+ GImNodes->LeftMouseClicked = false;
+ GImNodes->LeftMouseDragging = false;
+ }
+ }
+
+ // draw nodes
+ const bool apply_positions = m_force_node_positions_on_next_frame;
+ m_force_node_positions_on_next_frame = false;
+ for (auto& [name, node_renderer] : m_node_renderers) {
+ node_renderer->render(apply_positions);
+ }
+
+ // draw links
+ for (size_t i = 0; i < m_links.size(); ++i) {
+ const auto& [input_attr_id, output_attr_id] = m_links[i];
+ if (auto it = m_input_socket_by_id.find(input_attr_id); it != m_input_socket_by_id.end()) {
+ const ImU32 color = NodeRenderer::pin_color_for_type(it->second->type());
+ ImNodes::PushColorStyle(ImNodesCol_Link, color);
+ ImNodes::PushColorStyle(ImNodesCol_LinkHovered, color);
+ ImNodes::PushColorStyle(ImNodesCol_LinkSelected, color);
+ ImNodes::Link(int(i), input_attr_id, output_attr_id);
+ ImNodes::PopColorStyle();
+ ImNodes::PopColorStyle();
+ ImNodes::PopColorStyle();
+ } else {
+ ImNodes::Link(int(i), input_attr_id, output_attr_id);
+ }
+ }
+
+ ImNodes::MiniMap(0.1f, ImNodesMiniMapLocation_BottomRight);
+ ImNodes::EndNodeEditor();
+ if (m_use_small_font && ImGuiManager::s_node_font)
+ ImGui::PopFont();
+
+ int start_attr_id, end_attr_id;
+ if (ImNodes::IsLinkCreated(&start_attr_id, &end_attr_id)) {
+ nodes::OutputSocket* output_socket = nullptr;
+ nodes::InputSocket* input_socket = nullptr;
+
+ if (m_output_socket_by_id.count(start_attr_id) && m_input_socket_by_id.count(end_attr_id)) {
+ output_socket = m_output_socket_by_id.at(start_attr_id);
+ input_socket = m_input_socket_by_id.at(end_attr_id);
+ } else if (m_output_socket_by_id.count(end_attr_id) && m_input_socket_by_id.count(start_attr_id)) {
+ output_socket = m_output_socket_by_id.at(end_attr_id);
+ input_socket = m_input_socket_by_id.at(start_attr_id);
+ }
+
+ if (output_socket && input_socket && output_socket->type() == input_socket->type()) {
+ input_socket->connect(*output_socket);
+ m_node_graph->connect_node_signals_and_slots();
+ rebuild_links();
+ }
+ }
+
+ ImGui::End();
+
+ pop_style();
+
+ render_settings_panel();
+
+ for (auto& [name, node_renderer] : m_node_renderers) {
+ node_renderer->render_dialogs();
+ }
+
+ poll_keyboard_shortcuts();
+ render_add_node_popup();
+}
+
+void NodeGraphPanel::poll_keyboard_shortcuts()
+{
+ if (!ImGui::GetIO().WantTextInput) {
+ const bool alt = ImGui::GetIO().KeyAlt;
+ const bool shift = ImGui::GetIO().KeyShift;
+ if (alt && ImGui::IsKeyPressed(ImGuiKey_M, false))
+ m_render_mode = static_cast((static_cast(m_render_mode) + 1) % 3);
+ if (alt && ImGui::IsKeyPressed(ImGuiKey_F, false))
+ reset_graph_layout();
+ if (alt && ImGui::IsKeyPressed(ImGuiKey_C, false))
+ recenter_graph();
+ if (shift && ImGui::IsKeyPressed(ImGuiKey_R, false))
+ m_node_graph->run();
+ if (ImGui::IsKeyPressed(ImGuiKey_Delete))
+ delete_selected_nodes();
+ if (shift && ImGui::IsKeyPressed(ImGuiKey_A, false)) {
+ m_add_node_popup_pos = ImGui::GetMousePos();
+ m_open_add_node_modal = false;
+ m_open_add_node_request = true;
+ }
+ }
+}
+
+void NodeGraphPanel::delete_selected_nodes()
+{
+ const int num_selected = ImNodes::NumSelectedNodes();
+ if (num_selected == 0)
+ return;
+
+ std::vector selected_ids(num_selected);
+ ImNodes::GetSelectedNodes(selected_ids.data());
+
+ std::vector to_delete;
+ for (int node_id : selected_ids) {
+ for (auto& [name, renderer] : m_node_renderers) {
+ if (renderer->get_node_id() == node_id) {
+ to_delete.push_back(name);
+ break;
+ }
+ }
+ }
+
+ for (const auto& name : to_delete) {
+ auto it = m_node_renderers.find(name);
+ if (it == m_node_renderers.end())
+ continue;
+ m_node_renderers_by_node.erase(it->second->get_node());
+ m_node_renderers.erase(it);
+ m_node_graph->remove_node(name);
+ }
+
+ rebuild_socket_id_maps();
+ rebuild_links();
+ if (!m_node_graph->get_nodes().empty())
+ m_node_graph->connect_node_signals_and_slots();
+}
+
+void NodeGraphPanel::rename_selected_node(const std::string& old_name, const std::string& new_name)
+{
+ m_node_graph->rename_node(old_name, new_name);
+
+ auto it = m_node_renderers.find(old_name);
+ auto renderer = std::move(it->second);
+ renderer->rename(new_name);
+ m_node_renderers.erase(it);
+ m_node_renderers.emplace(new_name, std::move(renderer));
+
+ rebuild_socket_id_maps();
+ rebuild_links();
+
+ m_rename_current_node = new_name;
+}
+
+void NodeGraphPanel::rebuild_links()
+{
+ m_links.clear();
+ auto& nodes = m_node_graph->get_nodes();
+ for (auto& [name, node_renderer] : m_node_renderers) {
+ const auto& node = *nodes.at(name).get();
+ for (const auto& input_socket : node.input_sockets()) {
+ if (!input_socket.is_socket_connected())
+ continue;
+ const int input_attr_id = node_renderer->get_input_socket_id(input_socket.name());
+ const nodes::Node& connected_node = input_socket.connected_socket().node();
+ const NodeRenderer* connected_renderer = m_node_renderers_by_node.at(&connected_node);
+ const int output_attr_id = connected_renderer->get_output_socket_id(input_socket.connected_socket().name());
+ m_links.emplace_back(input_attr_id, output_attr_id);
+ }
+ }
+}
+
+void NodeGraphPanel::rebuild_socket_id_maps()
+{
+ m_input_socket_by_id.clear();
+ m_output_socket_by_id.clear();
+ auto& nodes = m_node_graph->get_nodes();
+ for (auto& [name, node_renderer] : m_node_renderers) {
+ auto& node = *nodes.at(name).get();
+ for (auto& socket : node.input_sockets())
+ m_input_socket_by_id[node_renderer->get_input_socket_id(socket.name())] = &socket;
+ for (auto& socket : node.output_sockets())
+ m_output_socket_by_id[node_renderer->get_output_socket_id(socket.name())] = &socket;
+ }
+}
+
+void NodeGraphPanel::render_error_modal()
+{
+ if (m_error_state.should_open) {
+ ImGui::OpenPopup("Error");
+ m_error_state.should_open = false;
+ }
+
+ // Always center this window when appearing
+ ImVec2 center = ImGui::GetMainViewport()->GetCenter();
+ ImGui::SetNextWindowPos(center, ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
+
+ if (ImGui::BeginPopupModal("Error", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
+ ImGui::PushTextWrapPos(30.0f * ImGui::GetFontSize());
+ ImGui::Text("%s", m_error_state.text.c_str());
+ ImGui::PopTextWrapPos();
+
+ ImGui::Separator();
+ if (ImGui::Button("OK", ImVec2(120, 0))) {
+ ImGui::CloseCurrentPopup();
+ }
+ ImGui::EndPopup();
+ }
+}
+
+void NodeGraphPanel::render_first_run_notice_modal()
+{
+ if (m_notice_state.should_open) {
+ ImGui::OpenPopup("first_run_notice");
+ m_notice_state.should_open = false;
+ }
+
+ ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
+ ImGui::SetNextWindowSize(ImVec2(400, 0));
+
+ if (ImGui::BeginPopupModal("first_run_notice", nullptr, ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_AlwaysAutoResize)) {
+ ImGui::PushTextWrapPos(ImGui::GetContentRegionAvail().x);
+ ImGui::TextWrapped("%s", m_notice_state.text.c_str());
+ ImGui::PopTextWrapPos();
+ ImGui::Spacing();
+ const float button_width = 150.0f;
+ ImGui::SetCursorPosX(ImGui::GetContentRegionAvail().x - button_width + ImGui::GetStyle().WindowPadding.x);
+ if (ImGui::Button("OK", ImVec2(button_width, 0)))
+ ImGui::CloseCurrentPopup();
+ ImGui::EndPopup();
+ }
+}
+
+void NodeGraphPanel::render_menu()
+{
+ if (ImGui::BeginMenuBar()) {
+ if (ImGui::BeginMenu("File")) {
+ if (ImGui::MenuItem(ICON_FA_FILE " New Graph"))
+ new_graph();
+ ImGui::Separator();
+ if (ImGui::BeginMenu(ICON_FA_FOLDER_OPEN " Load Preset")) {
+ for (const auto& preset : m_presets) {
+ if (ImGui::MenuItem(preset.name.c_str()))
+ m_pending_preset_path = preset.resource_path;
+ }
+ ImGui::EndMenu();
+ }
+ if (ImGui::MenuItem(ICON_FA_FILE_IMPORT " Load from File..."))
+ m_open_dialog_wants_open = true;
+ ImGui::Separator();
+ if (ImGui::MenuItem(ICON_FA_SAVE " Save to File..."))
+ m_save_dialog_wants_open = true;
+ ImGui::EndMenu();
+ }
+
+ if (ImGui::BeginMenu("Graph")) {
+ if (ImGui::MenuItem(ICON_FA_PLAY " Run Full Graph", "Shift+R"))
+ m_node_graph->run();
+ ImGui::Separator();
+ if (ImGui::MenuItem(ICON_FA_PLUS " Add Node", "Shift+A")) {
+ m_open_add_node_modal = true;
+ m_open_add_node_request = true;
+ }
+ if (ImGui::MenuItem(ICON_FA_TRASH " Delete Selected", "Del", false, ImNodes::NumSelectedNodes() > 0))
+ delete_selected_nodes();
+ ImGui::Separator();
+ if (ImGui::MenuItem(ICON_FA_TH " Apply Auto-Layout", "Alt+F"))
+ reset_graph_layout();
+ if (ImGui::MenuItem(ICON_FA_COMPRESS_ARROWS_ALT " Recenter Graph", "Alt+C"))
+ recenter_graph();
+ ImGui::EndMenu();
+ }
+
+ if (ImGui::BeginMenu("View")) {
+ if (ImGui::MenuItem(ICON_FA_ADJUST " Toggle Background Mode", "Alt+M")) {
+ m_render_mode = static_cast((static_cast(m_render_mode) + 1) % 3);
+ }
+ ImGui::Separator();
+ const char* mode_name = m_render_mode == GraphRenderingMode::Default ? "Default"
+ : m_render_mode == GraphRenderingMode::Transparent ? "Transparent"
+ : "White";
+ ImGui::Text("Current Mode: %s", mode_name);
+ ImGui::Separator();
+ ImGui::MenuItem(ICON_FA_FONT " Small Font", nullptr, &m_use_small_font);
+ ImGui::EndMenu();
+ }
+
+ ImGui::EndMenuBar();
+ }
+}
+
+NodeRenderer* NodeGraphPanel::find_selected_node_renderer() const
+{
+ if (ImNodes::NumSelectedNodes() != 1)
+ return nullptr;
+ int node_id;
+ ImNodes::GetSelectedNodes(&node_id);
+ for (const auto& [name, renderer] : m_node_renderers) {
+ if (renderer->get_node_id() == node_id)
+ return renderer.get();
+ }
+ return nullptr;
+}
+
+void NodeGraphPanel::render_settings_panel()
+{
+ NodeRenderer* selected = find_selected_node_renderer();
+
+ constexpr float panel_width = 430.0f;
+ constexpr float panel_margin = 11.0f;
+ constexpr float panel_margin_top = 26.0f;
+ ImGui::SetNextWindowPos({ m_window_size.x - panel_width - panel_margin, panel_margin + panel_margin_top }, ImGuiCond_Always);
+ ImGui::SetNextWindowSizeConstraints({ panel_width, 0 }, { panel_width, m_window_size.y - panel_margin * 2 });
+ ImGui::Begin("Node Settings",
+ nullptr,
+ ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar);
+
+ if (selected) {
+ // Sync buffer when a different node is selected
+ const std::string& raw_name = selected->get_name();
+ if (m_rename_current_node != raw_name) {
+ m_rename_current_node = raw_name;
+ strncpy(m_rename_buf, raw_name.c_str(), sizeof(m_rename_buf) - 1);
+ m_rename_buf[sizeof(m_rename_buf) - 1] = '\0';
+ }
+
+ const std::string buf_str(m_rename_buf);
+ const bool is_valid = !buf_str.empty() && (buf_str == raw_name || !m_node_graph->exists_node(buf_str));
+
+ if (!is_valid)
+ ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.55f, 0.1f, 0.1f, 1.0f));
+
+ const float input_width = std::max(80.0f, ImGui::CalcTextSize(m_rename_buf).x + 16.0f);
+ ImGui::SetNextItemWidth(input_width);
+ const bool changed = ImGui::InputText("##nodename", m_rename_buf, sizeof(m_rename_buf));
+
+ if (!is_valid)
+ ImGui::PopStyleColor();
+
+ ImGui::SameLine();
+ ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32(40, 70, 120, 200));
+ ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32(40, 70, 120, 200));
+ ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32(40, 70, 120, 200));
+ ImGui::SmallButton(selected->get_node()->get_type_name().c_str());
+ ImGui::PopStyleColor(3);
+
+ if (changed) {
+ // Read m_rename_buf AFTER InputText has written the new value into it
+ const std::string new_name(m_rename_buf);
+ const bool new_valid = !new_name.empty() && (new_name == raw_name || !m_node_graph->exists_node(new_name));
+ if (new_valid && new_name != raw_name)
+ rename_selected_node(raw_name, new_name);
+ }
+
+ ImGui::Separator();
+ bool enabled = selected->get_node()->is_enabled();
+ if (ImGui::Checkbox("Enabled", &enabled))
+ selected->get_node()->set_enabled(enabled);
+ if (selected->has_settings()) {
+ ImGui::Separator();
+ selected->render_settings_content();
+ }
+ } else {
+ ImGui::TextDisabled("Select a node to view settings.");
+ }
+
+ ImGui::End();
+}
+
+} // namespace webgpu_app
diff --git a/apps/webgpu_app/compute/NodeGraphPanel.h b/apps/webgpu_app/compute/NodeGraphPanel.h
new file mode 100644
index 000000000..5cae41f99
--- /dev/null
+++ b/apps/webgpu_app/compute/NodeGraphPanel.h
@@ -0,0 +1,169 @@
+/*****************************************************************************
+ * weBIGeo
+ * Copyright (C) 2025 Patrick Komon
+ * Copyright (C) 2025 Gerald Kimmersdorfer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *****************************************************************************/
+
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "nodes/NodeRenderer.h"
+#include "ui/ImGuiPanel.h"
+#include
+
+namespace webgpu_engine {
+class Context;
+}
+
+namespace webgpu_compute::nodes {
+class InputSocket;
+class OutputSocket;
+} // namespace webgpu_compute::nodes
+
+namespace webgpu_app {
+namespace nodes = webgpu_compute::nodes;
+
+class NodeGraphPanel : public ImGuiPanel {
+public:
+ explicit NodeGraphPanel(webgpu_engine::Context* context);
+
+ // Loads the default pipeline preset into the contexts compute graph (called after init).
+ void ready() override;
+ void draw() override;
+
+ enum class GraphRenderingMode { Default, Transparent, White };
+
+private:
+ // (Re)builds the node renderers for the currently loaded graph.
+ void init(nodes::NodeGraph& node_graph);
+
+ // Loads a preset graph from a Qt resource path, wires signals, and inits.
+ void load_preset(const std::string& resource_path);
+
+ // Replaces the current graph with a new empty graph.
+ void new_graph();
+
+ struct GraphPreset {
+ std::string name;
+ std::string resource_path;
+ };
+
+ void render_error_modal();
+ void render_first_run_notice_modal();
+
+ webgpu_engine::Context* m_context = nullptr;
+ std::unique_ptr m_owned_graph; // the panel owns the active compute graph
+ nodes::NodeGraph* m_node_graph = nullptr;
+
+ std::vector m_presets;
+ std::optional m_pending_preset_path;
+
+ uint32_t m_editor_visible = 0;
+
+ struct ErrorModalState {
+ bool should_open = false;
+ std::string text;
+ };
+ ErrorModalState m_error_state;
+
+ struct NoticeModalState {
+ bool should_open = false;
+ std::string text;
+ };
+ NoticeModalState m_notice_state;
+ std::string m_pending_first_run_notice;
+
+ ImVec2 m_window_size = ImVec2(0, 0);
+
+ std::unordered_map m_target_layout;
+
+ bool m_force_node_positions_on_next_frame = false;
+
+ ImVec2 m_initial_node_spacing = ImVec2(50.0f, 50.0f);
+ ImVec2 m_canvas_origin = { 0, 0 }; // screen-space top-left of the ImNodes canvas
+
+ std::unordered_map> m_node_renderers;
+ std::unordered_map m_node_renderers_by_node;
+ std::vector> m_links;
+
+ std::unordered_map m_input_socket_by_id;
+ std::unordered_map m_output_socket_by_id;
+
+ // Current rendering mode for the graph background and grid.
+ GraphRenderingMode m_render_mode = GraphRenderingMode::Default;
+
+ bool m_use_small_font = true;
+
+ bool m_save_dialog_wants_open = false;
+ bool m_open_dialog_wants_open = false;
+ bool m_pending_auto_layout = false;
+
+ // Add-node popup (Shift+A) and modal (menu)
+ bool m_open_add_node_request = false;
+ bool m_open_add_node_modal = false;
+ ImVec2 m_add_node_popup_pos = { 0, 0 };
+ std::vector m_registered_node_types; // populated lazily on first open
+ int m_add_node_selected_idx = 0;
+
+ // Inline rename state (settings panel)
+ char m_rename_buf[128] = {};
+ std::string m_rename_current_node; // raw name of node whose name is in m_rename_buf
+
+private:
+ // Serializes the current graph (engine + UI positions) as indented JSON bytes.
+ QByteArray export_graph_json() const;
+ void render_save_dialog();
+ void render_open_dialog();
+ void render_add_node_popup();
+
+ // Takes ownership of a new graph, wires run/error signals, and calls init().
+ void attach_graph(std::unique_ptr graph);
+
+ // Parses JSON bytes, deserializes the graph, applies UI positions (or auto-layouts),
+ // and swaps it in. Shows the error modal and keeps the current graph on any failure.
+ void import_graph_json(const QByteArray& data, const std::string& source_name);
+
+ void calculate_window_size();
+ void center_target_layout();
+ void calculate_auto_layout();
+
+ void recenter_graph();
+ void reset_graph_layout();
+
+ void push_style();
+ void pop_style();
+
+ void render_menu();
+ void render_settings_panel();
+ void poll_keyboard_shortcuts();
+
+ void rebuild_links();
+ void rebuild_socket_id_maps();
+ void delete_selected_nodes();
+ void rename_selected_node(const std::string& old_name, const std::string& new_name);
+
+ NodeRenderer* find_selected_node_renderer() const;
+};
+
+} // namespace webgpu_app
diff --git a/apps/webgpu_app/compute/OverlayRenderNode.cpp b/apps/webgpu_app/compute/OverlayRenderNode.cpp
new file mode 100644
index 000000000..877338623
--- /dev/null
+++ b/apps/webgpu_app/compute/OverlayRenderNode.cpp
@@ -0,0 +1,88 @@
+/*****************************************************************************
+ * weBIGeo
+ * Copyright (C) 2026 Gerald Kimmersdorfer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *****************************************************************************/
+
+#include "OverlayRenderNode.h"
+
+#include "nucleus/srs.h"
+#include
+#include
+#include
+#include
+#include
+
+namespace webgpu_compute::nodes {
+
+OverlayRenderNode::OverlayRenderNode(webgpu_engine::Context& context)
+ : OverlayRenderNode(context, OverlaySettings {})
+{
+}
+
+OverlayRenderNode::OverlayRenderNode(webgpu_engine::Context& context, const OverlaySettings& settings)
+ : Node({ InputSocket(*this, "texture", data_type()),
+ InputSocket(*this, "region aabb", data_type*>()) },
+ {})
+ , m_context(&context)
+ , m_settings(settings)
+{
+}
+
+OverlayRenderNode::~OverlayRenderNode()
+{
+ if (auto overlay = m_result_overlay.lock())
+ overlay->link_texture(nullptr);
+}
+
+void OverlayRenderNode::run_impl()
+{
+ if (input_socket("texture").is_socket_connected() && input_socket("region aabb").is_socket_connected()) {
+ const auto* texture = std::get()>(input_socket("texture").get_connected_data());
+ const auto* aabb = std::get*>()>(input_socket("region aabb").get_connected_data());
+
+ bool copy = m_settings.copy;
+ if (copy && texture && !(texture->texture().descriptor().usage & WGPUTextureUsage_CopySrc)) {
+ qWarning() << "OverlayRenderNode: source texture lacks CopySrc usage; falling back to linking instead of copying.";
+ copy = false;
+ }
+
+ auto overlay = m_result_overlay.lock();
+ if (!overlay) { // first run, or the user deleted it from the panel -> (re)create
+ overlay = std::make_shared();
+ overlay->name = "Compute Result";
+ m_context->overlay_renderer()->add_overlay(overlay);
+ m_result_overlay = overlay;
+ }
+
+ // TODO: the stitch node ignores the last col/row; trim the aabb to match. This
+ // correction should eventually move into the stitch node's "region aabb" output.
+ radix::geometry::Aabb<2, double> trimmed = *aabb;
+ trimmed.max -= glm::dvec2(nucleus::srs::tile_width(18) / 65, nucleus::srs::tile_height(18) / 65);
+ overlay->settings.aabb = trimmed;
+ if (texture) {
+ if (copy) {
+ overlay->load_texture(*texture);
+ } else {
+ overlay->link_texture(texture);
+ }
+ }
+ overlay->update_gpu_settings();
+ m_context->request_redraw();
+ }
+ complete_run();
+}
+
+} // namespace webgpu_compute::nodes
diff --git a/apps/webgpu_app/compute/OverlayRenderNode.h b/apps/webgpu_app/compute/OverlayRenderNode.h
new file mode 100644
index 000000000..34b78e30b
--- /dev/null
+++ b/apps/webgpu_app/compute/OverlayRenderNode.h
@@ -0,0 +1,69 @@
+/*****************************************************************************
+ * weBIGeo
+ * Copyright (C) 2026 Gerald Kimmersdorfer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *****************************************************************************/
+
+#pragma once
+
+#include
+#include
+#include
+#include
+
+namespace webgpu_engine {
+class Context;
+class TextureOverlay;
+} // namespace webgpu_engine
+
+namespace webgpu_compute::nodes {
+
+// A custom node that forwards the graph result to a TextureOverlay managed by the OverlayRenderer.
+// Unlike a base compute node it knows the rendering layer.
+class OverlayRenderNode : public Node {
+ Q_OBJECT
+
+public:
+ NODE_TYPE_NAME(OverlayRenderNode)
+
+ struct OverlaySettings {
+ // false: link the source texture directly (non-owning).
+ // true: copy the source into the overlay's own texture (requires CopySrc on the source).
+ bool copy = false;
+ };
+
+ explicit OverlayRenderNode(webgpu_engine::Context& context);
+ OverlayRenderNode(webgpu_engine::Context& context, const OverlaySettings& settings);
+ ~OverlayRenderNode() override;
+
+ void set_settings(const OverlaySettings& settings) { m_settings = settings; }
+ const OverlaySettings& get_settings() const { return m_settings; }
+ void serialize_settings(QJsonObject& out) const override { out["copy"] = m_settings.copy; }
+ void deserialize_settings(const QJsonObject& in) override
+ {
+ if (in.contains("copy"))
+ m_settings.copy = in["copy"].toBool(m_settings.copy);
+ }
+
+public slots:
+ void run_impl() override;
+
+private:
+ webgpu_engine::Context* m_context;
+ OverlaySettings m_settings;
+ std::weak_ptr m_result_overlay; // weak: the user may delete it in the gui
+};
+
+} // namespace webgpu_compute::nodes
diff --git a/apps/webgpu_app/compute/nodes/BufferToTextureNodeRenderer.cpp b/apps/webgpu_app/compute/nodes/BufferToTextureNodeRenderer.cpp
new file mode 100644
index 000000000..1c026dbda
--- /dev/null
+++ b/apps/webgpu_app/compute/nodes/BufferToTextureNodeRenderer.cpp
@@ -0,0 +1,69 @@
+/*****************************************************************************
+ * weBIGeo
+ * Copyright (C) 2026 Gerald Kimmersdorfer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *****************************************************************************/
+
+#include "BufferToTextureNodeRenderer.h"
+
+#include
+#include
+
+namespace webgpu_app {
+namespace nodes = webgpu_compute::nodes;
+
+BufferToTextureNodeRenderer::BufferToTextureNodeRenderer(const std::string& name, nodes::BufferToTextureNode& node)
+ : NodeRenderer(name, node)
+ , m_node(&node)
+{
+}
+
+void BufferToTextureNodeRenderer::render_settings_content()
+{
+ auto& s = m_node->settings();
+ bool changed = false;
+
+ changed |= ImGui::Checkbox("Alpha Blending", &s.use_transparency_buffer);
+ if (s.use_transparency_buffer) {
+ ImGui::DragFloat2("Alpha Bounds", &s.transparency_map_bounds.x, 1.0f, 0.0f, 1000.0f, "%.2f");
+ if (s.transparency_map_bounds.x > s.transparency_map_bounds.y)
+ s.transparency_map_bounds.x = s.transparency_map_bounds.y;
+ changed |= ImGui::IsItemDeactivatedAfterEdit();
+ }
+
+ bool interpolation = (s.texture_filter_mode == WGPUFilterMode_Linear);
+ if (ImGui::Checkbox("Interpolation & MipMaps", &interpolation)) {
+ if (interpolation) {
+ s.texture_filter_mode = WGPUFilterMode_Linear;
+ s.texture_mipmap_filter_mode = WGPUMipmapFilterMode_Linear;
+ s.texture_max_aniostropy = 16;
+ s.create_mipmaps = true;
+ } else {
+ s.texture_filter_mode = WGPUFilterMode_Nearest;
+ s.texture_mipmap_filter_mode = WGPUMipmapFilterMode_Nearest;
+ s.texture_max_aniostropy = 1;
+ s.create_mipmaps = false;
+ }
+ changed = true;
+ }
+
+ changed |= ImGui::DragFloat2("Color Bounds", &s.color_map_bounds.x, 1.0f, -10000.0f, 10000.0f, "%.2f");
+ changed |= ImGui::Checkbox("Bin Interpolation", &s.use_bin_interpolation);
+
+ if (changed)
+ m_node->rerun();
+}
+
+} // namespace webgpu_app
diff --git a/apps/webgpu_app/compute/nodes/BufferToTextureNodeRenderer.h b/apps/webgpu_app/compute/nodes/BufferToTextureNodeRenderer.h
new file mode 100644
index 000000000..9cfd32f3c
--- /dev/null
+++ b/apps/webgpu_app/compute/nodes/BufferToTextureNodeRenderer.h
@@ -0,0 +1,40 @@
+/*****************************************************************************
+ * weBIGeo
+ * Copyright (C) 2026 Gerald Kimmersdorfer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *****************************************************************************/
+
+#pragma once
+
+#include "NodeRenderer.h"
+
+namespace webgpu_compute::nodes {
+class BufferToTextureNode;
+}
+
+namespace webgpu_app {
+namespace nodes = webgpu_compute::nodes;
+
+class BufferToTextureNodeRenderer : public NodeRenderer {
+public:
+ BufferToTextureNodeRenderer(const std::string& name, nodes::BufferToTextureNode& node);
+ bool has_settings() const override { return true; }
+ void render_settings_content() override;
+
+private:
+ nodes::BufferToTextureNode* m_node;
+};
+
+} // namespace webgpu_app
diff --git a/apps/webgpu_app/compute/nodes/ComputeAvalancheTrajectoriesNodeRenderer.cpp b/apps/webgpu_app/compute/nodes/ComputeAvalancheTrajectoriesNodeRenderer.cpp
new file mode 100644
index 000000000..96444a9cf
--- /dev/null
+++ b/apps/webgpu_app/compute/nodes/ComputeAvalancheTrajectoriesNodeRenderer.cpp
@@ -0,0 +1,109 @@
+/*****************************************************************************
+ * weBIGeo
+ * Copyright (C) 2026 Gerald Kimmersdorfer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *****************************************************************************/
+
+#include "ComputeAvalancheTrajectoriesNodeRenderer.h"
+
+#include
+#include
+#include
+
+namespace webgpu_app {
+namespace nodes = webgpu_compute::nodes;
+
+using Node = nodes::ComputeAvalancheTrajectoriesNode;
+
+ComputeAvalancheTrajectoriesNodeRenderer::ComputeAvalancheTrajectoriesNodeRenderer(const std::string& name, nodes::ComputeAvalancheTrajectoriesNode& node)
+ : NodeRenderer(name, node)
+ , m_node(&node)
+{
+}
+
+void ComputeAvalancheTrajectoriesNodeRenderer::render_settings_content()
+{
+ auto settings = m_node->get_settings();
+ bool settings_changed = false;
+ bool rerun = false;
+
+ // --- General ---
+ const uint32_t min_res = 1, max_res = 32;
+ settings_changed |= ImGui::SliderScalar("Output Resolution", ImGuiDataType_U32, &settings.resolution_multiplier, &min_res, &max_res, "%ux");
+ rerun |= ImGui::IsItemDeactivatedAfterEdit();
+
+ const uint32_t min_steps = 1, max_steps = 20000;
+ settings_changed |= ImGui::DragScalar("Num steps", ImGuiDataType_U32, &settings.num_steps, 1.0f, &min_steps, &max_steps, "%u");
+ rerun |= ImGui::IsItemDeactivatedAfterEdit();
+
+ const uint32_t min_paths = 1, max_paths = 2048;
+ settings_changed
+ |= ImGui::DragScalar("Num particles per cell", ImGuiDataType_U32, &settings.num_paths_per_release_cell, 1.0f, &min_paths, &max_paths, "%u");
+ rerun |= ImGui::IsItemDeactivatedAfterEdit();
+
+ const uint32_t min_runs = 1, max_runs = 1000;
+ settings_changed |= ImGui::DragScalar("Number of Runs", ImGuiDataType_U32, &settings.num_runs, 1.0f, &min_runs, &max_runs, "%u");
+ rerun |= ImGui::IsItemDeactivatedAfterEdit();
+
+ const uint32_t min_seed = 1, max_seed = 1000000;
+ settings_changed |= ImGui::DragScalar("Random seed", ImGuiDataType_U32, &settings.random_seed, 1.0f, &min_seed, &max_seed);
+ rerun |= ImGui::IsItemDeactivatedAfterEdit();
+
+ ImGui::Separator();
+
+ // --- Physics model ---
+ if (ImGui::Combo("Model", (int*)&settings.active_model, "weBIGeo Avalanche Simulation\0Physics Less Simple\0")) {
+ settings_changed = rerun = true;
+ }
+
+ if (settings.active_model == Node::PhysicsModelType::WEBIGEO_AVALANCHE_SIMULATION) {
+ float perturbation_deg = glm::degrees(settings.max_perturbation);
+ if (ImGui::DragFloat("Max Perturbation", &perturbation_deg, 0.1f, 0.0f, 90.0f, "%.1f°")) {
+ settings.max_perturbation = glm::radians(perturbation_deg);
+ settings_changed = true;
+ }
+ rerun |= ImGui::IsItemDeactivatedAfterEdit();
+
+ settings_changed |= ImGui::DragFloat("Persistence", &settings.persistence_contribution, 0.01f, 0.0f, 0.99f, "%.2f");
+ rerun |= ImGui::IsItemDeactivatedAfterEdit();
+
+ settings_changed |= ImGui::DragFloat("Alpha", &settings.runout_flowpy.alpha, 0.01f, 0.0f, 90.0f, "%.2f°");
+ rerun |= ImGui::IsItemDeactivatedAfterEdit();
+
+ } else if (settings.active_model == Node::PhysicsModelType::PHYSICS_LESS_SIMPLE) {
+ settings_changed |= ImGui::SliderFloat("Gravity", &settings.model2.gravity, 0.0f, 15.0f, "%.2f");
+ rerun |= ImGui::IsItemDeactivatedAfterEdit();
+
+ settings_changed |= ImGui::SliderFloat("Mass", &settings.model2.mass, 0.0f, 100.0f, "%.2f");
+ rerun |= ImGui::IsItemDeactivatedAfterEdit();
+
+ settings_changed |= ImGui::SliderFloat("Drag coeff", &settings.model2.drag_coeff, 1.0f, 10000.0f, "%.0f");
+ rerun |= ImGui::IsItemDeactivatedAfterEdit();
+
+ settings_changed |= ImGui::SliderFloat("Friction coeff", &settings.model2.friction_coeff, 0.0f, 0.5f, "%.3f");
+ rerun |= ImGui::IsItemDeactivatedAfterEdit();
+
+ if (ImGui::Combo("Friction model", (int*)&settings.active_runout_model, "Coulomb\0Voellmy\0Voellmy Min Shear\0SamosAt\0")) {
+ settings_changed = rerun = true;
+ }
+ }
+
+ if (settings_changed)
+ m_node->set_settings(settings);
+ if (rerun)
+ m_node->rerun();
+}
+
+} // namespace webgpu_app
diff --git a/apps/webgpu_app/compute/nodes/ComputeAvalancheTrajectoriesNodeRenderer.h b/apps/webgpu_app/compute/nodes/ComputeAvalancheTrajectoriesNodeRenderer.h
new file mode 100644
index 000000000..bc62b4f26
--- /dev/null
+++ b/apps/webgpu_app/compute/nodes/ComputeAvalancheTrajectoriesNodeRenderer.h
@@ -0,0 +1,40 @@
+/*****************************************************************************
+ * weBIGeo
+ * Copyright (C) 2026 Gerald Kimmersdorfer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *****************************************************************************/
+
+#pragma once
+
+#include "NodeRenderer.h"
+
+namespace webgpu_compute::nodes {
+class ComputeAvalancheTrajectoriesNode;
+}
+
+namespace webgpu_app {
+namespace nodes = webgpu_compute::nodes;
+
+class ComputeAvalancheTrajectoriesNodeRenderer : public NodeRenderer {
+public:
+ ComputeAvalancheTrajectoriesNodeRenderer(const std::string& name, nodes::ComputeAvalancheTrajectoriesNode& node);
+ bool has_settings() const override { return true; }
+ void render_settings_content() override;
+
+private:
+ nodes::ComputeAvalancheTrajectoriesNode* m_node;
+};
+
+} // namespace webgpu_app
diff --git a/apps/webgpu_app/compute/nodes/ComputeReleasePointsNodeRenderer.cpp b/apps/webgpu_app/compute/nodes/ComputeReleasePointsNodeRenderer.cpp
new file mode 100644
index 000000000..79d67a061
--- /dev/null
+++ b/apps/webgpu_app/compute/nodes/ComputeReleasePointsNodeRenderer.cpp
@@ -0,0 +1,62 @@
+/*****************************************************************************
+ * weBIGeo
+ * Copyright (C) 2026 Gerald Kimmersdorfer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *****************************************************************************/
+
+#include "ComputeReleasePointsNodeRenderer.h"
+
+#include
+#include
+#include
+
+namespace webgpu_app {
+namespace nodes = webgpu_compute::nodes;
+
+ComputeReleasePointsNodeRenderer::ComputeReleasePointsNodeRenderer(const std::string& name, nodes::ComputeReleasePointsNode& node)
+ : NodeRenderer(name, node)
+ , m_node(&node)
+{
+}
+
+void ComputeReleasePointsNodeRenderer::render_settings_content()
+{
+ auto settings = m_node->get_settings();
+ bool settings_changed = false;
+ bool rerun = false;
+
+ int interval = (int)settings.sampling_interval.x;
+ if (ImGui::SliderInt("Interval", &interval, 1, 64, "%u")) {
+ settings.sampling_interval = glm::uvec2(interval);
+ settings_changed = true;
+ }
+ rerun |= ImGui::IsItemDeactivatedAfterEdit();
+
+ float min_deg = glm::degrees(settings.min_slope_angle);
+ float max_deg = glm::degrees(settings.max_slope_angle);
+ if (ImGui::DragFloatRange2("Steepness", &min_deg, &max_deg, 0.1f, 0.0f, 90.0f, "Min: %.1f°", "Max: %.1f°", ImGuiSliderFlags_AlwaysClamp)) {
+ settings.min_slope_angle = glm::radians(min_deg);
+ settings.max_slope_angle = glm::radians(max_deg);
+ settings_changed = true;
+ }
+ rerun |= ImGui::IsItemDeactivatedAfterEdit();
+
+ if (settings_changed)
+ m_node->set_settings(settings);
+ if (rerun)
+ m_node->rerun();
+}
+
+} // namespace webgpu_app
diff --git a/apps/webgpu_app/compute/nodes/ComputeReleasePointsNodeRenderer.h b/apps/webgpu_app/compute/nodes/ComputeReleasePointsNodeRenderer.h
new file mode 100644
index 000000000..e6bc0dc30
--- /dev/null
+++ b/apps/webgpu_app/compute/nodes/ComputeReleasePointsNodeRenderer.h
@@ -0,0 +1,40 @@
+/*****************************************************************************
+ * weBIGeo
+ * Copyright (C) 2026 Gerald Kimmersdorfer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *****************************************************************************/
+
+#pragma once
+
+#include "NodeRenderer.h"
+
+namespace webgpu_compute::nodes {
+class ComputeReleasePointsNode;
+}
+
+namespace webgpu_app {
+namespace nodes = webgpu_compute::nodes;
+
+class ComputeReleasePointsNodeRenderer : public NodeRenderer {
+public:
+ ComputeReleasePointsNodeRenderer(const std::string& name, nodes::ComputeReleasePointsNode& node);
+ bool has_settings() const override { return true; }
+ void render_settings_content() override;
+
+private:
+ nodes::ComputeReleasePointsNode* m_node;
+};
+
+} // namespace webgpu_app
diff --git a/apps/webgpu_app/compute/nodes/ComputeSnowNodeRenderer.cpp b/apps/webgpu_app/compute/nodes/ComputeSnowNodeRenderer.cpp
new file mode 100644
index 000000000..9cd55f57a
--- /dev/null
+++ b/apps/webgpu_app/compute/nodes/ComputeSnowNodeRenderer.cpp
@@ -0,0 +1,52 @@
+/*****************************************************************************
+ * weBIGeo
+ * Copyright (C) 2025 Patrick Komon
+ * Copyright (C) 2026 Gerald Kimmersdorfer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *****************************************************************************/
+
+#include "ComputeSnowNodeRenderer.h"
+
+#include
+#include
+
+namespace webgpu_app {
+namespace nodes = webgpu_compute::nodes;
+
+ComputeSnowNodeRenderer::ComputeSnowNodeRenderer(const std::string& name, nodes::ComputeSnowNode& node)
+ : NodeRenderer(name, node)
+ , m_snow_node(&node)
+{
+}
+
+void ComputeSnowNodeRenderer::render_settings_content()
+{
+ auto settings = m_snow_node->get_settings();
+ bool changed = false;
+
+ changed
+ |= ImGui::DragFloatRange2("Ang.-limit", &settings.min_angle, &settings.max_angle, 0.1f, 0.0f, 90.0f, "%.1f°", "%.1f°", ImGuiSliderFlags_AlwaysClamp);
+ changed |= ImGui::SliderFloat("Ang.-blend", &settings.angle_blend, 0.0f, 90.0f, "%.1f°");
+ changed |= ImGui::SliderFloat("Alt.-limit", &settings.min_altitude, 0.0f, 4000.0f, "%.1fm");
+ changed |= ImGui::SliderFloat("Alt.-variation", &settings.altitude_variation, 0.0f, 1000.0f, "%.1fm");
+ changed |= ImGui::SliderFloat("Alt.-blend", &settings.altitude_blend, 0.0f, 1000.0f, "%.1fm");
+
+ if (changed) {
+ m_snow_node->set_settings(settings);
+ m_snow_node->rerun();
+ }
+}
+
+} // namespace webgpu_app
diff --git a/apps/webgpu_app/compute/nodes/ComputeSnowNodeRenderer.h b/apps/webgpu_app/compute/nodes/ComputeSnowNodeRenderer.h
new file mode 100644
index 000000000..3574fdace
--- /dev/null
+++ b/apps/webgpu_app/compute/nodes/ComputeSnowNodeRenderer.h
@@ -0,0 +1,41 @@
+/*****************************************************************************
+ * weBIGeo
+ * Copyright (C) 2025 Patrick Komon
+ * Copyright (C) 2026 Gerald Kimmersdorfer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *****************************************************************************/
+
+#pragma once
+
+#include "NodeRenderer.h"
+
+namespace webgpu_compute::nodes {
+class ComputeSnowNode;
+}
+
+namespace webgpu_app {
+namespace nodes = webgpu_compute::nodes;
+
+class ComputeSnowNodeRenderer : public NodeRenderer {
+public:
+ ComputeSnowNodeRenderer(const std::string& name, nodes::ComputeSnowNode& node);
+ bool has_settings() const override { return true; }
+ void render_settings_content() override;
+
+private:
+ nodes::ComputeSnowNode* m_snow_node;
+};
+
+} // namespace webgpu_app
diff --git a/apps/webgpu_app/compute/nodes/ExportNodeRenderer.cpp b/apps/webgpu_app/compute/nodes/ExportNodeRenderer.cpp
new file mode 100644
index 000000000..a65d28da1
--- /dev/null
+++ b/apps/webgpu_app/compute/nodes/ExportNodeRenderer.cpp
@@ -0,0 +1,68 @@
+/*****************************************************************************
+ * weBIGeo
+ * Copyright (C) 2026 Gerald Kimmersdorfer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *****************************************************************************/
+
+#include "ExportNodeRenderer.h"
+
+#include
+#include
+#include
+
+namespace webgpu_app {
+namespace nodes = webgpu_compute::nodes;
+
+ExportNodeRenderer::ExportNodeRenderer(const std::string& name, nodes::ExportNode& node)
+ : NodeRenderer(name, node)
+ , m_node(&node)
+{
+ const auto& s = m_node->get_settings();
+ std::strncpy(m_buffer_buf, s.buffer_output_file.c_str(), sizeof(m_buffer_buf) - 1);
+ std::strncpy(m_texture_buf, s.texture_output_file.c_str(), sizeof(m_texture_buf) - 1);
+ std::strncpy(m_aabb_buf, s.aabb_output_file.c_str(), sizeof(m_aabb_buf) - 1);
+}
+
+void ExportNodeRenderer::render_settings_content()
+{
+ auto settings = m_node->get_settings();
+ bool changed = false;
+
+ auto field = [&](const char* label, char* buf, size_t buf_size, std::string& target, const char* socket_name) {
+ bool connected = m_node->input_socket(socket_name).is_socket_connected();
+ ImGui::TextUnformatted(label);
+ if (!connected) {
+ ImGui::SameLine();
+ ImGui::TextDisabled("(not connected)");
+ }
+ ImGui::SetNextItemWidth(-1);
+ if (ImGui::InputText((std::string("##") + socket_name).c_str(), buf, buf_size)) {
+ target = buf;
+ changed = true;
+ }
+ };
+
+ field("Buffer Output:", m_buffer_buf, sizeof(m_buffer_buf), settings.buffer_output_file, "buffer");
+ field("Texture Output:", m_texture_buf, sizeof(m_texture_buf), settings.texture_output_file, "texture");
+ field("AABB Output:", m_aabb_buf, sizeof(m_aabb_buf), settings.aabb_output_file, "region aabb");
+
+ if (changed)
+ m_node->set_settings(settings);
+
+ ImGui::Spacing();
+ ImGui::TextDisabled("Placeholders: {node_name}, {run_datetime}, {run_id}");
+}
+
+} // namespace webgpu_app
diff --git a/apps/webgpu_app/compute/nodes/ExportNodeRenderer.h b/apps/webgpu_app/compute/nodes/ExportNodeRenderer.h
new file mode 100644
index 000000000..ca0f3b841
--- /dev/null
+++ b/apps/webgpu_app/compute/nodes/ExportNodeRenderer.h
@@ -0,0 +1,43 @@
+/*****************************************************************************
+ * weBIGeo
+ * Copyright (C) 2026 Gerald Kimmersdorfer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *****************************************************************************/
+
+#pragma once
+
+#include "NodeRenderer.h"
+
+namespace webgpu_compute::nodes {
+class ExportNode;
+}
+
+namespace webgpu_app {
+namespace nodes = webgpu_compute::nodes;
+
+class ExportNodeRenderer : public NodeRenderer {
+public:
+ ExportNodeRenderer(const std::string& name, nodes::ExportNode& node);
+ bool has_settings() const override { return true; }
+ void render_settings_content() override;
+
+private:
+ nodes::ExportNode* m_node;
+ char m_buffer_buf[512] = {};
+ char m_texture_buf[512] = {};
+ char m_aabb_buf[512] = {};
+};
+
+} // namespace webgpu_app
diff --git a/apps/webgpu_app/compute/nodes/GPXTrackNodeRenderer.cpp b/apps/webgpu_app/compute/nodes/GPXTrackNodeRenderer.cpp
new file mode 100644
index 000000000..db5dbffb7
--- /dev/null
+++ b/apps/webgpu_app/compute/nodes/GPXTrackNodeRenderer.cpp
@@ -0,0 +1,74 @@
+/*****************************************************************************
+ * weBIGeo
+ * Copyright (C) 2026 Gerald Kimmersdorfer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *****************************************************************************/
+
+#include "GPXTrackNodeRenderer.h"
+
+#include
+#include
+#include
+#include
+#include
+
+namespace webgpu_app {
+namespace nodes = webgpu_compute::nodes;
+
+GPXTrackNodeRenderer::GPXTrackNodeRenderer(const std::string& name, nodes::GPXTrackNode& node)
+ : NodeRenderer(name, node)
+ , m_node(&node)
+ , m_dialog_id("gpxnode_" + std::to_string(get_node_id()))
+{
+ const std::string& path = m_node->get_settings().file_path;
+ std::strncpy(m_path_buffer.data(), path.c_str(), m_path_buffer.size() - 1);
+}
+
+void GPXTrackNodeRenderer::render_settings_content()
+{
+ auto settings = m_node->get_settings();
+
+ const float btn_w = ImGui::CalcTextSize("Browse...").x + ImGui::GetStyle().FramePadding.x * 2.0f;
+ ImGui::SetNextItemWidth(-btn_w - ImGui::GetStyle().ItemSpacing.x);
+ ImGui::InputText("##gpx_path", m_path_buffer.data(), m_path_buffer.size());
+ if (ImGui::IsItemDeactivatedAfterEdit()) {
+ settings.file_path = m_path_buffer.data();
+ m_node->set_settings(settings);
+ m_node->rerun();
+ }
+ ImGui::SameLine();
+ m_want_open_dialog = ImGui::Button("Browse...");
+
+ if (ImGui::Checkbox("Cache (reload only on path change)", &settings.enable_caching)) {
+ m_node->set_settings(settings);
+ m_node->rerun();
+ }
+}
+
+void GPXTrackNodeRenderer::render_dialogs()
+{
+ m_picked_files.clear();
+ if (ImGuiManager::FilePicker(
+ m_dialog_id.c_str(), "Choose GPX File", ".gpx,.*", m_want_open_dialog, m_picked_files, /*allow_multiple=*/false, m_last_dialog_directory.c_str())) {
+ m_last_dialog_directory = std::filesystem::path(m_picked_files[0]).parent_path().string();
+ auto settings = m_node->get_settings();
+ settings.file_path = m_picked_files[0];
+ std::strncpy(m_path_buffer.data(), settings.file_path.c_str(), m_path_buffer.size() - 1);
+ m_node->set_settings(settings);
+ m_node->rerun();
+ }
+}
+
+} // namespace webgpu_app
diff --git a/apps/webgpu_app/compute/nodes/GPXTrackNodeRenderer.h b/apps/webgpu_app/compute/nodes/GPXTrackNodeRenderer.h
new file mode 100644
index 000000000..13672d920
--- /dev/null
+++ b/apps/webgpu_app/compute/nodes/GPXTrackNodeRenderer.h
@@ -0,0 +1,49 @@
+/*****************************************************************************
+ * weBIGeo
+ * Copyright (C) 2026 Gerald Kimmersdorfer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *****************************************************************************/
+
+#pragma once
+
+#include "NodeRenderer.h"
+#include
+#include
+#include
+
+namespace webgpu_compute::nodes {
+class GPXTrackNode;
+}
+
+namespace webgpu_app {
+namespace nodes = webgpu_compute::nodes;
+
+class GPXTrackNodeRenderer : public NodeRenderer {
+public:
+ GPXTrackNodeRenderer(const std::string& name, nodes::GPXTrackNode& node);
+ bool has_settings() const override { return true; }
+ void render_settings_content() override;
+ void render_dialogs() override;
+
+private:
+ nodes::GPXTrackNode* m_node;
+ std::array m_path_buffer {};
+ std::string m_last_dialog_directory = ".";
+ std::string m_dialog_id;
+ std::vector m_picked_files;
+ bool m_want_open_dialog = false;
+};
+
+} // namespace webgpu_app
diff --git a/apps/webgpu_app/compute/nodes/NodeRenderer.cpp b/apps/webgpu_app/compute/nodes/NodeRenderer.cpp
new file mode 100644
index 000000000..228e284ec
--- /dev/null
+++ b/apps/webgpu_app/compute/nodes/NodeRenderer.cpp
@@ -0,0 +1,257 @@
+/*****************************************************************************
+ * weBIGeo
+ * Copyright (C) 2025 Patrick Komon
+ * Copyright (C) 2025 Gerald Kimmersdorfer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *****************************************************************************/
+
+#include "NodeRenderer.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace webgpu_app {
+namespace nodes = webgpu_compute::nodes;
+
+static std::hash hasher;
+
+ImU32 NodeRenderer::pin_color_for_type(nodes::DataType type)
+{
+ switch (type) {
+ case 0:
+ return IM_COL32(255, 160, 50, 255); // tile ID list -> orange
+ case 1:
+ return IM_COL32(150, 220, 50, 255); // QByteArray list -> yellow-green
+ case 2:
+ return IM_COL32(70, 130, 255, 255); // TileStorageTexture -> blue
+ case 3:
+ return IM_COL32(190, 80, 255, 255); // RawBuffer -> purple
+ case 4:
+ return IM_COL32(50, 210, 210, 255); // TextureWithSampler -> cyan
+ case 5:
+ return IM_COL32(255, 80, 80, 255); // Aabb -> red
+ case 6:
+ return IM_COL32(200, 200, 200, 255); // uvec2 -> gray
+ default:
+ return IM_COL32(255, 255, 255, 255);
+ }
+}
+
+ImNodesPinShape NodeRenderer::pin_shape_for_type(nodes::DataType type)
+{
+ switch (type) {
+ case 0:
+ return ImNodesPinShape_CircleFilled; // tile ID list
+ case 1:
+ return ImNodesPinShape_CircleFilled; // QByteArray list
+ case 2:
+ return ImNodesPinShape_QuadFilled; // TileStorageTexture
+ case 3:
+ return ImNodesPinShape_Quad; // RawBuffer
+ case 4:
+ return ImNodesPinShape_TriangleFilled; // TextureWithSampler
+ case 5:
+ return ImNodesPinShape_Triangle; // Aabb
+ case 6:
+ return ImNodesPinShape_Circle; // uvec2
+ default:
+ return ImNodesPinShape_CircleFilled;
+ }
+}
+
+std::string NodeRenderer::format_ms(int duration_in_ms)
+{
+ std::ostringstream ss;
+ ss << std::fixed << std::setprecision(2);
+
+ if (duration_in_ms < 1000) {
+ ss.str("");
+ ss.clear();
+ ss << duration_in_ms << " ms";
+ } else {
+ double seconds = duration_in_ms / 1000.0;
+ ss.str("");
+ ss.clear();
+ ss << seconds << " s";
+ }
+
+ return ss.str();
+}
+
+NodeRenderer::NodeRenderer(const std::string& name, nodes::Node& node)
+ : m_name(name)
+ , m_node(&node)
+ , m_node_id(int(hasher(m_name)))
+{
+ for (const auto& socket : m_node->input_sockets()) {
+ const int socket_id = int(hasher(m_name + socket.name()));
+ m_input_socket_ids.push_back(socket_id);
+ }
+
+ for (const auto& socket : m_node->output_sockets()) {
+ const int socket_id = int(hasher(m_name + socket.name()));
+ m_output_socket_ids.push_back(socket_id);
+ }
+}
+
+ImVec2 NodeRenderer::get_size() const { return m_size; }
+
+void NodeRenderer::render(bool reset_position)
+{
+ if (reset_position) {
+ ImNodes::SetNodeEditorSpacePos(m_node_id, m_position);
+ }
+
+ bool is_disabled = !m_node->is_enabled();
+ bool is_running = m_node->is_running();
+
+ if (is_disabled) {
+ ImGui::PushStyleVar(ImGuiStyleVar_Alpha, 0.3f);
+ ImNodes::PushColorStyle(ImNodesCol_TitleBar, IM_COL32(100, 100, 100, 255));
+ ImNodes::PushColorStyle(ImNodesCol_TitleBarHovered, IM_COL32(100, 100, 100, 255));
+ ImNodes::PushColorStyle(ImNodesCol_TitleBarSelected, IM_COL32(100, 100, 100, 255));
+ } else if (is_running) {
+ // Dark green title bar while running
+ ImNodes::PushColorStyle(ImNodesCol_TitleBar, IM_COL32(30, 100, 30, 255));
+ ImNodes::PushColorStyle(ImNodesCol_TitleBarHovered, IM_COL32(40, 120, 40, 255));
+ ImNodes::PushColorStyle(ImNodesCol_TitleBarSelected, IM_COL32(50, 140, 50, 255));
+ }
+
+ ImNodes::BeginNode(m_node_id);
+
+ ImNodes::BeginNodeTitleBar();
+ ImGui::TextUnformatted(m_name.c_str());
+ ImNodes::EndNodeTitleBar();
+
+ render_sockets();
+
+ ImNodes::EndNode();
+
+ // Get size of the node
+ ImVec2 min = ImGui::GetItemRectMin();
+ ImVec2 max = ImGui::GetItemRectMax();
+ m_size = ImVec2(max.x - min.x, max.y - min.y);
+
+ // Context menu (right-click on node)
+ std::string popup_id = "##ctx_" + m_name;
+ if (ImGui::IsItemClicked(ImGuiMouseButton_Right))
+ ImGui::OpenPopup(popup_id.c_str());
+
+ ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(12.0f, 8.0f));
+ ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(12.0f, 8.0f));
+ if (ImGui::BeginPopup(popup_id.c_str())) {
+ if (ImGui::MenuItem(ICON_FA_REDO " Rerun"))
+ m_node->rerun();
+ bool enabled = m_node->is_enabled();
+ if (ImGui::MenuItem(enabled ? ICON_FA_TOGGLE_OFF " Disable" : ICON_FA_TOGGLE_ON " Enable"))
+ m_node->set_enabled(!enabled);
+ ImGui::EndPopup();
+ }
+ ImGui::PopStyleVar(2);
+
+ // Get position of the node
+ m_position = ImNodes::GetNodeEditorSpacePos(m_node_id);
+
+ // Pop color/style stacks
+ if (is_disabled) {
+ ImNodes::PopColorStyle(); // selected
+ ImNodes::PopColorStyle(); // hovered
+ ImNodes::PopColorStyle(); // normal
+ ImGui::PopStyleVar();
+ } else if (is_running) {
+ ImNodes::PopColorStyle(); // selected
+ ImNodes::PopColorStyle(); // hovered
+ ImNodes::PopColorStyle(); // normal
+ }
+}
+
+void NodeRenderer::render_sockets()
+{
+ for (size_t i = 0; i < m_input_socket_ids.size(); i++) {
+ const nodes::DataType type = m_node->input_sockets().at(i).type();
+ ImNodes::PushColorStyle(ImNodesCol_Pin, pin_color_for_type(type));
+ ImNodes::PushColorStyle(ImNodesCol_PinHovered, pin_color_for_type(type));
+ ImNodes::BeginInputAttribute(m_input_socket_ids.at(i), pin_shape_for_type(type));
+ ImGui::Text("%s", m_node->input_sockets().at(i).name().c_str());
+ ImNodes::EndInputAttribute();
+ ImNodes::PopColorStyle();
+ ImNodes::PopColorStyle();
+ }
+
+ const float node_content_width = m_size.x >= 0 ? m_size.x - 1.0f : 0.0f;
+ for (size_t i = 0; i < m_output_socket_ids.size(); i++) {
+ const nodes::DataType type = m_node->output_sockets().at(i).type();
+ ImNodes::PushColorStyle(ImNodesCol_Pin, pin_color_for_type(type));
+ ImNodes::PushColorStyle(ImNodesCol_PinHovered, pin_color_for_type(type));
+ ImNodes::BeginOutputAttribute(m_output_socket_ids.at(i), pin_shape_for_type(type));
+ const char* label = m_node->output_sockets().at(i).name().c_str();
+ const float text_width = ImGui::CalcTextSize(label).x;
+ const float text_indent = std::max(0.0f, node_content_width - text_width);
+ ImGui::SetCursorPosX(ImGui::GetCursorPosX() + text_indent);
+ ImGui::TextUnformatted(label);
+ ImNodes::EndOutputAttribute();
+ ImNodes::PopColorStyle();
+ ImNodes::PopColorStyle();
+ }
+}
+
+QJsonObject NodeRenderer::serialize_ui() const { return QJsonObject { { "position", QJsonArray { m_position.x, m_position.y } } }; }
+
+void NodeRenderer::deserialize_ui(const QJsonObject& obj)
+{
+ if (obj.contains("position")) {
+ const auto arr = obj["position"].toArray();
+ if (arr.size() == 2)
+ m_position = { static_cast(arr[0].toDouble()), static_cast(arr[1].toDouble()) };
+ }
+}
+
+void NodeRenderer::rename(const std::string& new_name)
+{
+ m_name = new_name;
+ // Node ID and socket IDs are intentionally left unchanged to keep imnodes selection stable.
+}
+
+int NodeRenderer::get_input_socket_id(const std::string& input_socket_name) const
+{
+ assert(m_node->has_input_socket(input_socket_name));
+
+ for (size_t i = 0; i < m_node->input_sockets().size(); i++) {
+ if (input_socket_name == m_node->input_sockets().at(i).name()) {
+ return m_input_socket_ids.at(i);
+ }
+ }
+ qFatal() << "tried to get non-existing input socket " << input_socket_name << " from node renderer for node " << m_name;
+ return -1;
+}
+
+int NodeRenderer::get_output_socket_id(const std::string& output_socket_name) const
+{
+ assert(m_node->has_output_socket(output_socket_name));
+
+ for (size_t i = 0; i < m_node->output_sockets().size(); i++) {
+ if (output_socket_name == m_node->output_sockets().at(i).name()) {
+ return m_output_socket_ids.at(i);
+ }
+ }
+ qFatal() << "tried to get non-existing output socket " << output_socket_name << " from node renderer for node " << m_name;
+ return -1;
+}
+
+} // namespace webgpu_app
diff --git a/apps/webgpu_app/compute/nodes/NodeRenderer.h b/apps/webgpu_app/compute/nodes/NodeRenderer.h
new file mode 100644
index 000000000..145119806
--- /dev/null
+++ b/apps/webgpu_app/compute/nodes/NodeRenderer.h
@@ -0,0 +1,81 @@
+/*****************************************************************************
+ * weBIGeo
+ * Copyright (C) 2025 Patrick Komon
+ * Copyright (C) 2025 Gerald Kimmersdorfer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see