Just for fun WebGPU and Pip for Krita

Experimental WebGPU + PIP plugin

I have toyed a bit with WebGPU (wgpu rust implementaion and it’s python binding)
Also plugin uses Pip to install required packages, and if Pip it self is missing, it also will be installed.

About WebGPU:

  • it’s easier than Vulcan.
  • It’s multi-platform (can use OpenGL, Vulcan, DirectX and Metal as backend.)
  • It`s fresh, without 10+years of legacy code.
  • When DockWidget is docked to Krita’s MainWindow canvas will have insane lag (without animation updates there is no lag)

About Pip

  • packages are installed on shared users package-site folder (packages will be shared with system python of same version.)
  • request.urlopen(...) is used to download latest Pip wheel from pypi.org site (on macOS ssl.SSLContext() would be required, but this test code lacks that.)
  • But if you really wish to test this on macOS, pip-25.0.1-py3-none-any.whl can be manually downloaded from pypi.org/pypi/pip and placed in user pykrita folder)

File tree

pykrita/
    hello_wgpu_plugin/
        __init__.py
        pip_for_krita.py
        hello_wgpu_docker.py
    hello_wgpu_plugin.desktop

hello_wgpu_plugin/__init__.py

from krita import (
        Krita,
        DockWidgetFactory,
        DockWidgetFactoryBase)


PLUGIN_NAME = __name__


def ensure_dependencies():
    """ check is wgpu available. If not, use pip to get it. """
    try:
        import wgpu
    except ImportError:
        from .pip_for_krita import pip_install
        pip_install(packages=['wgpu'])


def register():
    ensure_dependencies()
    from .hello_wgpu_docker import HelloWgpuDocker
    app = Krita.instance()
    app.addDockWidgetFactory(DockWidgetFactory(
            f'{PLUGIN_NAME}:{HelloWgpuDocker.__qualname__}',
            DockWidgetFactoryBase.DockRight,
            HelloWgpuDocker))


register()

hello_wgpu_plugin/pip_for_krita.py

import sys
import site
import json
import runpy
import io
from pathlib import Path
from urllib import request
from krita import Krita


def ensure_pip():
    # make sure that user site backages exists and is in sys.path
    user_site_packages = site.getusersitepackages()
    if user_site_packages not in sys.path:
        Path(user_site_packages).mkdir(parents=True, exist_ok=True)
        site.addsitedir(user_site_packages)

    # test is pip currently available
    try:
        import pip
        return  # pip available, nothing to do
    except ImportError:
        pass  # no pip, try to get it

    user_pykrita_path = Path(Krita.instance().getAppDataLocation()) / 'pykrita'

    existing_pip_wheel_path = next(user_pykrita_path.glob('pip-*.whl'), None)
    if existing_pip_wheel_path:
        # pip wheel found, making sure that it's in sys.path
        existing_pip_wheel_path_str = str(existing_pip_wheel_path)
        if existing_pip_wheel_path_str not in sys.path:
            sys.path.append(existing_pip_wheel_path_str)
        print(f'Existing Pip wheel "{existing_pip_wheel_path}" added to sys.path')
        return
    else:
        # get available Pip releases from pypi site
        pip_releases_json = json.loads(request.urlopen('https://pypi.org/pypi/pip/json').read())
        filename = None
        latest_pip_download_url = None
        for url in pip_releases_json['urls']:
            if url['packagetype'] == 'bdist_wheel':
                filename = url['filename']
                latest_pip_download_url = url['url']
        # download pip package
        pip_wheel_path = user_pykrita_path / filename
        pip_wheel_path.write_bytes(request.urlopen(latest_pip_download_url).read())
        sys.path.append(str(pip_wheel_path))
        print(f'Pip wheel "{pip_wheel_path}" downloaded and added to sys.path')


def pip_install(packages=None, requirement=None, upgrade=False):
    pip_cmd = ['pip', 'install', '--user']
    if upgrade:
        pip_cmd += ['--upgrade']
    if packages is not None:
        pip_cmd += packages
    if requirement is not None:
        pip_cmd += ['--requirement', requirement]
    return _run_pip(pip_cmd)


def pip_inspect():
    stdout, stderr = _run_pip(['pip', 'inspect', '--user'])
    return json.loads(stdout)


def _run_pip(pip_cmd):
    ensure_pip()
    def _dummy_exit_func(_exit_code=0):
        return _exit_code
    # store original args, exit func, stdout, stderr
    old_argv = sys.argv
    old_exit = sys.exit
    old_sys_stdout = sys.stdout
    old_sys_stderr = sys.stderr
    try:
        stdout_buffer = io.StringIO()
        stderr_buffer = io.StringIO()
        sys.exit = _dummy_exit_func
        sys.argv = pip_cmd
        sys.stdout = stdout_buffer
        sys.stderr = stderr_buffer
        runpy.run_module('pip', run_name='__main__')
        return stdout_buffer.getvalue(), stderr_buffer.getvalue()
    finally:
        # restore original args, exit func, stdout, stderr
        sys.exit = old_argv
        sys.argv = old_exit
        sys.stdout = old_sys_stdout
        sys.stderr = old_sys_stderr

hello_wgpu_plugin/hello_wgpu_docker.py

import time
from struct import Struct
from krita import Krita, DockWidget
from PyQt5.QtCore import Qt, QSize
from PyQt5.QtWidgets import QWidget, QLabel, QVBoxLayout
import wgpu
from wgpu.gui.qt import WgpuWidget


shader_art_1 = """\
// https://pongasoft.github.io/webgpu-shader-toy/ (Shader Art 1)
// Ported from Shader Art Coding (https://www.youtube.com/watch?v=f4s1h2YETNY)

struct ShaderToyInputs {
    size: vec4f,
    mouse: vec4f,
    time: f32,
    frame: i32
};

@vertex
fn vs_main(@builtin(vertex_index) i : u32) -> @builtin(position) vec4f {
    const pos = array(vec2f(-1, 1), vec2f(1, 1), vec2f(-1, -1), vec2f(1, -1));
    return vec4f(pos[i], 0, 1);
};

@group(0) @binding(0)
var<uniform> inputs: ShaderToyInputs;

fn palette(t: f32) -> vec3f {
    const a = vec3f(0.5, 0.5, 0.5);
    const b = vec3f(0.5, 0.5, 0.5);
    const c = vec3f(1.0, 1.0, 1.0);
    const d = vec3f(0.263, 0.416, 0.557);
    return a + (b * cos(6.28318 * ((c*t) + d)));
};

@fragment
fn fs_main(@builtin(position) pos: vec4f) -> @location(0) vec4f {
    var uv = (pos.xy * 2.0 - inputs.size.xy) / inputs.size.y;
    let uv0 = uv;
    var finalColor = vec3f(0.0);

    for(var i = 0; i < 4; i++) {
        uv = fract(uv * 1.5) - 0.5;
        var d = length(uv) * exp(-length(uv0));
        var col = palette(length(uv0) + f32(i) * 0.4 + inputs.time * 0.4);

        d = sin(d * 8.0 + inputs.time) / 8.0;
        d = abs(d);
        d = pow(0.01 / d, 1.2);

        finalColor += col * d;
    }
    return vec4f(finalColor, 1.0);
};
"""


class HelloWgpuDocker(DockWidget):
    def __init__(self):
        super().__init__()
        self._frame = 0
        self._start_time = time.time()
        self.setWindowTitle('Hello WGPU')
        self._init_ui()
        self._init_wgpu()

    def _init_ui(self):
        self._content_widget = QWidget()
        layout = QVBoxLayout()
        self._content_widget.setContentsMargins(6, 6, 6, 6)
        self._content_widget.setLayout(layout)
        self._wgpu_canvas = WgpuWidget(minimumSize=QSize(200, 200), parent=self._content_widget)
        layout.addWidget(self._wgpu_canvas, stretch=100)
        self._author_label = QLabel(
                text=('<a href="https://www.shadertoy.com/view/mtyGWy">'
                        '<font face="DejaVu Sans" color="#8888FF" size="5"><i>'
                        'Original created by kishimisu'
                        '</i></font></a>'),
                textFormat=Qt.RichText,
                textInteractionFlags=Qt.TextBrowserInteraction,
                openExternalLinks=True,
                alignment=Qt.AlignRight | Qt.AlignBaseline,
                parent=self._content_widget)
        layout.addWidget(self._author_label)
        self.setWidget(self._content_widget)

    def _init_wgpu(self):
        adapter = wgpu.gpu.request_adapter_sync(power_preference='high-performance')
        self._device = adapter.request_device_sync(required_limits=None)
        context = self._wgpu_canvas.get_context('wgpu')
        render_texture_format = context.get_preferred_format(self._device.adapter)
        context.configure(device=self._device, format=render_texture_format)
        shader = self._device.create_shader_module(code=shader_art_1)
        self._render_pipeline = self._device.create_render_pipeline(
                layout='auto',
                depth_stencil=None,
                multisample=None,
                primitive={
                    'topology': wgpu.PrimitiveTopology.triangle_strip,
                    'front_face': wgpu.FrontFace.ccw,
                    'cull_mode': wgpu.CullMode.none},
                vertex={
                    'module': shader,
                    'entry_point': 'vs_main'},
                fragment={
                    'module': shader,
                    'entry_point': 'fs_main',
                    'targets': [
                        {
                            'format': render_texture_format,
                            'blend': {'color': {}, 'alpha': {}}
                        }
                    ]}
                )
        self._init_uniforms()
        self._wgpu_canvas.request_draw(self.draw_wgpu)

    def _init_uniforms(self):
        self._size_struct  = Struct('4f')  # offset 0, size 16 bytes
        self._mouse_struct = Struct('4f')  # offset 16, size 16 bytes
        self._time_struct  = Struct('f')   # offset 32, size 4 bytes
        self._frame_struct = Struct('i')   # offset 36, size 4 bytes
        self._pad_8_struct = Struct('8x')  # offset 40, size 8 bytes
        self._uniform_data = bytearray(
                [0] * (self._size_struct.size
                + self._mouse_struct.size
                + self._time_struct.size
                + self._frame_struct.size
                + self._pad_8_struct.size))
        self._uniform_buffer = self._device.create_buffer(
                size=len(self._uniform_data),
                usage=wgpu.BufferUsage.UNIFORM | wgpu.BufferUsage.COPY_DST)
        bind_groups_layout_entries = [
            {
                'binding': 0,
                'resource': {
                    'buffer': self._uniform_buffer,
                    'offset': 0,
                    'size': len(self._uniform_data)
                }
            }
        ]
        binding_layout = [
            {
                'binding': 0,
                'visibility': wgpu.ShaderStage.FRAGMENT,
                'buffer': {'type': wgpu.BufferBindingType.uniform}
            }
        ]
        self._bind_group_layout = self._device.create_bind_group_layout(entries=binding_layout)
        self._bind_group = self._device.create_bind_group(
                layout=self._render_pipeline.get_bind_group_layout(0),
                entries=bind_groups_layout_entries)

    def draw_wgpu(self):
        self._size_struct.pack_into(self._uniform_data, 0,
                float(self._wgpu_canvas.width()), float(self._wgpu_canvas.height()), 1.0, 1.0)
        self._mouse_struct.pack_into(self._uniform_data, 16, 100.0, 100.0, -1.0, -1.0)
        self._time_struct.pack_into(self._uniform_data, 32, time.time() - self._start_time)
        self._frame_struct.pack_into(self._uniform_data, 36, self._frame)
        self._device.queue.write_buffer(self._uniform_buffer, 0, self._uniform_data)
        current_texture = self._wgpu_canvas.get_context('wgpu').get_current_texture()
        command_encoder = self._device.create_command_encoder()
        render_pass = command_encoder.begin_render_pass(
                color_attachments=[
                    {
                        'view': current_texture.create_view(),
                        'resolve_target': None,
                        'clear_value': (0, 0, 0, 1),
                        'load_op': wgpu.LoadOp.clear,
                        'store_op': wgpu.StoreOp.store
                    }
                ])
        render_pass.set_pipeline(self._render_pipeline)
        render_pass.set_bind_group(0, self._bind_group)
        render_pass.draw(4)
        render_pass.end()
        self._device.queue.submit([command_encoder.finish()])
        self._frame += 1
        # request next frame
        self._wgpu_canvas.update()

    def canvasChanged(self, canvas):
        pass

hello_wgpu_plugin.desktop

[Desktop Entry]
Type=Service
ServiceTypes=Krita/PythonPlugin
X-KDE-Library=hello_wgpu_plugin
X-Python-2-Compatible=false
X-Krita-Manual=readme.md
Name=Hello WGPU plugin
Comment=Experimental plugin for testing WGPU in Krita, also uses pip_for_krita to install depending packages.

Have fun!

Edit: "Original" created by kishimisu added to crediting text.

/AkiR

3 Likes

Hey, that’s a really cool hack :smiley: I need to check it out when I have a bit more time.

I was also thinking of doing something like that one day, but there’s always something that seems more important to get out of the way first :sweat_smile: I wanted to have the shader running as a layer, so that it could perhaps sample the projection below and output a new color. Something like SeExpr but with shaders, and not limited to just being a generator layer. I was hoping it could help make some cool effects, like a quick chromatic aberration, or an interesting texture or maybe even simulated lighting.

Still, super cool, and a nice opportunity to look at WebGPU. My primary expertise is with Vulkan these days (with a “k” :smiley: ), but it’s always good to be somewhat familiar with what other APIs have to offer.

Just reporting back that I got it to work on my Windows 11 without any issues, runs great! :slight_smile: And yeah, it’s actually using Vulkan by default, and DX12 is also possible. So Krita can run OpenGL and render another API in the Qt widget without any issues, which is neat. I tested on a 5.3 build.

1 Like

Nice to hear that example worked on Windows 11. Today I also did test it on Ubuntu and example worked on first try.

I also have though on SeExpr like generator but using wgpu supported languages (WGSL, SPIR-V, and GLSL). Also option to load ShaderToy shaders would be nice.

Another interesting thing would be to add GLTF preview and maybe projection painting similar to Mari. (USD preview would be nicer but I’m not sure how complete usd-core package for python is)

/AkiR