motionlab/shaders.py
0xrsydn 6591138bbf Python/OpenCV proof of concept plus first native Swift port
Snapshot before the Python POC is removed. The POC (main.py, effects.py,
panel.py, shaders.py, effects/*.glsl, flake.nix) is the reference for the
native app under native/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 20:26:47 +07:00

172 lines
6.2 KiB
Python

"""Shader layers: every effects/*.glsl file becomes a layer, reloaded whenever the file is saved.
A shader file only contains its own sliders and a main(). The host supplies the rest:
in vec2 uv; pixel position, (0,0) top-left .. (1,1) bottom-right
out vec4 color; what to write for this pixel
uniform sampler2D camera; the incoming frame (output of the layer above), RGB
uniform sampler2D prev; this layer's own output from the previous frame
uniform sampler2D flow; .xy = how far this pixel moved since last frame, in uv units
uniform sampler2D motion; .r = how much this pixel changed since last frame, 0..1
uniform float time; seconds since start
uniform float energy; overall amount of motion in the frame, 0..1
uniform vec2 resolution; output size in pixels
Sliders are plain float uniforms with a range comment:
uniform float amount; // 0.0 .. 0.05 = 0.01
"""
import os
import re
import moderngl
import numpy as np
from effects import Layer, Param
VERTEX = """#version 410
out vec2 uv;
void main() {
uv = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);
gl_Position = vec4(uv * 2.0 - 1.0, 0.0, 1.0);
}
"""
# "#line 1" makes compiler errors report line numbers of the user's file
HEADER = """#version 410
uniform sampler2D camera;
uniform sampler2D prev;
uniform sampler2D flow;
uniform sampler2D motion;
uniform float time;
uniform float energy;
uniform vec2 resolution;
in vec2 uv;
out vec4 color;
#line 1
"""
NUM = r"(-?[\d.]+)"
PARAM_RE = re.compile(rf"uniform\s+float\s+(\w+)\s*;(?:[ \t]*//\s*{NUM}\s*\.\.\s*{NUM}\s*=\s*{NUM})?")
class ShaderLayer(Layer):
enabled = False
def __init__(self, gl, path):
super().__init__()
self.gl, self.path = gl, path
self.name = os.path.splitext(os.path.basename(path))[0]
self.program = None
self.error = None
self.mtime = None
self.targets = None
self.reload()
def reload(self):
"""Recompile if the file changed. On a compile error the last working version keeps running."""
try:
mtime = os.stat(self.path).st_mtime
except FileNotFoundError:
return
if mtime == self.mtime:
return
self.mtime = mtime
with open(self.path) as f:
source = f.read()
try:
program = self.gl.program(vertex_shader=VERTEX, fragment_shader=HEADER + source)
except moderngl.Error as e:
self.error = str(e)
print(f"[{self.name}] shader error:\n{e}", flush=True)
return
self.error = None
self.program = program
self.vao = self.gl.vertex_array(program, [])
old = self.params
self.params = {name: Param(float(default or 0.5), float(lo or 0), float(hi or 1))
for name, lo, hi, default in PARAM_RE.findall(source)}
for name, p in self.params.items(): # keep slider positions across reloads
if name in old:
p.value = min(max(old[name].value, p.lo), p.hi)
print(f"[{self.name}] loaded", flush=True)
def _target(self, size):
texture = self.gl.texture(size, 4)
texture.repeat_x = texture.repeat_y = False
return texture, self.gl.framebuffer([texture])
def _texture(self, attr, size, components, dtype="f1"):
if not hasattr(self, attr):
texture = self.gl.texture(size, components, dtype=dtype)
texture.repeat_x = texture.repeat_y = False
setattr(self, attr, texture)
return getattr(self, attr)
def _set(self, name, value):
uniform = self.program.get(name, None) # absent if the shader doesn't use it
if uniform is not None:
uniform.value = value
return uniform is not None
def apply(self, frame, ctx):
if self.program is None:
return frame
h, w = frame.shape[:2]
if self.targets is None:
# two render targets, swapped every frame, so the shader can read its previous output
self.targets = [self._target((w, h)), self._target((w, h))]
(_, framebuffer), (prev, _) = self.targets
camera = self._texture("camera", (w, h), 3)
camera.swizzle = "BGR1" # frames are BGR; shaders see RGB
camera.write(np.ascontiguousarray(frame))
camera.use(0)
self._set("camera", 0)
prev.use(1)
self._set("prev", 1)
if self._set("flow", 2):
fh, fw = ctx.flow.shape[:2]
flow = self._texture("flow_tex", (fw, fh), 2, "f4")
flow.write((ctx.flow / (fw, fh)).astype("f4"))
flow.use(2)
if self._set("motion", 3):
mh, mw = ctx.motion.shape
motion = self._texture("motion_tex", (mw, mh), 1)
motion.write(ctx.motion)
motion.use(3)
self._set("time", ctx.t)
self._set("energy", ctx.energy)
self._set("resolution", (w, h))
for name, p in self.params.items():
self._set(name, p.value)
framebuffer.use()
self.vao.render(moderngl.TRIANGLES, vertices=3)
out = np.frombuffer(framebuffer.read(components=3), np.uint8).reshape(h, w, 3)
self.targets.reverse()
return np.ascontiguousarray(out[:, :, ::-1])
class ShaderBank:
"""Watches a folder of .glsl files and keeps one ShaderLayer per file in the shared layer list."""
def __init__(self, directory, layers):
self.directory, self.layers = directory, layers
self.known = {}
self.gl = moderngl.create_context(standalone=True, require=410)
self.refresh()
def refresh(self):
if os.path.isdir(self.directory):
for filename in sorted(os.listdir(self.directory)):
path = os.path.join(self.directory, filename)
if filename.endswith(".glsl") and path not in self.known:
self.known[path] = ShaderLayer(self.gl, path)
self.layers.append(self.known[path])
for layer in self.known.values():
layer.reload()
@property
def errors(self):
return [layer.name for layer in self.known.values() if layer.error]