commit 2a77fc6546aa8f7ff50939d8d6fc5c9216667150 Author: emil28092005 Date: Sun Oct 25 17:29:16 2026 +0300 Initial release of Lumen Wallpaper diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7aa8e08 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +/build/ +/target/ +/dist/ +/CMakeFiles/ +.env +.env.* +!.env.example +*.log +*.swp +*.swo +*.tmp +.idea/ +.vscode/ +.cache/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3b9cffe --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +## 0.1.0 + +Initial public release. + +- Native KDE Plasma 6 wallpaper package and settings page. +- Rust/Vulkan renderer with direct Qt command-buffer integration. +- Runtime Naga GLSL-to-SPIR-V compilation and a procedural built-in shader. +- Per-monitor resolution, frame-rate limiting, speed, pause, and mouse input. +- Debounced hot reload and last-working-shader fallback with useful diagnostics. +- Per-frame uniform slots and configurable MSAA support. +- Self-extracting source-building installer for Fedora, Debian/Ubuntu, and Arch Linux. diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..a0b3764 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,68 @@ +cmake_minimum_required(VERSION 3.22) +project(lumen-wallpaper VERSION 0.1.0 LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_AUTOMOC ON) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +find_package(ECM 6.0.0 REQUIRED NO_MODULE) +set(CMAKE_MODULE_PATH ${ECM_MODULE_PATH}) +include(KDEInstallDirs) +include(KDECMakeSettings) +include(KDECompilerSettings NO_POLICY_SCOPE) + +find_package(Qt6 6.6 REQUIRED COMPONENTS Core Gui Qml Quick) + +set(LUMEN_PACKAGE_ID dev.emil.lumen) +set(LUMEN_INSTALL_DIR "${KDE_INSTALL_DATADIR}/plasma/wallpapers/${LUMEN_PACKAGE_ID}") +set(LUMEN_RUST_LIBRARY "${CMAKE_BINARY_DIR}/cargo/release/liblumen_renderer.a") + +file(GLOB_RECURSE LUMEN_RUST_SOURCES CONFIGURE_DEPENDS + "${CMAKE_SOURCE_DIR}/renderer/*.rs" +) +add_custom_command( + OUTPUT "${LUMEN_RUST_LIBRARY}" + COMMAND "${CMAKE_COMMAND}" -E env CARGO_TARGET_DIR=${CMAKE_BINARY_DIR}/cargo + cargo build --release + WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}" + DEPENDS ${LUMEN_RUST_SOURCES} + "${CMAKE_SOURCE_DIR}/Cargo.toml" + "${CMAKE_SOURCE_DIR}/Cargo.lock" + COMMENT "Building the Rust Vulkan renderer" + VERBATIM +) +add_custom_target(lumen_renderer_build DEPENDS "${LUMEN_RUST_LIBRARY}") +add_library(lumen_renderer STATIC IMPORTED GLOBAL) +set_target_properties(lumen_renderer PROPERTIES IMPORTED_LOCATION "${LUMEN_RUST_LIBRARY}") +add_dependencies(lumen_renderer lumen_renderer_build) + +add_library(lumenplugin MODULE + src/plugin.cpp + src/plugin.h + src/wallpaperitem.cpp + src/wallpaperitem.h + src/rendernode.cpp + src/rendernode.h + src/lumen_ffi.h +) +target_include_directories(lumenplugin PRIVATE "${CMAKE_SOURCE_DIR}/src") +target_link_libraries(lumenplugin PRIVATE + Qt6::Core + Qt6::Gui + Qt6::Qml + Qt6::Quick + lumen_renderer + ${CMAKE_DL_LIBS} + pthread + m +) +set_target_properties(lumenplugin PROPERTIES + PREFIX "lib" + LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/package/contents/ui/lumen" +) + +install(DIRECTORY package/ DESTINATION "${LUMEN_INSTALL_DIR}") +install(TARGETS lumenplugin + LIBRARY DESTINATION "${LUMEN_INSTALL_DIR}/contents/ui/lumen" +) diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..498c413 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,408 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bit-set" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lumen-wallpaper" +version = "0.1.0" +dependencies = [ + "ash", + "bytemuck", + "naga", +] + +[[package]] +name = "naga" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown", + "indexmap", + "libm", + "log", + "naga-types", + "num-traits", + "once_cell", + "pp-rs", + "rustc-hash", + "spirv", + "thiserror", + "unicode-ident", +] + +[[package]] +name = "naga-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49" +dependencies = [ + "hashbrown", + "indexmap", + "rustc-hash", + "thiserror", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pp-rs" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb458bb7f6e250e6eb79d5026badc10a3ebb8f9a15d1fff0f13d17c71f4d6dee" +dependencies = [ + "unicode-xid", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "spirv" +version = "0.4.0+sdk-1.4.341.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" +dependencies = [ + "bitflags", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..5485a4c --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "lumen-wallpaper" +version = "0.1.0" +edition = "2021" +rust-version = "1.87" +publish = false +description = "Procedural GLSL wallpapers for KDE Plasma 6, rendered with Rust and Vulkan" +license = "GPL-3.0-or-later" +repository = "https://git.shanaty.ru/Emil_Shanaty/Lumen-wallpaper" +readme = "README.md" + +[lib] +name = "lumen_renderer" +path = "renderer/src/lib.rs" +crate-type = ["staticlib", "rlib"] + +[[bin]] +name = "lumen-shader-check" +path = "renderer/src/bin/shader_check.rs" + +[dependencies] +ash = { version = "0.38.0", default-features = false, features = ["loaded"] } +bytemuck = { version = "1.22", features = ["derive"] } +naga = { version = "30.0.1", features = ["glsl-in", "wgsl-in", "spv-out", "termcolor"] } + +[profile.release] +lto = "thin" +codegen-units = 1 +panic = "unwind" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + 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 . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7466b2d --- /dev/null +++ b/README.md @@ -0,0 +1,120 @@ +# Lumen Wallpaper + +Procedural GLSL wallpapers for KDE Plasma 6, rendered natively with Rust and Vulkan. + +![Lumen Wallpaper](docs/images/lumen.jpg) + +Lumen is a native Plasma wallpaper package. A small C++/QML bridge connects it to Qt Quick, while shader compilation and Vulkan rendering live in Rust. Frames are recorded directly into Qt's Vulkan command buffer; there is no video, browser, OpenGL fallback, or per-frame CPU copy. + +## Features + +- Native wallpaper type and settings in KDE System Settings. +- Independent rendering at each monitor's native resolution and device pixel ratio. +- Runtime GLSL-to-SPIR-V compilation with Naga. +- Shadertoy-style `mainImage()` fragment shaders. +- Mouse interaction, animation speed, frame-rate limit, and pause. +- Hot reload with debouncing and the last working shader preserved after errors. +- Correct per-frame resources for Qt's frames in flight and configurable MSAA. + +## Requirements + +- Linux with KDE Plasma 6 and Qt Quick 6.6 or newer; Wayland is the tested configuration. +- A working Vulkan driver. Qt Quick must use its Vulkan backend. +- CMake 3.22+, Ninja, a C++20 compiler, and Rust/Cargo 1.87+ to build from source. +- Qt 6 development files, Extra CMake Modules, Vulkan headers, Kirigami, and Plasma Workspace. + +The installer can offer to install these packages on Fedora, Debian/Ubuntu, and Arch Linux. System packages are changed only after confirmation. The wallpaper itself installs into `~/.local` and does not require sudo. + +## One-file installer + +Download `lumen-wallpaper-0.1.0-installer.run` and its checksum from [Releases](https://git.shanaty.ru/Emil_Shanaty/Lumen-wallpaper/releases), then run: + +```bash +chmod +x lumen-wallpaper-0.1.0-installer.run +./lumen-wallpaper-0.1.0-installer.run +``` + +The installer extracts the included source into a temporary directory, checks dependencies, builds the plugin for the laptop's Qt version, installs it, and activates it in the running Plasma session. + +A native Plasma plugin cannot be portable across arbitrary Qt versions as a prebuilt binary. Building on the target machine is intentional and avoids Qt/C++ ABI mismatches. + +Useful options: + +```bash +./lumen-wallpaper-0.1.0-installer.run --no-deps # do not offer package installation +./lumen-wallpaper-0.1.0-installer.run --no-activate # install only +./lumen-wallpaper-0.1.0-installer.run --yes # non-interactive confirmation +./lumen-wallpaper-0.1.0-installer.run --help +``` + +`PREFIX` and `BUILD_DIR` can override the installation and build locations for an isolated installation. + +## From source + +```bash +git clone https://git.shanaty.ru/Emil_Shanaty/Lumen-wallpaper.git +cd Lumen-wallpaper +./scripts/install.sh +./scripts/activate.sh +``` + +`install.sh` only builds and installs. `activate.sh` backs up the current wallpaper configuration, selects Lumen on all desktops, configures `QSG_RHI_BACKEND=vulkan`, and restarts `plasmashell`. + +To enable it manually, open **System Settings → Wallpaper**, select **Lumen Wallpaper**, and click Apply. The Vulkan environment is loaded for new Plasma sessions from `~/.config/plasma-workspace/env/lumen-vulkan.sh`; log out and back in if necessary. + +To return to the previous wallpaper, select it in the same settings page. Activation backups are kept outside the source package in the build directory. + +## Custom shaders + +Choose a `.frag` or `.glsl` file in the wallpaper settings, or leave the path empty to use the built-in Lumen shader. A minimal shader looks like this: + +```glsl +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 uv = fragCoord / iResolution.xy; + float pulse = 0.5 + 0.5 * sin(iTime + uv.x); + fragColor = vec4(0.05, 0.25 * pulse, 0.45 * pulse, 1.0); +} +``` + +Available values include `iResolution`, `iTime`, `iTimeDelta`, `iFrameRate`, `iFrame`, `iMouse`, `iDate`, `iScreenIndex`, and `iScreenCount`. Mouse coordinates use Shadertoy's bottom-left origin. On Wayland, pointer input is available while the pointer is over the desktop. + +See `examples/Gradient.frag` for a complete example. Validate a file before selecting it: + +```bash +cargo run --bin lumen-shader-check -- /absolute/path/to/shader.frag +``` + +The source directory is not scanned for shaders, and no shader is downloaded automatically. + +### Current limitations + +- Only single-pass image shaders are supported. +- Feedback buffers A–D, `iChannel*`, textures, and audio input are not implemented yet. +- Layered Qt items and arbitrary offscreen Qt render targets are outside the supported wallpaper-surface path. +- OpenGL is not a rendering fallback; Lumen requires Qt Quick's Vulkan backend. + +## Development and release packaging + +```bash +cargo fmt --check +cargo check +./scripts/install.sh +./scripts/package-release.sh +``` + +The packaging script uses `git archive`, so local build outputs, logs, screenshots, backups, credentials, and other untracked files cannot enter a release archive. Artifacts are written to `dist/`: + +- `lumen-wallpaper-0.1.0-source.tar.gz` +- `lumen-wallpaper-0.1.0-installer.run` +- `SHA256SUMS` + +Test the self-extracting installer without changing system packages or the active wallpaper: + +```bash +PREFIX="$PWD/build/test-prefix" ./dist/lumen-wallpaper-0.1.0-installer.run \ + --no-deps --no-activate +``` + +## License + +Lumen Wallpaper is distributed under [GPL-3.0-or-later](LICENSE). diff --git a/docs/images/lumen.jpg b/docs/images/lumen.jpg new file mode 100644 index 0000000..ce8f58d Binary files /dev/null and b/docs/images/lumen.jpg differ diff --git a/examples/Gradient.frag b/examples/Gradient.frag new file mode 100644 index 0000000..06715e7 --- /dev/null +++ b/examples/Gradient.frag @@ -0,0 +1,10 @@ +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 uv = fragCoord / iResolution.xy; + float wave = 0.5 + 0.5 * sin(iTime + uv.x * 6.2831853); + vec3 deep = vec3(0.015, 0.025, 0.07); + vec3 light = vec3(0.08, 0.65, 0.78); + vec3 color = mix(deep, light, wave * (0.35 + 0.65 * uv.y)); + float glow = exp(-8.0 * dot(uv - 0.5, uv - 0.5)); + color += vec3(0.25, 0.08, 0.35) * glow; + fragColor = vec4(color, 1.0); +} diff --git a/package/contents/config/main.xml b/package/contents/config/main.xml new file mode 100644 index 0000000..1218a1a --- /dev/null +++ b/package/contents/config/main.xml @@ -0,0 +1,23 @@ + + + + + + + + 30 + + + 1.0 + + + true + + + true + + + true + + + diff --git a/package/contents/ui/config.qml b/package/contents/ui/config.qml new file mode 100644 index 0000000..09232d2 --- /dev/null +++ b/package/contents/ui/config.qml @@ -0,0 +1,111 @@ +import QtQuick +import QtQuick.Controls as Controls +import QtQuick.Dialogs +import QtQuick.Layouts +import org.kde.kirigami as Kirigami + +Item { + id: configRoot + + property alias cfg_shaderPath: shaderPath.text + property alias cfg_targetFps: fpsSlider.value + property alias cfg_speed: speedSlider.value + property alias cfg_running: runningCheck.checked + property alias cfg_mouseEnabled: mouseCheck.checked + property alias cfg_watchSource: watchCheck.checked + + implicitWidth: 560 + implicitHeight: form.implicitHeight + Kirigami.Units.gridUnit * 2 + + FileDialog { + id: shaderDialog + title: "Choose a fragment shader" + nameFilters: ["Fragment shaders (*.frag *.glsl)", "All files (*)"] + onAccepted: shaderPath.text = selectedFile.toString() + } + + Kirigami.FormLayout { + id: form + anchors { + left: parent.left + right: parent.right + top: parent.top + margins: Kirigami.Units.gridUnit + } + + RowLayout { + Kirigami.FormData.label: "Fragment shader:" + Layout.fillWidth: true + + Controls.TextField { + id: shaderPath + Layout.fillWidth: true + placeholderText: "Built-in Lumen shader" + } + Controls.Button { + text: "Browse…" + onClicked: shaderDialog.open() + } + Controls.Button { + text: "Reset" + onClicked: shaderPath.text = "" + } + } + + Controls.Label { + text: "Shadertoy-style GLSL with mainImage(). Files reload automatically when enabled below." + wrapMode: Text.Wrap + Layout.fillWidth: true + opacity: 0.7 + } + + RowLayout { + Kirigami.FormData.label: "Frame rate:" + Controls.Slider { + id: fpsSlider + from: 5 + to: 120 + stepSize: 1 + value: 30 + Layout.fillWidth: true + } + Controls.Label { + text: Math.round(fpsSlider.value) + " FPS" + Layout.minimumWidth: 64 + } + } + + RowLayout { + Kirigami.FormData.label: "Animation speed:" + Controls.Slider { + id: speedSlider + from: 0.1 + to: 3.0 + stepSize: 0.05 + value: 1.0 + Layout.fillWidth: true + } + Controls.Label { + text: speedSlider.value.toFixed(2) + "×" + Layout.minimumWidth: 64 + } + } + + Controls.CheckBox { + id: runningCheck + Kirigami.FormData.label: "Playback:" + text: "Animate wallpaper" + checked: true + } + Controls.CheckBox { + id: mouseCheck + text: "React to the pointer over the desktop" + checked: true + } + Controls.CheckBox { + id: watchCheck + text: "Reload the shader when its file changes" + checked: true + } + } +} diff --git a/package/contents/ui/lumen/qmldir b/package/contents/ui/lumen/qmldir new file mode 100644 index 0000000..f45bd46 --- /dev/null +++ b/package/contents/ui/lumen/qmldir @@ -0,0 +1,3 @@ +module dev.emil.lumen +plugin lumenplugin +classname LumenPlugin diff --git a/package/contents/ui/main.qml b/package/contents/ui/main.qml new file mode 100644 index 0000000..a27454f --- /dev/null +++ b/package/contents/ui/main.qml @@ -0,0 +1,74 @@ +import QtQuick +import org.kde.plasma.plasmoid +import "lumen" + +WallpaperItem { + id: root + + readonly property var activeConfiguration: { + if (typeof wallpaper !== "undefined" && wallpaper && wallpaper.configuration) + return wallpaper.configuration + return typeof configuration !== "undefined" ? configuration : null + } + + function option(name, fallback) { + if (!activeConfiguration) + return fallback + const value = activeConfiguration[name] + return value === undefined ? fallback : value + } + + VulkanWallpaper { + id: engine + anchors.fill: parent + shaderUrl: { + const path = String(root.option("shaderPath", "")) + return path.length > 0 ? path : Qt.resolvedUrl("shaders/Lumen.frag") + } + targetFps: Number(root.option("targetFps", 30)) + speed: Number(root.option("speed", 1.0)) + running: Boolean(root.option("running", true)) + mouseEnabled: Boolean(root.option("mouseEnabled", true)) + watchSource: Boolean(root.option("watchSource", true)) + } + + Rectangle { + anchors.fill: parent + color: "#111522" + visible: engine.errorMessage.length > 0 && !engine.fallbackActive + + Text { + anchors.centerIn: parent + width: Math.min(parent.width * 0.8, 640) + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.Wrap + color: "#f3f5fa" + font.pixelSize: 16 + text: "Lumen Wallpaper\n\n" + engine.errorMessage + } + } + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.bottomMargin: 80 + height: warningText.implicitHeight + 16 + color: "#cc421f35" + visible: engine.fallbackActive && engine.errorMessage.length > 0 + + Text { + id: warningText + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: 12 + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.Wrap + elide: Text.ElideRight + maximumLineCount: 3 + color: "#ffd9e2" + text: "Lumen: using the last working shader\n" + engine.errorMessage + } + } +} diff --git a/package/contents/ui/shaders/Lumen.frag b/package/contents/ui/shaders/Lumen.frag new file mode 100644 index 0000000..06add00 --- /dev/null +++ b/package/contents/ui/shaders/Lumen.frag @@ -0,0 +1,67 @@ +float hash21(vec2 p) { + p = fract(p * vec2(123.34, 456.21)); + p += dot(p, p + 45.32); + return fract(p.x * p.y); +} + +float noise21(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + f = f * f * (3.0 - 2.0 * f); + return mix( + mix(hash21(i), hash21(i + vec2(1.0, 0.0)), f.x), + mix(hash21(i + vec2(0.0, 1.0)), hash21(i + vec2(1.0, 1.0)), f.x), + f.y + ); +} + +float fbm(vec2 p) { + float value = 0.0; + float amplitude = 0.5; + mat2 turn = mat2(0.80, 0.60, -0.60, 0.80); + for (int i = 0; i < 5; i++) { + value += amplitude * noise21(p); + p = turn * p * 2.03 + vec2(3.1, 1.7); + amplitude *= 0.5; + } + return value; +} + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 uv = fragCoord / iResolution.xy; + float unit = min(iResolution.x, iResolution.y); + vec2 p = (2.0 * fragCoord - iResolution.xy) / unit; + vec2 mouse = (2.0 * iMouse.xy - iResolution.xy) / unit; + float t = iTime * 0.12; + + vec2 pointerDelta = p - mouse; + float pointerGlow = exp(-dot(pointerDelta, pointerDelta) * 4.0); + float pressed = iMouse.z > 0.0 ? 1.0 : 0.0; + + vec2 q = vec2( + fbm(p * 0.65 + vec2(t * 0.35, -t * 0.20)), + fbm(p * 0.65 + vec2(4.7 - t * 0.24, 2.9 + t * 0.18)) + ); + vec2 r = vec2( + fbm(p + q * 1.8 + vec2(1.7, 8.2) + t * 0.16), + fbm(p + q * 1.8 + vec2(8.3, 2.8) - t * 0.13) + ); + float field = fbm(p * 0.85 + r * 2.2 + pointerDelta * pointerGlow * 0.12); + + float ribbon = 0.5 + 0.5 * sin(10.0 * field + 3.0 * r.x - 1.8 * p.y + t * 1.4); + float silk = pow(ribbon, 4.0); + float edge = pow(1.0 - abs(ribbon * 2.0 - 1.0), 10.0); + + vec3 color = mix(vec3(0.006, 0.010, 0.032), vec3(0.075, 0.025, 0.16), field); + color = mix(color, vec3(0.02, 0.65, 0.72), silk * 0.72); + color += vec3(0.65, 0.08, 0.32) * pow(1.0 - field, 5.0) * 0.28; + color += vec3(0.08, 0.55, 0.70) * edge * 0.16; + color += mix(vec3(0.02, 0.55, 0.68), vec3(0.85, 0.12, 0.38), pressed) + * pointerGlow * (0.14 + pressed * 0.18); + + float vignette = 1.0 - smoothstep(0.25, 1.25, length((uv - 0.5) * vec2(1.1, 0.9))); + color *= 0.62 + vignette * 0.38; + color = 1.0 - exp(-color * 1.35); + color += (hash21(fragCoord + fract(iTime) * 91.0) - 0.5) * 0.012; + fragColor = vec4(color, 1.0); +} diff --git a/package/metadata.json b/package/metadata.json new file mode 100644 index 0000000..c251954 --- /dev/null +++ b/package/metadata.json @@ -0,0 +1,18 @@ +{ + "KPackageStructure": "Plasma/Wallpaper", + "KPlugin": { + "Authors": [ + { + "Name": "Emil" + } + ], + "Id": "dev.emil.lumen", + "License": "GPL-3.0-or-later", + "Name": "Lumen Wallpaper", + "Version": "0.1.0", + "Description": "Procedural GLSL wallpapers rendered natively with Rust and Vulkan", + "Website": "https://git.shanaty.ru/Emil_Shanaty/Lumen-wallpaper" + }, + "X-KDE-ParentApp": "org.kde.plasmashell", + "X-Plasma-API-Minimum-Version": "6.0" +} diff --git a/packaging/installer-stub.sh b/packaging/installer-stub.sh new file mode 100755 index 0000000..fa25ff0 --- /dev/null +++ b/packaging/installer-stub.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +for command_name in awk tail base64 tar mktemp; do + command -v "$command_name" >/dev/null 2>&1 || { + printf 'Lumen installer: missing command: %s\n' "$command_name" >&2 + exit 1 + } +done + +ARCHIVE_LINE=$(awk '/^__LUMEN_ARCHIVE_BELOW__$/{print NR + 1; exit}' "$0") +if [[ -z $ARCHIVE_LINE ]]; then + printf 'Lumen installer: embedded source archive is missing\n' >&2 + exit 1 +fi + +WORK_DIR=$(mktemp -d "${TMPDIR:-/tmp}/lumen-wallpaper-installer.XXXXXX") +trap 'rm -rf -- "$WORK_DIR"' EXIT + +tail -n +"$ARCHIVE_LINE" "$0" | base64 -d | tar -xz -C "$WORK_DIR" +"$WORK_DIR/Lumen-wallpaper-@LUMEN_VERSION@/scripts/install.sh" --deps --activate "$@" + +__LUMEN_ARCHIVE_BELOW__ diff --git a/renderer/src/bin/shader_check.rs b/renderer/src/bin/shader_check.rs new file mode 100644 index 0000000..b3e99f8 --- /dev/null +++ b/renderer/src/bin/shader_check.rs @@ -0,0 +1,31 @@ +use lumen_renderer::shader::compile_shader; +use std::process::ExitCode; + +fn main() -> ExitCode { + let Some(path) = std::env::args().nth(1) else { + eprintln!("Usage: lumen-shader-check "); + return ExitCode::from(2); + }; + let source = match std::fs::read_to_string(&path) { + Ok(source) => source, + Err(error) => { + eprintln!("Cannot read {path}: {error}"); + return ExitCode::FAILURE; + } + }; + + match compile_shader(&source) { + Ok(shader) => { + println!( + "{path}: valid (vertex: {} words, fragment: {} words)", + shader.vertex.len(), + shader.fragment.len() + ); + ExitCode::SUCCESS + } + Err(error) => { + eprintln!("{error}"); + ExitCode::FAILURE + } + } +} diff --git a/renderer/src/frame.rs b/renderer/src/frame.rs new file mode 100644 index 0000000..e197f51 --- /dev/null +++ b/renderer/src/frame.rs @@ -0,0 +1,41 @@ +use bytemuck::{Pod, Zeroable}; +use std::ffi::c_void; + +#[repr(C, align(16))] +#[derive(Clone, Copy, Debug, Pod, Zeroable)] +pub struct LumenUniforms { + pub mvp: [f32; 16], + pub resolution: [f32; 3], + pub time: f32, + pub mouse: [f32; 4], + pub item_size: [f32; 2], + pub time_delta: f32, + pub frame_rate: f32, + pub date: [f32; 4], + pub frame: i32, + pub screen_index: i32, + pub screen_count: i32, + pub sample_rate: f32, + pub screen_offset: [f32; 2], + pub desktop_resolution: [f32; 2], +} + +const _: [(); 160] = [(); std::mem::size_of::()]; + +#[repr(C)] +pub struct LumenFrame { + pub instance: *mut c_void, + pub physical_device: *mut c_void, + pub device: *mut c_void, + pub command_buffer: *mut c_void, + pub render_pass: u64, + pub uniforms: LumenUniforms, + pub viewport: [f32; 4], + pub scissor: [i32; 4], + pub sample_count: u32, + pub frame_slot: u32, + pub frames_in_flight: u32, + pub reserved: u32, +} + +const _: [(); 256] = [(); std::mem::size_of::()]; diff --git a/renderer/src/lib.rs b/renderer/src/lib.rs new file mode 100644 index 0000000..ec0f1e5 --- /dev/null +++ b/renderer/src/lib.rs @@ -0,0 +1,217 @@ +pub mod frame; +pub mod shader; +mod vulkan; + +use crate::frame::LumenFrame; +use crate::shader::{compile_shader, CompiledShader}; +use crate::vulkan::Gpu; +use ash::vk::Handle; +use std::ffi::{c_char, c_int, CStr, CString}; +use std::panic::{catch_unwind, AssertUnwindSafe}; + +pub struct Renderer { + source: String, + compiled: Option, + source_dirty: bool, + source_failed: bool, + pipeline_dirty: bool, + blocked: bool, + gpu: Option, + error: CString, +} + +impl Renderer { + fn new() -> Self { + Self { + source: String::new(), + compiled: None, + source_dirty: true, + source_failed: false, + pipeline_dirty: false, + blocked: false, + gpu: None, + error: CString::new("No shader loaded").unwrap(), + } + } + + fn set_shader(&mut self, source: &str) { + if self.source == source { + return; + } + self.source = source.to_owned(); + self.source_dirty = true; + self.blocked = false; + self.clear_error(); + } + + fn reset(&mut self) { + self.gpu = None; + self.pipeline_dirty = true; + self.blocked = false; + } + + fn set_error(&mut self, message: impl Into) { + let message = message.into().replace('\0', " "); + self.error = + CString::new(message).unwrap_or_else(|_| CString::new("Renderer error").unwrap()); + } + + fn clear_error(&mut self) { + self.error = CString::default(); + } + + fn render(&mut self, frame: &LumenFrame) -> Result { + if self.source_dirty { + self.source_dirty = false; + match compile_shader(&self.source) { + Ok(compiled) => { + self.compiled = Some(compiled); + self.source_failed = false; + self.pipeline_dirty = true; + self.blocked = false; + self.clear_error(); + } + Err(error) => { + self.source_failed = true; + self.set_error(error); + } + } + } + + if self.blocked && self.gpu.is_none() { + return Err(self.error.to_string_lossy().into_owned()); + } + + let Some(compiled) = self.compiled.clone() else { + return Err(self.error.to_string_lossy().into_owned()); + }; + + let device_handle = frame.device as usize as u64; + let render_pass = ash::vk::RenderPass::from_raw(frame.render_pass); + let samples = crate::vulkan::sample_count_flags(frame.sample_count)?; + let frames_in_flight = (frame.frames_in_flight.max(1) as usize).min(16); + let resources_changed = self.gpu.as_ref().is_some_and(|gpu| { + gpu.device_handle.as_raw() != device_handle + || gpu.frames_in_flight != frames_in_flight + || gpu.samples != samples + }); + if resources_changed { + self.reset(); + } + + if self.gpu.is_none() { + match unsafe { Gpu::new(frame) } { + Ok(gpu) => { + self.gpu = Some(gpu); + self.pipeline_dirty = true; + } + Err(error) => { + self.blocked = true; + self.set_error(format!("Vulkan initialization failed: {error}")); + return Err(self.error.to_string_lossy().into_owned()); + } + } + } + + { + let gpu = self.gpu.as_mut().unwrap(); + if gpu.render_pass != render_pass { + gpu.render_pass = render_pass; + self.pipeline_dirty = true; + } + } + + if self.pipeline_dirty { + let result = unsafe { self.gpu.as_mut().unwrap().create_pipeline(&compiled) }; + match result { + Ok(()) => { + self.pipeline_dirty = false; + if !self.source_failed { + self.clear_error(); + } + } + Err(error) => { + self.set_error(format!("Vulkan pipeline creation failed: {error}")); + } + } + } + + let gpu = self.gpu.as_ref().unwrap(); + if gpu.pipeline.is_null() + || gpu.pipeline_render_pass != render_pass + || gpu.pipeline_samples != samples + { + return Err(self.error.to_string_lossy().into_owned()); + } + + unsafe { gpu.draw(frame) }; + Ok(!self.error.to_bytes().is_empty()) + } +} + +#[no_mangle] +pub extern "C" fn lumen_renderer_create() -> *mut Renderer { + Box::into_raw(Box::new(Renderer::new())) +} + +#[no_mangle] +pub unsafe extern "C" fn lumen_renderer_destroy(renderer: *mut Renderer) { + if !renderer.is_null() { + drop(Box::from_raw(renderer)); + } +} + +#[no_mangle] +pub unsafe extern "C" fn lumen_renderer_set_shader(renderer: *mut Renderer, source: *const c_char) { + if renderer.is_null() || source.is_null() { + return; + } + let result = catch_unwind(AssertUnwindSafe(|| { + let source = CStr::from_ptr(source).to_string_lossy(); + (*renderer).set_shader(&source); + })); + if result.is_err() { + (*renderer).set_error("Panic while updating the shader"); + } +} + +#[no_mangle] +pub unsafe extern "C" fn lumen_renderer_reset(renderer: *mut Renderer) { + if let Some(renderer) = renderer.as_mut() { + let result = catch_unwind(AssertUnwindSafe(|| renderer.reset())); + if result.is_err() { + renderer.set_error("Panic while releasing Vulkan resources"); + } + } +} + +#[no_mangle] +pub unsafe extern "C" fn lumen_renderer_render( + renderer: *mut Renderer, + frame: *const LumenFrame, +) -> c_int { + if renderer.is_null() || frame.is_null() { + return -1; + } + let renderer = &mut *renderer; + match catch_unwind(AssertUnwindSafe(|| renderer.render(&*frame))) { + Ok(Ok(true)) => 1, + Ok(Ok(false)) => 0, + Ok(Err(error)) => { + renderer.set_error(error); + -1 + } + Err(_) => { + renderer.set_error("Panic in the Vulkan renderer"); + -1 + } + } +} + +#[no_mangle] +pub unsafe extern "C" fn lumen_renderer_error(renderer: *const Renderer) -> *const c_char { + if renderer.is_null() { + return c"Invalid renderer handle".as_ptr(); + } + (*renderer).error.as_ptr() +} diff --git a/renderer/src/shader.rs b/renderer/src/shader.rs new file mode 100644 index 0000000..aef31b2 --- /dev/null +++ b/renderer/src/shader.rs @@ -0,0 +1,135 @@ +use naga::back::spv; +use naga::front::glsl::{Frontend, Options as GlslOptions}; +use naga::valid::{Capabilities, ValidationFlags, Validator}; +use naga::ShaderStage; + +const UNIFORMS: &str = r#" +layout(std140, set = 0, binding = 0) uniform LumenUniforms { + mat4 lwMvp; + vec3 iResolution; + float iTime; + vec4 iMouse; + vec2 lwItemSize; + float iTimeDelta; + float iFrameRate; + vec4 iDate; + int iFrame; + int iScreenIndex; + int iScreenCount; + float iSampleRate; + vec2 iScreenOffset; + vec2 iDesktopResolution; +}; +"#; + +const VERTEX_PREFIX: &str = r#"#version 450 +layout(location = 0) out vec2 lwUv; +"#; + +const VERTEX_MAIN: &str = r#" +void main() { + vec2 position = vec2( + float((gl_VertexIndex << 1) & 2), + float(gl_VertexIndex & 2) + ); + lwUv = position; + gl_Position = lwMvp * vec4(position * lwItemSize, 0.0, 1.0); +} +"#; + +const FRAGMENT_PREFIX: &str = r#"#version 450 +layout(location = 0) in vec2 lwUv; +layout(location = 0) out vec4 fragColor; +"#; + +const FRAGMENT_MAIN: &str = r#" +void main() { + vec2 fragCoord = vec2(lwUv.x * iResolution.x, (1.0 - lwUv.y) * iResolution.y); + vec4 color = vec4(0.0, 0.0, 0.0, 1.0); + mainImage(color, fragCoord); + fragColor = color; +} +"#; + +#[derive(Clone)] +pub struct CompiledShader { + pub vertex: Vec, + pub fragment: Vec, +} + +pub fn compile_shader(user_source: &str) -> Result { + let user_source = sanitize_user_source(user_source); + let vertex_source = format!("{VERTEX_PREFIX}{UNIFORMS}{VERTEX_MAIN}"); + let fragment_source = format!("{FRAGMENT_PREFIX}{UNIFORMS}\n{user_source}\n{FRAGMENT_MAIN}"); + + Ok(CompiledShader { + vertex: compile_stage(&vertex_source, ShaderStage::Vertex)?, + fragment: compile_stage(&fragment_source, ShaderStage::Fragment)?, + }) +} + +fn sanitize_user_source(source: &str) -> String { + source + .lines() + .map(|line| { + let trimmed = line.trim_start(); + if trimmed.starts_with("#version") || trimmed.starts_with("precision ") { + "" + } else { + line + } + }) + .collect::>() + .join("\n") +} + +fn compile_stage(source: &str, stage: ShaderStage) -> Result, String> { + let stage_name = match stage { + ShaderStage::Vertex => "vertex", + ShaderStage::Fragment => "fragment", + ShaderStage::Compute => "compute", + _ => "unsupported", + }; + + let module = Frontend::default() + .parse(&GlslOptions::from(stage), source) + .map_err(|errors| { + format!( + "GLSL {stage_name} compilation failed:\n{}", + errors.emit_to_string(source) + ) + })?; + + let info = Validator::new(ValidationFlags::all(), Capabilities::default()) + .validate(&module) + .map_err(|error| { + format!( + "GLSL {stage_name} validation failed:\n{}", + error.emit_to_string_with_path(source, "glsl") + ) + })?; + + let mut options = spv::Options::default(); + options.fake_missing_bindings = false; + options.binding_map.insert( + naga::ResourceBinding { + group: 0, + binding: 0, + }, + spv::BindingInfo { + descriptor_set: 0, + binding: 0, + binding_array_size: None, + }, + ); + options + .flags + .remove(spv::WriterFlags::ADJUST_COORDINATE_SPACE); + let pipeline = spv::PipelineOptions { + shader_stage: stage, + entry_point: "main".to_owned(), + }; + + spv::write_vec(&module, &info, &options, Some(&pipeline)) + .map_err(|error| format!("SPIR-V {stage_name} generation failed: {error}")) +} diff --git a/renderer/src/vulkan.rs b/renderer/src/vulkan.rs new file mode 100644 index 0000000..ec2f845 --- /dev/null +++ b/renderer/src/vulkan.rs @@ -0,0 +1,371 @@ +use crate::frame::LumenFrame; +use crate::shader::CompiledShader; +use ash::vk::{self, Handle}; +use ash::{Device, Entry, Instance}; +use std::ffi::{c_void, CStr}; +use std::mem::size_of; + +pub struct Gpu { + _entry: Entry, + instance: Instance, + device: Device, + physical_device: vk::PhysicalDevice, + pub device_handle: vk::Device, + pub render_pass: vk::RenderPass, + pub pipeline_render_pass: vk::RenderPass, + descriptor_set_layout: vk::DescriptorSetLayout, + pipeline_layout: vk::PipelineLayout, + descriptor_pool: vk::DescriptorPool, + descriptor_set: vk::DescriptorSet, + uniform_buffer: vk::Buffer, + uniform_memory: vk::DeviceMemory, + uniform_mapped: *mut c_void, + pub uniform_stride: u64, + pub frames_in_flight: usize, + pub samples: vk::SampleCountFlags, + pub pipeline_samples: vk::SampleCountFlags, + pub pipeline: vk::Pipeline, +} + +impl Gpu { + pub unsafe fn new(frame: &LumenFrame) -> Result { + if frame.instance.is_null() + || frame.physical_device.is_null() + || frame.device.is_null() + || frame.command_buffer.is_null() + || frame.render_pass == 0 + { + return Err("Qt did not provide all required Vulkan handles".to_owned()); + } + + let entry = Entry::load().map_err(|error| error.to_string())?; + let instance_handle = vk::Instance::from_raw(frame.instance as usize as u64); + let instance = Instance::load(entry.static_fn(), instance_handle); + let device_handle = vk::Device::from_raw(frame.device as usize as u64); + let device = Device::load(instance.fp_v1_0(), device_handle); + let physical_device = vk::PhysicalDevice::from_raw(frame.physical_device as usize as u64); + let properties = instance.get_physical_device_properties(physical_device); + let alignment = properties.limits.min_uniform_buffer_offset_alignment.max(1); + let uniform_size = size_of::() as u64; + let uniform_stride = uniform_size.div_ceil(alignment) * alignment; + let samples = sample_count_flags(frame.sample_count)?; + + let mut gpu = Self { + _entry: entry, + instance, + device, + physical_device, + device_handle, + render_pass: vk::RenderPass::from_raw(frame.render_pass), + pipeline_render_pass: vk::RenderPass::null(), + descriptor_set_layout: vk::DescriptorSetLayout::null(), + pipeline_layout: vk::PipelineLayout::null(), + descriptor_pool: vk::DescriptorPool::null(), + descriptor_set: vk::DescriptorSet::null(), + uniform_buffer: vk::Buffer::null(), + uniform_memory: vk::DeviceMemory::null(), + uniform_mapped: std::ptr::null_mut(), + uniform_stride, + frames_in_flight: (frame.frames_in_flight.max(1) as usize).min(16), + samples, + pipeline_samples: vk::SampleCountFlags::empty(), + pipeline: vk::Pipeline::null(), + }; + gpu.create_uniform_resources()?; + Ok(gpu) + } + + unsafe fn create_uniform_resources(&mut self) -> Result<(), String> { + let binding = vk::DescriptorSetLayoutBinding::default() + .binding(0) + .descriptor_type(vk::DescriptorType::UNIFORM_BUFFER_DYNAMIC) + .descriptor_count(1) + .stage_flags(vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT); + let layout_info = + vk::DescriptorSetLayoutCreateInfo::default().bindings(std::slice::from_ref(&binding)); + self.descriptor_set_layout = self + .device + .create_descriptor_set_layout(&layout_info, None) + .map_err(vk_error)?; + + let layouts = [self.descriptor_set_layout]; + let pipeline_layout_info = vk::PipelineLayoutCreateInfo::default().set_layouts(&layouts); + self.pipeline_layout = self + .device + .create_pipeline_layout(&pipeline_layout_info, None) + .map_err(vk_error)?; + + let pool_size = vk::DescriptorPoolSize::default() + .ty(vk::DescriptorType::UNIFORM_BUFFER_DYNAMIC) + .descriptor_count(1); + let pool_sizes = [pool_size]; + let pool_info = vk::DescriptorPoolCreateInfo::default() + .max_sets(1) + .pool_sizes(&pool_sizes); + self.descriptor_pool = self + .device + .create_descriptor_pool(&pool_info, None) + .map_err(vk_error)?; + + let allocate_info = vk::DescriptorSetAllocateInfo::default() + .descriptor_pool(self.descriptor_pool) + .set_layouts(&layouts); + self.descriptor_set = self + .device + .allocate_descriptor_sets(&allocate_info) + .map_err(vk_error)?[0]; + + let buffer_info = vk::BufferCreateInfo::default() + .size(self.uniform_stride * self.frames_in_flight as u64) + .usage(vk::BufferUsageFlags::UNIFORM_BUFFER) + .sharing_mode(vk::SharingMode::EXCLUSIVE); + self.uniform_buffer = self + .device + .create_buffer(&buffer_info, None) + .map_err(vk_error)?; + + let requirements = self + .device + .get_buffer_memory_requirements(self.uniform_buffer); + let properties = self + .instance + .get_physical_device_memory_properties(self.physical_device); + let memory_type = (0..properties.memory_type_count) + .find(|index| { + let supported = requirements.memory_type_bits & (1 << index) != 0; + let flags = properties.memory_types[*index as usize].property_flags; + let required = + vk::MemoryPropertyFlags::HOST_VISIBLE | vk::MemoryPropertyFlags::HOST_COHERENT; + supported && flags.contains(required) + }) + .ok_or_else(|| "No host-visible coherent memory for the uniform buffer".to_owned())?; + let allocate_info = vk::MemoryAllocateInfo::default() + .allocation_size(requirements.size) + .memory_type_index(memory_type); + self.uniform_memory = self + .device + .allocate_memory(&allocate_info, None) + .map_err(vk_error)?; + self.device + .bind_buffer_memory(self.uniform_buffer, self.uniform_memory, 0) + .map_err(vk_error)?; + self.uniform_mapped = self + .device + .map_memory( + self.uniform_memory, + 0, + vk::WHOLE_SIZE, + vk::MemoryMapFlags::empty(), + ) + .map_err(vk_error)?; + + let buffer_info = vk::DescriptorBufferInfo::default() + .buffer(self.uniform_buffer) + .offset(0) + .range(size_of::() as u64); + let buffer_infos = [buffer_info]; + let write = vk::WriteDescriptorSet::default() + .dst_set(self.descriptor_set) + .dst_binding(0) + .descriptor_type(vk::DescriptorType::UNIFORM_BUFFER_DYNAMIC) + .buffer_info(&buffer_infos); + self.device.update_descriptor_sets(&[write], &[]); + Ok(()) + } + + pub unsafe fn create_pipeline(&mut self, shader: &CompiledShader) -> Result<(), String> { + let vertex_module = self.create_shader_module(&shader.vertex)?; + let fragment_module = match self.create_shader_module(&shader.fragment) { + Ok(module) => module, + Err(error) => { + self.device.destroy_shader_module(vertex_module, None); + return Err(error); + } + }; + let result = self.create_pipeline_with_modules(vertex_module, fragment_module); + self.device.destroy_shader_module(vertex_module, None); + self.device.destroy_shader_module(fragment_module, None); + + let new_pipeline = result?; + let old_pipeline = self.pipeline; + self.pipeline = new_pipeline; + self.pipeline_render_pass = self.render_pass; + self.pipeline_samples = self.samples; + if !old_pipeline.is_null() { + self.device.device_wait_idle().map_err(vk_error)?; + self.device.destroy_pipeline(old_pipeline, None); + } + Ok(()) + } + + unsafe fn create_shader_module(&self, words: &[u32]) -> Result { + let info = vk::ShaderModuleCreateInfo::default().code(words); + self.device + .create_shader_module(&info, None) + .map_err(vk_error) + } + + unsafe fn create_pipeline_with_modules( + &self, + vertex_module: vk::ShaderModule, + fragment_module: vk::ShaderModule, + ) -> Result { + let entry_name = CStr::from_bytes_with_nul(b"main\0").unwrap(); + let stages = [ + vk::PipelineShaderStageCreateInfo::default() + .stage(vk::ShaderStageFlags::VERTEX) + .module(vertex_module) + .name(entry_name), + vk::PipelineShaderStageCreateInfo::default() + .stage(vk::ShaderStageFlags::FRAGMENT) + .module(fragment_module) + .name(entry_name), + ]; + let vertex_input = vk::PipelineVertexInputStateCreateInfo::default(); + let input_assembly = vk::PipelineInputAssemblyStateCreateInfo::default() + .topology(vk::PrimitiveTopology::TRIANGLE_LIST); + let viewport_state = vk::PipelineViewportStateCreateInfo::default() + .viewport_count(1) + .scissor_count(1); + let rasterization = vk::PipelineRasterizationStateCreateInfo::default() + .polygon_mode(vk::PolygonMode::FILL) + .cull_mode(vk::CullModeFlags::NONE) + .front_face(vk::FrontFace::COUNTER_CLOCKWISE) + .line_width(1.0); + let multisample = + vk::PipelineMultisampleStateCreateInfo::default().rasterization_samples(self.samples); + let depth_stencil = vk::PipelineDepthStencilStateCreateInfo::default(); + let color_attachment = vk::PipelineColorBlendAttachmentState::default() + .blend_enable(false) + .color_write_mask( + vk::ColorComponentFlags::R + | vk::ColorComponentFlags::G + | vk::ColorComponentFlags::B + | vk::ColorComponentFlags::A, + ); + let color_attachments = [color_attachment]; + let color_blend = + vk::PipelineColorBlendStateCreateInfo::default().attachments(&color_attachments); + let dynamic_states = [vk::DynamicState::VIEWPORT, vk::DynamicState::SCISSOR]; + let dynamic = vk::PipelineDynamicStateCreateInfo::default().dynamic_states(&dynamic_states); + let pipeline_info = vk::GraphicsPipelineCreateInfo::default() + .stages(&stages) + .vertex_input_state(&vertex_input) + .input_assembly_state(&input_assembly) + .viewport_state(&viewport_state) + .rasterization_state(&rasterization) + .multisample_state(&multisample) + .depth_stencil_state(&depth_stencil) + .color_blend_state(&color_blend) + .dynamic_state(&dynamic) + .layout(self.pipeline_layout) + .render_pass(self.render_pass) + .subpass(0); + + self.device + .create_graphics_pipelines(vk::PipelineCache::null(), &[pipeline_info], None) + .map(|pipelines| pipelines[0]) + .map_err(|(pipelines, error)| { + for pipeline in pipelines { + self.device.destroy_pipeline(pipeline, None); + } + vk_error(error) + }) + } + + pub unsafe fn draw(&self, frame: &LumenFrame) { + let slot = frame.frame_slot as usize % self.frames_in_flight; + let dynamic_offset = (slot as u64 * self.uniform_stride) as u32; + std::ptr::copy_nonoverlapping( + &frame.uniforms as *const _ as *const u8, + self.uniform_mapped + .cast::() + .add(dynamic_offset as usize), + size_of::(), + ); + let command_buffer = vk::CommandBuffer::from_raw(frame.command_buffer as usize as u64); + self.device.cmd_bind_pipeline( + command_buffer, + vk::PipelineBindPoint::GRAPHICS, + self.pipeline, + ); + self.device.cmd_bind_descriptor_sets( + command_buffer, + vk::PipelineBindPoint::GRAPHICS, + self.pipeline_layout, + 0, + &[self.descriptor_set], + &[dynamic_offset], + ); + let viewport = vk::Viewport { + x: frame.viewport[0], + y: frame.viewport[1], + width: frame.viewport[2].max(1.0), + height: frame.viewport[3].max(1.0), + min_depth: 0.0, + max_depth: 1.0, + }; + let scissor = vk::Rect2D { + offset: vk::Offset2D { + x: frame.scissor[0], + y: frame.scissor[1], + }, + extent: vk::Extent2D { + width: frame.scissor[2].max(1) as u32, + height: frame.scissor[3].max(1) as u32, + }, + }; + self.device.cmd_set_viewport(command_buffer, 0, &[viewport]); + self.device.cmd_set_scissor(command_buffer, 0, &[scissor]); + self.device.cmd_draw(command_buffer, 3, 1, 0, 0); + } +} + +impl Drop for Gpu { + fn drop(&mut self) { + unsafe { + let _ = self.device.device_wait_idle(); + if !self.pipeline.is_null() { + self.device.destroy_pipeline(self.pipeline, None); + } + if !self.uniform_mapped.is_null() { + self.device.unmap_memory(self.uniform_memory); + } + if !self.uniform_buffer.is_null() { + self.device.destroy_buffer(self.uniform_buffer, None); + } + if !self.uniform_memory.is_null() { + self.device.free_memory(self.uniform_memory, None); + } + if !self.descriptor_pool.is_null() { + self.device + .destroy_descriptor_pool(self.descriptor_pool, None); + } + if !self.pipeline_layout.is_null() { + self.device + .destroy_pipeline_layout(self.pipeline_layout, None); + } + if !self.descriptor_set_layout.is_null() { + self.device + .destroy_descriptor_set_layout(self.descriptor_set_layout, None); + } + } + } +} + +pub(crate) fn sample_count_flags(count: u32) -> Result { + match count { + 1 => Ok(vk::SampleCountFlags::TYPE_1), + 2 => Ok(vk::SampleCountFlags::TYPE_2), + 4 => Ok(vk::SampleCountFlags::TYPE_4), + 8 => Ok(vk::SampleCountFlags::TYPE_8), + 16 => Ok(vk::SampleCountFlags::TYPE_16), + 32 => Ok(vk::SampleCountFlags::TYPE_32), + 64 => Ok(vk::SampleCountFlags::TYPE_64), + _ => Err(format!("Unsupported Vulkan sample count: {count}")), + } +} + +fn vk_error(error: vk::Result) -> String { + format!("{error}") +} diff --git a/scripts/activate.sh b/scripts/activate.sh new file mode 100755 index 0000000..ca91b60 --- /dev/null +++ b/scripts/activate.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +BUILD_DIR=${BUILD_DIR:-"$ROOT/build"} +PACKAGE_ID=dev.emil.lumen + +for command_name in busctl plasmashell nohup; do + command -v "$command_name" >/dev/null 2>&1 || { + printf 'Lumen activation: missing command: %s\n' "$command_name" >&2 + exit 1 + } +done +if ! busctl --user call org.kde.plasmashell /PlasmaShell \ + org.kde.PlasmaShell wallpaper u 0 >/dev/null 2>&1; then + printf 'Lumen activation: no running Plasma session was found\n' >&2 + exit 1 +fi + +BACKUP_DIR="$BUILD_DIR/activation-backup-$(date +%Y%m%d-%H%M%S)-$$" +mkdir -p "$BACKUP_DIR" "$HOME/.config/plasma-workspace/env" +: > "$BACKUP_DIR/wallpaper-plugins.txt" +for ((screen = 0; ; ++screen)); do + if ! description=$(busctl --user call org.kde.plasmashell /PlasmaShell \ + org.kde.PlasmaShell wallpaper u "$screen" 2>/dev/null); then + break + fi + printf '%s\n' "$description" > "$BACKUP_DIR/wallpaper-screen-$screen.txt" + if [[ $description =~ \"wallpaperPlugin\"\ s\ \"([^\"]+)\" ]]; then + printf '%d %s\n' "$screen" "${BASH_REMATCH[1]}" \ + >> "$BACKUP_DIR/wallpaper-plugins.txt" + else + break + fi +done +if [[ ! -s "$BACKUP_DIR/wallpaper-plugins.txt" ]]; then + printf 'Lumen activation: could not back up the current wallpaper plugins\n' >&2 + exit 1 +fi + +printf '%s\n' 'export QSG_RHI_BACKEND=vulkan' \ + > "$HOME/.config/plasma-workspace/env/lumen-vulkan.sh" + +busctl --user call org.kde.plasmashell /PlasmaShell org.kde.PlasmaShell evaluateScript s \ + "desktops().forEach(function(d) { d.wallpaperPlugin = '$PACKAGE_ID'; });" + +QSG_RHI_BACKEND=vulkan nohup plasmashell --replace \ + > "$BACKUP_DIR/plasmashell.log" 2>&1 & +printf 'Lumen Wallpaper activated. Backup: %s\n' "$BACKUP_DIR" diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..477dcb1 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,189 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +PREFIX=${PREFIX:-"$HOME/.local"} +BUILD_DIR=${BUILD_DIR:-"$ROOT/build"} +INSTALL_DEPS=0 +ACTIVATE=0 +ASSUME_YES=0 +MINIMUM_RUST=1.87.0 + +usage() { + cat <<'EOF' +Usage: scripts/install.sh [options] + +Options: + --deps Offer to install missing build dependencies + --no-deps Do not install dependencies (default) + --activate Activate Lumen in the running Plasma session + --no-activate Build and install only (default) + --yes, -y Assume yes for dependency installation prompts + --help Show this help + +Environment: + PREFIX Installation prefix (default: ~/.local) + BUILD_DIR Build directory (default: ./build) + LUMEN_STATE_DIR Activation backups and logs (default: PREFIX/share/lumen-wallpaper) +EOF +} + +log() { + printf 'Lumen installer: %s\n' "$*" +} + +fail() { + printf 'Lumen installer: %s\n' "$*" >&2 + exit 1 +} + +while (($#)); do + case "$1" in + --deps) INSTALL_DEPS=1 ;; + --no-deps) INSTALL_DEPS=0 ;; + --activate) ACTIVATE=1 ;; + --no-activate) ACTIVATE=0 ;; + --yes|-y) ASSUME_YES=1 ;; + --help) + usage + exit 0 + ;; + *) fail "Unknown option: $1" ;; + esac + shift +done + +confirm() { + local prompt=$1 + local answer + if ((ASSUME_YES)); then + return 0 + fi + if [[ ! -t 0 ]]; then + return 1 + fi + read -r -p "$prompt [y/N] " answer + [[ $answer == y || $answer == Y || $answer == yes || $answer == YES ]] +} + +rust_version_ok() { + command -v rustc >/dev/null 2>&1 || return 1 + local version + version=$(rustc --version 2>/dev/null | awk '{print $2}') + [[ -n $version ]] || return 1 + printf '%s\n%s\n' "$MINIMUM_RUST" "$version" | sort -V -C +} + +ecm_available() { + local probe_dir + probe_dir=$(mktemp -d "${TMPDIR:-/tmp}/lumen-ecm-probe.XXXXXX") + if (cd "$probe_dir" && cmake --find-package \ + -DNAME=ECM -DCOMPILER_ID=GNU -DLANGUAGE=CXX -DMODE=EXIST \ + >/dev/null 2>&1); then + rm -rf -- "$probe_dir" + return 0 + fi + rm -rf -- "$probe_dir" + return 1 +} + +dependencies_ready() { + local command_name + for command_name in cmake ninja c++ cargo rustc pkg-config; do + command -v "$command_name" >/dev/null 2>&1 || return 1 + done + rust_version_ok || return 1 + pkg-config --exists Qt6Core Qt6Gui Qt6Qml Qt6Quick || return 1 + ecm_available || return 1 +} + +run_as_root() { + if ((EUID == 0)); then + "$@" + else + command -v sudo >/dev/null 2>&1 || fail "sudo is required for system packages" + sudo "$@" + fi +} + +install_dependencies() { + if dependencies_ready; then + log "Build dependencies are already available" + return + fi + ((INSTALL_DEPS)) || return + confirm "Install missing build dependencies with the system package manager?" \ + || fail "Build dependencies are required; install them manually or rerun with --deps" + + if command -v dnf >/dev/null 2>&1; then + run_as_root dnf install -y \ + gcc-c++ cargo rustc cmake ninja-build extra-cmake-modules pkgconf-pkg-config curl \ + qt6-qtbase-devel qt6-qtdeclarative-devel vulkan-loader-devel vulkan-headers \ + kf6-kirigami plasma-workspace + elif command -v apt-get >/dev/null 2>&1; then + run_as_root apt-get update + run_as_root apt-get install -y \ + build-essential cargo rustc cmake ninja-build extra-cmake-modules pkg-config curl \ + qt6-base-dev qt6-declarative-dev libvulkan-dev \ + qml6-module-org-kde-kirigami plasma-workspace + elif command -v pacman >/dev/null 2>&1; then + run_as_root pacman -Sy --needed --noconfirm \ + base-devel cargo cmake ninja extra-cmake-modules pkgconf curl \ + qt6-base qt6-declarative vulkan-headers kirigami plasma-workspace + else + fail "Unsupported package manager. Install CMake, Ninja, a C++ compiler, Rust, Qt6, ECM, Vulkan, Kirigami, and Plasma Workspace manually" + fi +} + +ensure_rust() { + if rust_version_ok; then + return + fi + ((INSTALL_DEPS)) || return + confirm "Rust $MINIMUM_RUST or newer is required. Install/update it with rustup in your home directory?" \ + || fail "Rust $MINIMUM_RUST or newer is required" + + if command -v rustup >/dev/null 2>&1; then + rustup toolchain install stable --profile minimal + else + command -v curl >/dev/null 2>&1 || fail "curl is required to download rustup" + local rustup_script + rustup_script=$(mktemp "${TMPDIR:-/tmp}/lumen-rustup.XXXXXX") + curl --proto '=https' --tlsv1.2 -fsSL https://sh.rustup.rs -o "$rustup_script" + sh "$rustup_script" -y --profile minimal --no-modify-path + rm -f -- "$rustup_script" + fi + export PATH="$HOME/.cargo/bin:$PATH" + export RUSTUP_TOOLCHAIN=stable + rust_version_ok || fail "The Rust toolchain is still older than $MINIMUM_RUST" +} + +preflight() { + local command_name + for command_name in cmake ninja c++ cargo rustc pkg-config; do + command -v "$command_name" >/dev/null 2>&1 \ + || fail "Missing required command: $command_name" + done + rust_version_ok || fail "Rust $MINIMUM_RUST or newer is required" + pkg-config --exists Qt6Core Qt6Gui Qt6Qml Qt6Quick \ + || fail "Qt6 development packages are required" + ecm_available || fail "Extra CMake Modules (ECM) are required" +} + +install_dependencies +ensure_rust +preflight + +cmake -S "$ROOT" -B "$BUILD_DIR" -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX="$PREFIX" +cmake --build "$BUILD_DIR" +cmake --install "$BUILD_DIR" + +log "Installed Lumen Wallpaper to $PREFIX" +if ((ACTIVATE)); then + BUILD_DIR=${LUMEN_STATE_DIR:-"$PREFIX/share/lumen-wallpaper"} \ + "$ROOT/scripts/activate.sh" +else + log "Activate it with scripts/activate.sh or choose Lumen Wallpaper in KDE settings" +fi diff --git a/scripts/package-release.sh b/scripts/package-release.sh new file mode 100755 index 0000000..e824f39 --- /dev/null +++ b/scripts/package-release.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +VERSION=$(awk -F'"' '/^version =/{print $2; exit}' "$ROOT/Cargo.toml") +REF=${1:-HEAD} +DIST_DIR=${DIST_DIR:-"$ROOT/dist"} +SOURCE_ARCHIVE="$DIST_DIR/lumen-wallpaper-$VERSION-source.tar.gz" +INSTALLER="$DIST_DIR/lumen-wallpaper-$VERSION-installer.run" + +command -v git >/dev/null 2>&1 || { + printf 'Release packaging requires git\n' >&2 + exit 1 +} +git -C "$ROOT" rev-parse --verify "$REF^{commit}" >/dev/null +mkdir -p "$DIST_DIR" + +git -C "$ROOT" archive --format=tar.gz \ + --prefix="Lumen-wallpaper-$VERSION/" "$REF" -o "$SOURCE_ARCHIVE" + +if tar -tzf "$SOURCE_ARCHIVE" | grep -E '(^|/)(build|target|dist|\.env)(/|$)|\.(log|swp|tmp)$' >/dev/null; then + printf 'Release archive contains a forbidden local artifact\n' >&2 + exit 1 +fi + +sed "s/@LUMEN_VERSION@/$VERSION/g" "$ROOT/packaging/installer-stub.sh" > "$INSTALLER" +base64 "$SOURCE_ARCHIVE" >> "$INSTALLER" +chmod +x "$INSTALLER" + +( + cd "$DIST_DIR" + sha256sum "$(basename "$SOURCE_ARCHIVE")" "$(basename "$INSTALLER")" > SHA256SUMS +) +printf 'Created %s\nCreated %s\n' "$SOURCE_ARCHIVE" "$INSTALLER" diff --git a/src/lumen_ffi.h b/src/lumen_ffi.h new file mode 100644 index 0000000..31d8a25 --- /dev/null +++ b/src/lumen_ffi.h @@ -0,0 +1,51 @@ +#pragma once + +#include + +struct LumenRenderer; + +struct alignas(16) LumenUniforms { + float mvp[16]; + float resolution[3]; + float time; + float mouse[4]; + float itemSize[2]; + float timeDelta; + float frameRate; + float date[4]; + std::int32_t frame; + std::int32_t screenIndex; + std::int32_t screenCount; + float sampleRate; + float screenOffset[2]; + float desktopResolution[2]; +}; + +static_assert(sizeof(LumenUniforms) == 160); + +struct LumenFrame { + void *instance; + void *physicalDevice; + void *device; + void *commandBuffer; + std::uint64_t renderPass; + LumenUniforms uniforms; + float viewport[4]; + std::int32_t scissor[4]; + std::uint32_t sampleCount; + std::uint32_t frameSlot; + std::uint32_t framesInFlight; + std::uint32_t reserved; +}; + +static_assert(alignof(LumenUniforms) == 16); +static_assert(sizeof(LumenFrame) == 256); + +extern "C" { +LumenRenderer *lumen_renderer_create(); +void lumen_renderer_destroy(LumenRenderer *renderer); +void lumen_renderer_set_shader(LumenRenderer *renderer, const char *source); +void lumen_renderer_reset(LumenRenderer *renderer); +int lumen_renderer_render(LumenRenderer *renderer, const LumenFrame *frame); +const char *lumen_renderer_error(const LumenRenderer *renderer); +} diff --git a/src/plugin.cpp b/src/plugin.cpp new file mode 100644 index 0000000..b9fd2a1 --- /dev/null +++ b/src/plugin.cpp @@ -0,0 +1,9 @@ +#include "plugin.h" + +#include "wallpaperitem.h" + +#include + +void LumenPlugin::registerTypes(const char *uri) { + qmlRegisterType(uri, 1, 0, "VulkanWallpaper"); +} diff --git a/src/plugin.h b/src/plugin.h new file mode 100644 index 0000000..a15d291 --- /dev/null +++ b/src/plugin.h @@ -0,0 +1,11 @@ +#pragma once + +#include + +class LumenPlugin final : public QQmlExtensionPlugin { + Q_OBJECT + Q_PLUGIN_METADATA(IID QQmlExtensionInterface_iid) + +public: + void registerTypes(const char *uri) override; +}; diff --git a/src/rendernode.cpp b/src/rendernode.cpp new file mode 100644 index 0000000..a9caab9 --- /dev/null +++ b/src/rendernode.cpp @@ -0,0 +1,185 @@ +#include "rendernode.h" + +#include "wallpaperitem.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +template +void *dispatchableHandle(Handle handle) { + if constexpr (std::is_pointer_v) + return reinterpret_cast(handle); + else + return reinterpret_cast(static_cast(handle)); +} + +template +uint64_t nonDispatchableHandle(Handle handle) { + if constexpr (std::is_pointer_v) + return static_cast(reinterpret_cast(handle)); + else + return static_cast(handle); +} + +template +Handle resource(QSGRendererInterface *renderer, QQuickWindow *window, + QSGRendererInterface::Resource type) { + void *value = renderer->getResource(window, type); + return value ? *static_cast(value) : Handle{}; +} +} + +LumenRenderNode::LumenRenderNode(VulkanWallpaperItem *item) + : m_item(item), m_renderer(lumen_renderer_create()) { +} + +LumenRenderNode::~LumenRenderNode() { + lumen_renderer_destroy(m_renderer); +} + +void LumenRenderNode::sync(const WallpaperFrameState &state) { + m_state = state; + m_itemSize = state.itemSize; + m_window = state.window; +} + +void LumenRenderNode::render(const RenderState *renderState) { + if (!m_window) + return; + + QSGRendererInterface *rendererInterface = m_window->rendererInterface(); + if (!rendererInterface || rendererInterface->graphicsApi() != QSGRendererInterface::Vulkan) { + publishStatus(-1, QStringLiteral( + "Qt Quick is not using Vulkan. Restart Plasma with QSG_RHI_BACKEND=vulkan.")); + return; + } + + auto *vulkanInstance = static_cast( + rendererInterface->getResource(m_window, QSGRendererInterface::VulkanInstanceResource)); + const VkInstance instance = vulkanInstance ? vulkanInstance->vkInstance() : VK_NULL_HANDLE; + const VkPhysicalDevice physicalDevice = resource( + rendererInterface, m_window, QSGRendererInterface::PhysicalDeviceResource); + const VkDevice device = resource( + rendererInterface, m_window, QSGRendererInterface::DeviceResource); + const VkCommandBuffer commandBuffer = resource( + rendererInterface, m_window, QSGRendererInterface::CommandListResource); + const VkRenderPass renderPass = resource( + rendererInterface, m_window, QSGRendererInterface::RenderPassResource); + + if (!vulkanInstance || instance == VK_NULL_HANDLE || physicalDevice == VK_NULL_HANDLE + || device == VK_NULL_HANDLE || commandBuffer == VK_NULL_HANDLE || renderPass == VK_NULL_HANDLE) { + publishStatus(-1, QStringLiteral("Could not acquire the Vulkan resources from Qt Quick.")); + return; + } + + if (m_loadedSource != m_state.shaderSource) { + m_loadedSource = m_state.shaderSource; + lumen_renderer_set_shader(m_renderer, m_loadedSource.constData()); + } + + LumenFrame frame{}; + frame.instance = dispatchableHandle(instance); + frame.physicalDevice = dispatchableHandle(physicalDevice); + frame.device = dispatchableHandle(device); + frame.commandBuffer = dispatchableHandle(commandBuffer); + frame.renderPass = nonDispatchableHandle(renderPass); + frame.sampleCount = static_cast(m_state.sampleCount); + const auto graphicsState = m_window->graphicsStateInfo(); + frame.frameSlot = static_cast(std::max(0, graphicsState.currentFrameSlot)); + frame.framesInFlight = static_cast(std::max(1, graphicsState.framesInFlight)); + + const QMatrix4x4 mvp = *renderState->projectionMatrix() * *matrix(); + std::copy_n(mvp.constData(), 16, frame.uniforms.mvp); + + const qreal dpr = m_state.devicePixelRatio; + const qreal pixelWidth = std::max(1.0, m_state.itemSize.width() * dpr); + const qreal pixelHeight = std::max(1.0, m_state.itemSize.height() * dpr); + frame.uniforms.resolution[0] = static_cast(pixelWidth); + frame.uniforms.resolution[1] = static_cast(pixelHeight); + frame.uniforms.resolution[2] = static_cast(dpr); + frame.uniforms.time = static_cast(m_state.time); + for (int i = 0; i < 4; ++i) + frame.uniforms.mouse[i] = static_cast(m_state.mouse[i]); + frame.uniforms.itemSize[0] = static_cast(m_state.itemSize.width()); + frame.uniforms.itemSize[1] = static_cast(m_state.itemSize.height()); + frame.uniforms.timeDelta = static_cast(m_state.timeDelta); + frame.uniforms.frameRate = static_cast(m_state.frameRate); + + const QDateTime now = QDateTime::currentDateTime(); + const QTime time = now.time(); + frame.uniforms.date[0] = static_cast(now.date().year()); + frame.uniforms.date[1] = static_cast(now.date().month()); + frame.uniforms.date[2] = static_cast(now.date().day()); + frame.uniforms.date[3] = static_cast(QTime(0, 0).secsTo(time)) + + static_cast(time.msec()) / 1000.0f; + frame.uniforms.frame = static_cast(m_state.frame & 0x7fffffff); + frame.uniforms.screenIndex = m_state.screenIndex; + frame.uniforms.screenCount = m_state.screenCount; + frame.uniforms.sampleRate = 48000.0f; + frame.uniforms.desktopResolution[0] = frame.uniforms.resolution[0]; + frame.uniforms.desktopResolution[1] = frame.uniforms.resolution[1]; + + const QSize &outputSize = m_state.outputSize; + frame.viewport[0] = 0.0f; + frame.viewport[1] = 0.0f; + frame.viewport[2] = static_cast(outputSize.width()); + frame.viewport[3] = static_cast(outputSize.height()); + const QRect scissor = renderState->scissorEnabled() + ? renderState->scissorRect() + : QRect(QPoint(0, 0), outputSize); + frame.scissor[0] = scissor.x(); + frame.scissor[1] = scissor.y(); + frame.scissor[2] = scissor.width(); + frame.scissor[3] = scissor.height(); + + int status = lumen_renderer_render(m_renderer, &frame); + QString message = QString::fromUtf8(lumen_renderer_error(m_renderer)); + if (!m_state.fileError.isEmpty()) { + if (!message.isEmpty()) + message += QLatin1Char('\n'); + message += m_state.fileError; + if (status == 0) + status = 1; + } + publishStatus(status, message); +} + +void LumenRenderNode::releaseResources() { + lumen_renderer_reset(m_renderer); +} + +QSGRenderNode::StateFlags LumenRenderNode::changedStates() const { + return ViewportState | ScissorState; +} + +QSGRenderNode::RenderingFlags LumenRenderNode::flags() const { + return BoundedRectRendering | OpaqueRendering; +} + +QRectF LumenRenderNode::rect() const { + return QRectF(QPointF(0, 0), m_itemSize); +} + +void LumenRenderNode::publishStatus(int status, const QString &message) { + if (status == m_lastStatus && message == m_lastMessage) + return; + m_lastStatus = status; + m_lastMessage = message; + if (!message.isEmpty()) + qWarning().noquote() << "Lumen Wallpaper:" << message; + QPointer item = m_item; + QMetaObject::invokeMethod(item.data(), [item, status, message]() { + if (item) + item->applyRenderStatus(status, message); + }, Qt::QueuedConnection); +} diff --git a/src/rendernode.h b/src/rendernode.h new file mode 100644 index 0000000..4acff13 --- /dev/null +++ b/src/rendernode.h @@ -0,0 +1,56 @@ +#pragma once + +#include "lumen_ffi.h" + +#include +#include +#include +#include +#include +#include + +class VulkanWallpaperItem; + +class QQuickWindow; + +struct WallpaperFrameState { + QByteArray shaderSource; + QString fileError; + QSizeF itemSize; + QSize outputSize; + QQuickWindow *window = nullptr; + qreal devicePixelRatio = 1.0; + double time = 0.0; + double timeDelta = 1.0 / 30.0; + double frameRate = 30.0; + quint64 frame = 0; + QVector4D mouse; + int screenIndex = 0; + int screenCount = 1; + int sampleCount = 1; +}; + +class LumenRenderNode final : public QSGRenderNode { +public: + explicit LumenRenderNode(VulkanWallpaperItem *item); + ~LumenRenderNode() override; + + void sync(const WallpaperFrameState &state); + void render(const RenderState *state) override; + void releaseResources() override; + StateFlags changedStates() const override; + RenderingFlags flags() const override; + QRectF rect() const override; + +private: + void publishStatus(int status, const QString &message); + + QPointer m_item; + QQuickWindow *m_window = nullptr; + LumenRenderer *m_renderer; + WallpaperFrameState m_state; + QByteArray m_loadedSource; + QSizeF m_itemSize; + QString m_lastMessage; + int m_lastStatus = -2; +}; diff --git a/src/wallpaperitem.cpp b/src/wallpaperitem.cpp new file mode 100644 index 0000000..1ec4158 --- /dev/null +++ b/src/wallpaperitem.cpp @@ -0,0 +1,262 @@ +#include "wallpaperitem.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +const QByteArray fallbackShader = R"( +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 uv = fragCoord / iResolution.xy; + vec3 color = 0.04 + 0.04 * cos(iTime * 0.2 + uv.xyx + vec3(0.0, 2.0, 4.0)); + fragColor = vec4(color, 1.0); +} +)"; +} + +VulkanWallpaperItem::VulkanWallpaperItem(QQuickItem *parent) + : QQuickItem(parent) { + setFlag(ItemHasContents, true); + setAcceptHoverEvents(true); + setAcceptedMouseButtons(Qt::LeftButton); + m_shaderSource = fallbackShader; + m_renderTimer.setTimerType(Qt::PreciseTimer); + m_renderTimer.setSingleShot(false); + connect(&m_renderTimer, &QTimer::timeout, this, &VulkanWallpaperItem::tick); + m_reloadTimer.setSingleShot(true); + m_reloadTimer.setInterval(75); + connect(&m_reloadTimer, &QTimer::timeout, this, [this]() { + loadShader(); + updateWatcher(); + }); + connect(&m_watcher, &QFileSystemWatcher::fileChanged, + this, [this](const QString &) { + m_reloadTimer.start(); + }); + m_clock.start(); + m_lastTickNs = m_clock.nsecsElapsed(); + updateTimer(); +} + +void VulkanWallpaperItem::setShaderUrl(const QUrl &url) { + if (m_shaderUrl == url) + return; + m_shaderUrl = url; + m_shaderFile = url.isLocalFile() ? url.toLocalFile() : url.toString(); + loadShader(); + updateWatcher(); + Q_EMIT shaderUrlChanged(); +} + +void VulkanWallpaperItem::setTargetFps(int fps) { + fps = std::clamp(fps, 1, 240); + if (m_targetFps == fps) + return; + m_targetFps = fps; + updateTimer(); + Q_EMIT targetFpsChanged(); +} + +void VulkanWallpaperItem::setSpeed(double speed) { + speed = std::clamp(speed, 0.0, 10.0); + if (qFuzzyCompare(m_speed, speed)) + return; + m_speed = speed; + Q_EMIT speedChanged(); +} + +void VulkanWallpaperItem::setRunning(bool running) { + if (m_running == running) + return; + m_running = running; + m_lastTickNs = m_clock.nsecsElapsed(); + updateTimer(); + update(); + Q_EMIT runningChanged(); +} + +void VulkanWallpaperItem::setMouseEnabled(bool enabled) { + if (m_mouseEnabled == enabled) + return; + m_mouseEnabled = enabled; + setAcceptHoverEvents(enabled); + setAcceptedMouseButtons(enabled ? Qt::LeftButton : Qt::NoButton); + if (!enabled) + m_mouse = QVector4D(); + update(); + Q_EMIT mouseEnabledChanged(); +} + +void VulkanWallpaperItem::setWatchSource(bool enabled) { + if (m_watchSource == enabled) + return; + m_watchSource = enabled; + updateWatcher(); + Q_EMIT watchSourceChanged(); +} + +void VulkanWallpaperItem::applyRenderStatus(int status, const QString &message) { + m_renderStatus = status; + m_renderError = message; + updateErrorState(); +} + +QSGNode *VulkanWallpaperItem::updatePaintNode(QSGNode *oldNode, UpdatePaintNodeData *) { + auto *node = static_cast(oldNode); + if (!node) + node = new LumenRenderNode(this); + + WallpaperFrameState state; + state.shaderSource = m_shaderSource; + state.fileError = m_fileError; + state.itemSize = size(); + state.window = window(); + state.devicePixelRatio = window() ? window()->effectiveDevicePixelRatio() : 1.0; + state.outputSize = window() ? window()->size() * state.devicePixelRatio : QSize(); + state.sampleCount = window() ? std::max(1, window()->format().samples()) : 1; + state.time = m_animationTime; + state.timeDelta = m_timeDelta; + state.frameRate = m_frameRate; + state.frame = m_frame; + state.mouse = m_mouse; + if (window() && window()->screen()) { + const auto screens = QGuiApplication::screens(); + state.screenIndex = std::max(0, static_cast(screens.indexOf(window()->screen()))); + state.screenCount = screens.size(); + } + node->sync(state); + return node; +} + +void VulkanWallpaperItem::itemChange(ItemChange change, const ItemChangeData &value) { + QQuickItem::itemChange(change, value); + if (change == ItemSceneChange) { + if (value.window) { + connect(value.window, &QQuickWindow::visibleChanged, + this, &VulkanWallpaperItem::updateTimer, Qt::UniqueConnection); + } + updateTimer(); + } else if (change == ItemDevicePixelRatioHasChanged) { + update(); + } +} + +void VulkanWallpaperItem::geometryChange(const QRectF &newGeometry, const QRectF &oldGeometry) { + QQuickItem::geometryChange(newGeometry, oldGeometry); + update(); +} + +void VulkanWallpaperItem::hoverMoveEvent(QHoverEvent *event) { + setMousePosition(event->position()); + event->setAccepted(false); +} + +void VulkanWallpaperItem::mouseMoveEvent(QMouseEvent *event) { + setMousePosition(event->position()); + event->accept(); +} + +void VulkanWallpaperItem::mousePressEvent(QMouseEvent *event) { + setMousePosition(event->position()); + setMouseDown(true); + event->accept(); +} + +void VulkanWallpaperItem::mouseReleaseEvent(QMouseEvent *event) { + setMousePosition(event->position()); + setMouseDown(false); + event->accept(); +} + +void VulkanWallpaperItem::mouseUngrabEvent() { + setMouseDown(false); +} + +void VulkanWallpaperItem::loadShader() { + QFile file(m_shaderFile); + if (m_shaderFile.isEmpty() || !file.open(QIODevice::ReadOnly | QIODevice::Text)) { + m_fileError = QStringLiteral("Cannot open shader: %1").arg(m_shaderFile); + updateErrorState(); + return; + } + + const QByteArray source = file.readAll(); + if (source.trimmed().isEmpty()) { + m_fileError = QStringLiteral("Shader is empty: %1").arg(m_shaderFile); + updateErrorState(); + return; + } + + m_shaderSource = source; + m_fileError.clear(); + updateErrorState(); + update(); +} + +void VulkanWallpaperItem::updateWatcher() { + const QStringList watched = m_watcher.files(); + if (!watched.isEmpty()) + m_watcher.removePaths(watched); + if (m_watchSource && !m_shaderFile.isEmpty() && QFileInfo::exists(m_shaderFile)) + m_watcher.addPath(m_shaderFile); +} + +void VulkanWallpaperItem::updateTimer() { + const bool windowVisible = !window() || window()->isVisible(); + if (m_running && windowVisible && isVisible()) { + m_renderTimer.start(std::max(1, 1000 / m_targetFps)); + } else { + m_renderTimer.stop(); + } +} + +void VulkanWallpaperItem::tick() { + const qint64 now = m_clock.nsecsElapsed(); + const double elapsed = std::clamp((now - m_lastTickNs) / 1'000'000'000.0, 0.0, 0.25); + m_lastTickNs = now; + m_timeDelta = elapsed > 0.0 ? elapsed : 1.0 / m_targetFps; + m_frameRate = 1.0 / m_timeDelta; + m_animationTime += elapsed * m_speed; + ++m_frame; + update(); +} + +void VulkanWallpaperItem::setMousePosition(const QPointF &position) { + if (!m_mouseEnabled) + return; + const qreal dpr = window() ? window()->effectiveDevicePixelRatio() : 1.0; + m_mouse.setX(static_cast(position.x() * dpr)); + m_mouse.setY(static_cast((height() - position.y()) * dpr)); + if (!m_running) + update(); +} + +void VulkanWallpaperItem::setMouseDown(bool down) { + m_mouse.setZ(down ? 1.0f : 0.0f); + m_mouse.setW(down ? 1.0f : 0.0f); + update(); +} + +void VulkanWallpaperItem::updateErrorState() { + QString message = m_renderError; + if (!m_fileError.isEmpty() && !message.contains(m_fileError)) { + if (!message.isEmpty()) + message += QLatin1Char('\n'); + message += m_fileError; + } + const bool fallback = m_renderStatus == 1 + || (m_renderStatus != -1 && !m_fileError.isEmpty()); + if (message != m_errorMessage) { + m_errorMessage = message; + Q_EMIT errorMessageChanged(); + } + if (fallback != m_fallbackActive) { + m_fallbackActive = fallback; + Q_EMIT fallbackActiveChanged(); + } +} diff --git a/src/wallpaperitem.h b/src/wallpaperitem.h new file mode 100644 index 0000000..382f4d2 --- /dev/null +++ b/src/wallpaperitem.h @@ -0,0 +1,97 @@ +#pragma once + +#include "rendernode.h" + +#include +#include +#include +#include +#include +#include +#include + +class VulkanWallpaperItem : public QQuickItem { + Q_OBJECT + Q_PROPERTY(QUrl shaderUrl READ shaderUrl WRITE setShaderUrl NOTIFY shaderUrlChanged) + Q_PROPERTY(int targetFps READ targetFps WRITE setTargetFps NOTIFY targetFpsChanged) + Q_PROPERTY(double speed READ speed WRITE setSpeed NOTIFY speedChanged) + Q_PROPERTY(bool running READ running WRITE setRunning NOTIFY runningChanged) + Q_PROPERTY(bool mouseEnabled READ mouseEnabled WRITE setMouseEnabled NOTIFY mouseEnabledChanged) + Q_PROPERTY(bool watchSource READ watchSource WRITE setWatchSource NOTIFY watchSourceChanged) + Q_PROPERTY(QString errorMessage READ errorMessage NOTIFY errorMessageChanged) + Q_PROPERTY(bool fallbackActive READ fallbackActive NOTIFY fallbackActiveChanged) + +public: + explicit VulkanWallpaperItem(QQuickItem *parent = nullptr); + ~VulkanWallpaperItem() override = default; + + QUrl shaderUrl() const { return m_shaderUrl; } + int targetFps() const { return m_targetFps; } + double speed() const { return m_speed; } + bool running() const { return m_running; } + bool mouseEnabled() const { return m_mouseEnabled; } + bool watchSource() const { return m_watchSource; } + QString errorMessage() const { return m_errorMessage; } + bool fallbackActive() const { return m_fallbackActive; } + + void setShaderUrl(const QUrl &url); + void setTargetFps(int fps); + void setSpeed(double speed); + void setRunning(bool running); + void setMouseEnabled(bool enabled); + void setWatchSource(bool enabled); + void applyRenderStatus(int status, const QString &message); + +Q_SIGNALS: + void shaderUrlChanged(); + void targetFpsChanged(); + void speedChanged(); + void runningChanged(); + void mouseEnabledChanged(); + void watchSourceChanged(); + void errorMessageChanged(); + void fallbackActiveChanged(); + +protected: + QSGNode *updatePaintNode(QSGNode *oldNode, UpdatePaintNodeData *) override; + void itemChange(ItemChange change, const ItemChangeData &value) override; + void geometryChange(const QRectF &newGeometry, const QRectF &oldGeometry) override; + void hoverMoveEvent(QHoverEvent *event) override; + void mouseMoveEvent(QMouseEvent *event) override; + void mousePressEvent(QMouseEvent *event) override; + void mouseReleaseEvent(QMouseEvent *event) override; + void mouseUngrabEvent() override; + +private: + void loadShader(); + void updateWatcher(); + void updateTimer(); + void tick(); + void setMousePosition(const QPointF &position); + void setMouseDown(bool down); + void updateErrorState(); + + QUrl m_shaderUrl; + QString m_shaderFile; + QByteArray m_shaderSource; + QString m_fileError; + QString m_renderError; + QString m_errorMessage; + QFileSystemWatcher m_watcher; + QTimer m_renderTimer; + QTimer m_reloadTimer; + QElapsedTimer m_clock; + qint64 m_lastTickNs = 0; + double m_animationTime = 0.0; + double m_timeDelta = 1.0 / 30.0; + double m_frameRate = 30.0; + quint64 m_frame = 0; + int m_targetFps = 30; + double m_speed = 1.0; + bool m_running = true; + bool m_mouseEnabled = true; + bool m_watchSource = true; + bool m_fallbackActive = false; + int m_renderStatus = -2; + QVector4D m_mouse; +};