368 lines
12 KiB
Python
368 lines
12 KiB
Python
from collections.abc import Iterable
|
|
from enum import Enum
|
|
from typing import Literal, cast, overload
|
|
|
|
import gi
|
|
import OpenGL.GL as GL
|
|
from fabric import Application, Property, Signal
|
|
from fabric.widgets.widget import Widget
|
|
from OpenGL.GL.shaders import compileProgram, compileShader
|
|
|
|
gi.require_version("Gtk", "3.0")
|
|
from gi.repository import Gdk, GdkPixbuf, GLib, Gtk
|
|
|
|
|
|
class ShadertoyUniformType(Enum):
|
|
# TODO: add more types
|
|
FLOAT = 1
|
|
INTEGER = 2
|
|
VECTOR = 3
|
|
TEXTURE = 4
|
|
|
|
|
|
class ShadertoyCompileError(Exception): ...
|
|
|
|
|
|
class Shadertoy(Gtk.GLArea, Widget):
|
|
@Signal # pygobject signal
|
|
def ready(self) -> None: ...
|
|
|
|
@Property(str, "read-write")
|
|
def shader_buffer(self) -> str:
|
|
return self._shader_buffer
|
|
|
|
@shader_buffer.setter
|
|
def shader_buffer(self, shader_buffer: str) -> None:
|
|
self._shader_buffer = shader_buffer
|
|
if not self._ready:
|
|
return
|
|
self._shader_uniforms.clear()
|
|
self.do_realize()
|
|
self.queue_draw()
|
|
return
|
|
|
|
# signatures for building a replica of shadertoy
|
|
DEFAULT_VERTEX_SHADER = """
|
|
#version 330
|
|
|
|
in vec2 position;
|
|
|
|
void main() {
|
|
gl_Position = vec4(position, 0.0, 1.0);
|
|
}
|
|
"""
|
|
|
|
DEFAULT_FRAGMENT_UNIFORMS = """
|
|
#version 330
|
|
|
|
uniform vec3 iResolution; // viewport resolution (in pixels)
|
|
uniform float iTime; // shader playback time (in seconds)
|
|
uniform float iTimeDelta; // render time (in seconds)
|
|
uniform float iFrameRate; // shader frame rate
|
|
uniform int iFrame; // shader playback frame
|
|
uniform float iChannelTime[4]; // channel playback time (in seconds)
|
|
uniform vec3 iChannelResolution[4]; // channel resolution (in pixels)
|
|
uniform vec4 iMouse; // mouse pixel coords. xy: current (if MLB down), zw: click
|
|
uniform sampler2D iChannel0; // input channel. XX = 2D/Cube
|
|
uniform sampler2D iChannel1;
|
|
uniform sampler2D iChannel2;
|
|
uniform sampler2D iChannel3;
|
|
uniform vec4 iDate; // (year, month, day, time in seconds)
|
|
uniform float iSampleRate; // sound sample rate (i.e., 44100)
|
|
|
|
"""
|
|
|
|
FRAGMENT_MAIN_FUNCTION = """
|
|
void main() {
|
|
mainImage(gl_FragColor, gl_FragCoord.xy);
|
|
}
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
shader_buffer: str,
|
|
shader_uniforms: list[
|
|
tuple[
|
|
str,
|
|
ShadertoyUniformType,
|
|
bool | float | int | tuple[float, ...] | GdkPixbuf.Pixbuf,
|
|
]
|
|
]
|
|
| None = None,
|
|
name: str | None = None,
|
|
visible: bool = True,
|
|
all_visible: bool = False,
|
|
style: str | None = None,
|
|
style_classes: Iterable[str] | str | None = None,
|
|
tooltip_text: str | None = None,
|
|
tooltip_markup: str | None = None,
|
|
h_align: Literal["fill", "start", "end", "center", "baseline"]
|
|
| Gtk.Align
|
|
| None = None,
|
|
v_align: Literal["fill", "start", "end", "center", "baseline"]
|
|
| Gtk.Align
|
|
| None = None,
|
|
h_expand: bool = False,
|
|
v_expand: bool = False,
|
|
size: Iterable[int] | int | None = None,
|
|
**kwargs,
|
|
):
|
|
Gtk.GLArea.__init__(
|
|
self # type: ignore
|
|
)
|
|
Widget.__init__(
|
|
self,
|
|
name,
|
|
visible,
|
|
all_visible,
|
|
style,
|
|
style_classes,
|
|
tooltip_text,
|
|
tooltip_markup,
|
|
h_align,
|
|
v_align,
|
|
h_expand,
|
|
v_expand,
|
|
size,
|
|
**kwargs,
|
|
)
|
|
self._shader_buffer = shader_buffer
|
|
self._shader_uniforms = shader_uniforms or []
|
|
|
|
# widget settings
|
|
self.set_required_version(3, 3)
|
|
self.set_has_depth_buffer(False)
|
|
self.set_has_stencil_buffer(False)
|
|
|
|
self._ready = False
|
|
self._program = None
|
|
self._vao = None
|
|
self._quad_vbo = None
|
|
self._texture_units = {}
|
|
|
|
# timer
|
|
self._start_time = GLib.get_monotonic_time() / 1e6
|
|
self._frame_time = self._start_time
|
|
self._frame_count = 0
|
|
|
|
# to avoid a constant framerate we tell
|
|
# gtk to render a frame whenever possible
|
|
self._tick_id = self.add_tick_callback(lambda *_: (self.queue_draw(), True)[1])
|
|
|
|
def do_bake_program(self):
|
|
try:
|
|
vertex_shader = compileShader(
|
|
self.DEFAULT_VERTEX_SHADER, GL.GL_VERTEX_SHADER
|
|
)
|
|
fragment_shader = compileShader(
|
|
self.DEFAULT_FRAGMENT_UNIFORMS
|
|
+ self._shader_buffer
|
|
+ self.FRAGMENT_MAIN_FUNCTION,
|
|
GL.GL_FRAGMENT_SHADER,
|
|
)
|
|
except Exception as e:
|
|
raise ShadertoyCompileError(
|
|
f"couldn't compile the provided shader, OpenGL error:\n {e}"
|
|
)
|
|
|
|
return compileProgram(vertex_shader, fragment_shader)
|
|
|
|
def do_realize(self, *_):
|
|
Gtk.GLArea.do_realize(self)
|
|
if not self._ready:
|
|
ctx = self.get_context()
|
|
if (err := self.get_error()) or not ctx:
|
|
raise RuntimeError(
|
|
f"couldn't initialize the drawing context, error: {err or 'context is None'}"
|
|
)
|
|
|
|
ctx.make_current()
|
|
|
|
if self._program:
|
|
GL.glDeleteProgram(self._program)
|
|
self._program = None
|
|
self._program = self.do_bake_program()
|
|
|
|
# NOTE: for this to work (alpha pixels) `self.set_has_alpha(True)` must be done
|
|
# this breaks some fragment shaders, for some reason, so i'm leaving it for anyone willing to use
|
|
GL.glEnable(GL.GL_BLEND)
|
|
GL.glBlendFunc(GL.GL_SRC_ALPHA, GL.GL_ONE_MINUS_SRC_ALPHA)
|
|
|
|
self._quad_vbo = GL.glGenBuffers(1)
|
|
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self._quad_vbo)
|
|
|
|
# this is not so good, unless the introduction of numpy, we must do
|
|
# a hack to generate an array GL would accept, i've tried using
|
|
# the "array" python library but it doesn't seem to be working
|
|
|
|
# cast python type into GL type (list[float] -> arraybuf[GLfloat])
|
|
quad_verts = (-1.0, -1.0, 1.0, -1.0, -1.0, 1.0, 1.0, 1.0)
|
|
array_type = GL.GLfloat * len(quad_verts)
|
|
|
|
GL.glBufferData(
|
|
GL.GL_ARRAY_BUFFER,
|
|
len(quad_verts) * 4,
|
|
array_type(*quad_verts),
|
|
GL.GL_STATIC_DRAW,
|
|
)
|
|
|
|
self._vao = GL.glGenVertexArrays(1)
|
|
GL.glBindVertexArray(self._vao)
|
|
|
|
position = GL.glGetAttribLocation(self._program, "position")
|
|
GL.glEnableVertexAttribArray(position)
|
|
GL.glVertexAttribPointer(position, 2, GL.GL_FLOAT, GL.GL_FALSE, 0, None)
|
|
|
|
for uname, utype, uvalue in self._shader_uniforms:
|
|
self.set_uniform(uname, utype, uvalue) # type: ignore
|
|
|
|
self._ready = True
|
|
self.ready()
|
|
return
|
|
|
|
def do_get_timing(self) -> tuple[float, float, float]:
|
|
current_time = GLib.get_monotonic_time() / 1e6
|
|
delta_time = current_time - self._frame_time
|
|
return current_time, delta_time, (1.0 / delta_time) if delta_time > 0 else 0.0
|
|
|
|
def do_post_render(self, time: float):
|
|
self._frame_time = time
|
|
self._frame_count += 1
|
|
return
|
|
|
|
def do_render(self, ctx: Gdk.GLContext):
|
|
if not self._program:
|
|
if self._tick_id:
|
|
self.remove_tick_callback(self._tick_id)
|
|
self._tick_id = 0
|
|
return False
|
|
|
|
GL.glUseProgram(self._program)
|
|
|
|
# clear up for next frame
|
|
GL.glClear(GL.GL_COLOR_BUFFER_BIT)
|
|
|
|
alloc = self.get_allocation()
|
|
width: int = alloc.width # type: ignore
|
|
height: int = alloc.height # type: ignore
|
|
mouse_pos = cast(tuple[int, int], self.get_pointer())
|
|
|
|
current_time, delta_time, frame_rate = self.do_get_timing()
|
|
|
|
self.set_uniform(
|
|
"iTime", ShadertoyUniformType.FLOAT, current_time - self._start_time
|
|
)
|
|
self.set_uniform("iFrame", ShadertoyUniformType.INTEGER, self._frame_count)
|
|
self.set_uniform("iTimeDelta", ShadertoyUniformType.FLOAT, delta_time)
|
|
self.set_uniform("iFrameRate", ShadertoyUniformType.FLOAT, frame_rate)
|
|
self.set_uniform(
|
|
"iResolution", ShadertoyUniformType.VECTOR, (width, height, 1.0)
|
|
)
|
|
self.set_uniform(
|
|
"iMouse",
|
|
ShadertoyUniformType.VECTOR,
|
|
(mouse_pos[0], height - mouse_pos[1], 0, 0),
|
|
)
|
|
|
|
# paint the quad
|
|
GL.glBindVertexArray(self._vao)
|
|
GL.glDrawArrays(GL.GL_TRIANGLE_STRIP, 0, 4)
|
|
self.do_post_render(current_time)
|
|
return True
|
|
|
|
def do_resize(self, width: int, height: int):
|
|
Gtk.GLArea.do_resize(self, width, height)
|
|
GL.glViewport(0, 0, width, height)
|
|
return
|
|
|
|
@overload
|
|
def set_uniform(
|
|
self, name: str, type: Literal[ShadertoyUniformType.FLOAT], value: float
|
|
): ...
|
|
|
|
@overload
|
|
def set_uniform(
|
|
self, name: str, type: Literal[ShadertoyUniformType.INTEGER], value: int
|
|
): ...
|
|
|
|
@overload
|
|
def set_uniform(
|
|
self,
|
|
name: str,
|
|
type: Literal[ShadertoyUniformType.VECTOR],
|
|
value: tuple[float, ...],
|
|
): ...
|
|
|
|
@overload
|
|
def set_uniform(
|
|
self,
|
|
name: str,
|
|
type: Literal[ShadertoyUniformType.TEXTURE],
|
|
value: GdkPixbuf.Pixbuf,
|
|
): ...
|
|
|
|
def set_uniform(
|
|
self,
|
|
name: str,
|
|
type: ShadertoyUniformType,
|
|
value: bool | float | int | tuple[float, ...] | GdkPixbuf.Pixbuf,
|
|
):
|
|
if not self._program:
|
|
raise RuntimeError("the shader program is not initialized")
|
|
GL.glUseProgram(self._program)
|
|
location = GL.glGetUniformLocation(self._program, name)
|
|
match type:
|
|
case ShadertoyUniformType.VECTOR:
|
|
value = cast(tuple[float, ...], value)
|
|
(
|
|
GL.glUniform2f
|
|
if (vlen := len(value)) == 2
|
|
else GL.glUniform3f
|
|
if vlen == 3
|
|
else GL.glUniform4f
|
|
)(location, *value)
|
|
case ShadertoyUniformType.FLOAT:
|
|
GL.glUniform1f(location, value)
|
|
case ShadertoyUniformType.INTEGER:
|
|
GL.glUniform1i(location, value)
|
|
case ShadertoyUniformType.TEXTURE:
|
|
# who dislikes boilerplate?
|
|
value = cast(GdkPixbuf.Pixbuf, value).flip(False)
|
|
format = GL.GL_RGBA if value.get_has_alpha() else GL.GL_RGB
|
|
|
|
if name not in self._texture_units:
|
|
texture = GL.glGenTextures(1)
|
|
self._texture_units[name] = (len(self._texture_units), texture)
|
|
else:
|
|
texture_unit, texture = self._texture_units[name]
|
|
|
|
texture_unit = self._texture_units[name][0]
|
|
GL.glActiveTexture(GL.GL_TEXTURE0 + texture_unit)
|
|
GL.glBindTexture(GL.GL_TEXTURE_2D, texture)
|
|
|
|
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_S, GL.GL_REPEAT)
|
|
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_T, GL.GL_REPEAT)
|
|
GL.glTexParameteri(
|
|
GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MIN_FILTER, GL.GL_LINEAR
|
|
)
|
|
GL.glTexParameteri(
|
|
GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MAG_FILTER, GL.GL_LINEAR
|
|
)
|
|
|
|
# "upload" the texture
|
|
GL.glTexImage2D(
|
|
GL.GL_TEXTURE_2D,
|
|
0, # detail level (woah?)
|
|
format, # result format
|
|
value.get_width(),
|
|
value.get_height(),
|
|
0, # "border"
|
|
format, # input format
|
|
GL.GL_UNSIGNED_BYTE,
|
|
value.get_pixels(),
|
|
)
|
|
GL.glGenerateMipmap(GL.GL_TEXTURE_2D)
|
|
|
|
# all aboard...
|
|
GL.glUniform1i(location, texture_unit)
|