"""GPU motion-graphics engine for the MobileGL showreel (moderngl, OpenGL 4.3+ core). Everything is laid out in logical 1920x1080 pixels, y down. A frame is the average of `samples` sub-frames across a shutter (motion blur), then bloom / aberration / grain / vignette in post. Set MGL_LIBGL to an opengl32.dll to render through another GL implementation (MobileGL).""" import math import os import functools from collections import OrderedDict import numpy as np import moderngl import freetype import uharfbuzz as hb HERE = os.path.dirname(os.path.abspath(__file__)) LW, LH = 1920, 1080 # --- colour ---------------------------------------------------------------------------------------- def srgb2lin(c): c = c / 255.0 return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 def hexc(h, k=1.0, a=1.0): """'#rrggbb' -> linear (r, g, b, a), rgb scaled by k (k > 1 for emissive glow).""" h = h.lstrip('#') return (srgb2lin(int(h[0:2], 16)) * k, srgb2lin(int(h[2:4], 16)) * k, srgb2lin(int(h[4:6], 16)) * k, a) def mix(a, b, u): return tuple(x + (y - x) * u for x, y in zip(a, b)) def with_alpha(c, a): return (c[0], c[1], c[2], c[3] * a) def scale_rgb(c, k): return (c[0] * k, c[1] * k, c[2] * k, c[3]) # --- fonts ----------------------------------------------------------------------------------------- class Face: def __init__(self, path): self.path = path self.hbface = hb.Face(hb.Blob.from_file_path(path)) self.hbfont = hb.Font(self.hbface) self.upem = self.hbface.upem self.ft = freetype.Face(path) self.axes = [a.tag for a in self.ft.get_variation_info().axes] if self.ft.has_multiple_masters else [] self.ft.set_char_size(1000 * 64) self.ft.load_char('H', freetype.FT_LOAD_NO_HINTING) self.cap = self.ft.glyph.metrics.horiBearingY / 64 / 1000 # cap height / em self.ft.load_char('x', freetype.FT_LOAD_NO_HINTING) self.xh = self.ft.glyph.metrics.horiBearingY / 64 / 1000 def _vary(self, var): coords = tuple(var.get(a, None) for a in self.axes) if self.axes: info = {a.tag: a for a in self.ft.get_variation_info().axes} full = tuple(v if v is not None else info[a].default for a, v in zip(self.axes, coords)) self.ft.set_var_design_coords(full) self.hbfont.set_variations({a: v for a, v in zip(self.axes, full)}) def shape(self, s, size, var, tracking=0.0): """-> list of (gid, x, y, cluster) pen positions in px, and total advance.""" self._vary(var) buf = hb.Buffer() buf.add_str(s) buf.guess_segment_properties() hb.shape(self.hbfont, buf, {'kern': True, 'liga': True}) k = size / self.upem out = [] x = 0.0 for info, pos in zip(buf.glyph_infos, buf.glyph_positions): out.append((info.codepoint, x + pos.x_offset * k, -pos.y_offset * k, info.cluster)) x += pos.x_advance * k + tracking return out, x - (tracking if out else 0) def raster(self, gid, size, var): self._vary(var) self.ft.set_char_size(int(round(size * 64))) self.ft.load_glyph(gid, freetype.FT_LOAD_RENDER | freetype.FT_LOAD_NO_HINTING) g = self.ft.glyph b = g.bitmap arr = np.array(b.buffer, dtype=np.uint8).reshape(b.rows, b.width) if b.rows else np.zeros((0, 0), np.uint8) return arr, g.bitmap_left, g.bitmap_top FONT_FILES = { 'archivo': 'Archivo[wdth,wght].ttf', 'archivo-i': 'Archivo-Italic[wdth,wght].ttf', 'mono': 'JetBrainsMono[wght].ttf', } @functools.lru_cache(None) def face(name): return Face(os.path.join(HERE, 'fonts', FONT_FILES[name])) SIZE_LADDER = 2 ** (1 / 10) def quant_size(px): """Raster size ladder (~7% steps) so animated sizes reuse textures.""" k = math.ceil(math.log(max(px, 2)) / math.log(SIZE_LADDER) - 1e-6) return SIZE_LADDER ** k # --- shaders --------------------------------------------------------------------------------------- def _read(name): with open(os.path.join(HERE, 'shaders', name), encoding='utf-8') as f: return f.read() def preprocess(src): out = [] for line in src.splitlines(): if line.strip().startswith('#include'): name = line.split('"')[1] out.append(preprocess(COMMON if name == 'common.glsl' else _read(name))) else: out.append(line) return '\n'.join(out) VS_FULL = '''#version 430 out vec2 v_uv; void main(){ vec2 p = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2); v_uv = p; gl_Position = vec4(p * 2.0 - 1.0, 0, 1); } ''' SHAPE_VS = '''#version 430 #include "common.glsl" in vec2 a_corner; in vec4 i_rect; in vec4 i_xf; in vec4 i_kind; in vec4 i_p0; in vec4 i_p1; in vec4 i_p2; in vec4 i_p3; in vec4 i_fill; in vec4 i_stroke; in vec4 i_sw; out vec2 v_lp; flat out vec4 v_kind; flat out vec4 v_p0; flat out vec4 v_p1; flat out vec4 v_p2; flat out vec4 v_p3; flat out vec4 v_fill; flat out vec4 v_stroke; flat out vec4 v_sw; void main(){ float pad = i_sw.x + 4.0 * i_sw.y + 2.0; vec2 lp = mix(i_rect.xy - pad, i_rect.zw + pad, a_corner); float c = cos(i_xf.z), s = sin(i_xf.z); vec2 wp = i_xf.xy + i_xf.w * vec2(c * lp.x - s * lp.y, s * lp.x + c * lp.y); gl_Position = vec4(to_ndc(cam(wp)), 0, 1); v_lp = lp; v_kind = i_kind; v_p0 = i_p0; v_p1 = i_p1; v_p2 = i_p2; v_p3 = i_p3; v_fill = i_fill; v_stroke = i_stroke; v_sw = i_sw; } ''' SHAPE_FS = '''#version 430 #include "common.glsl" in vec2 v_lp; flat in vec4 v_kind; flat in vec4 v_p0; flat in vec4 v_p1; flat in vec4 v_p2; flat in vec4 v_p3; flat in vec4 v_fill; flat in vec4 v_stroke; flat in vec4 v_sw; out vec4 o; vec2 vtx(int i){ vec4 q = i < 2 ? v_p0 : i < 4 ? v_p1 : i < 6 ? v_p2 : v_p3; return (i % 2 == 0) ? q.xy : q.zw; } float sd_poly(vec2 p, int n){ vec2 v0 = vtx(0); float d = dot(p - v0, p - v0), sg = 1.0; for (int i = 0, j = n - 1; i < n; j = i, i++){ vec2 vi = vtx(i), vj = vtx(j); vec2 e = vj - vi, w = p - vi; vec2 b = w - e * clamp(dot(w, e) / dot(e, e), 0.0, 1.0); d = min(d, dot(b, b)); bvec3 cnd = bvec3(p.y >= vi.y, p.y < vj.y, e.x * w.y > e.y * w.x); if (all(cnd) || all(not(cnd))) sg *= -1.0; } return sg * sqrt(d); } float sd_arc(vec2 p, float r, float th, float a0, float a1){ float a = atan(p.y, p.x); float mid = 0.5 * (a0 + a1), half_ = 0.5 * (a1 - a0); float da = mod(a - mid + PI, 2.0 * PI) - PI; if (abs(da) <= half_) return abs(length(p) - r) - th * 0.5; vec2 e0 = r * vec2(cos(a0), sin(a0)), e1 = r * vec2(cos(a1), sin(a1)); return min(length(p - e0), length(p - e1)) - th * 0.5; } void main(){ int kind = int(v_kind.x + 0.5); vec2 p = v_lp; float d; if (kind == 0) d = length(p) - v_p0.x; else if (kind == 1) { vec2 q = abs(p) - v_p0.xy + v_p0.z; d = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - v_p0.z; } else if (kind == 2) { vec2 a = v_p0.xy, b = v_p0.zw; vec2 pa = p - a, ba = b - a; float h = clamp(dot(pa, ba) / max(dot(ba, ba), 1e-6), 0.0, 1.0); d = length(pa - ba * h) - mix(v_p1.x, v_p1.y, h); } else if (kind == 5) d = sd_arc(p, v_p0.x, v_p0.y, v_p0.z, v_p0.w); else d = sd_poly(p, int(v_kind.y + 0.5)) - v_kind.z; float aa = max(length(fwidth(p)) * 0.75, 1e-4); vec4 col = vec4(0); float af = clamp(0.5 - d / aa, 0.0, 1.0) * v_fill.a; col = vec4(v_fill.rgb * af, af); if (v_sw.x > 0.0) { float as_ = clamp(0.5 - (abs(d) - v_sw.x * 0.5) / aa, 0.0, 1.0) * v_stroke.a; col = vec4(v_stroke.rgb * as_, as_) + col * (1.0 - as_); } if (v_sw.y > 0.0) { // emissive halo, additive (alpha untouched) float q = max(d, 0.0) / v_sw.y; float g = exp(-0.5 * q * q) * v_sw.z * (d > 0.0 ? 1.0 : 0.0); col.rgb += v_fill.rgb * g; } o = col * u_alpha; } ''' TEX_VS = '''#version 430 #include "common.glsl" in vec2 a_corner; uniform vec4 u_rect; // local quad x0,y0,x1,y1 uniform vec2 u_t; // translation uniform vec4 u_m; // 2x2 matrix (m00, m10, m01, m11) out vec2 v_uv; void main(){ vec2 lp = mix(u_rect.xy, u_rect.zw, a_corner); vec2 wp = u_t + vec2(u_m.x * lp.x + u_m.z * lp.y, u_m.y * lp.x + u_m.w * lp.y); gl_Position = vec4(to_ndc(cam(wp)), 0, 1); v_uv = a_corner; } ''' TEX_FS = '''#version 430 #include "common.glsl" in vec2 v_uv; uniform sampler2D u_tex; uniform vec4 u_color; uniform vec4 u_reveal; // u0, u1, softness, mode (0 = horizontal, 1 = vertical) uniform int u_mode; // 0 = mask (R channel) coloured, 1 = premultiplied rgba image out vec4 o; void main(){ vec4 c; if (u_mode == 0) { float m = texture(u_tex, v_uv).r; c = vec4(u_color.rgb, 1.0) * m * u_color.a; } else c = texture(u_tex, v_uv) * u_color; float x = u_reveal.w > 0.5 ? v_uv.y : v_uv.x; float sft = max(u_reveal.z, 1e-4); float r = smoothstep(u_reveal.x - sft, u_reveal.x, x) * (1.0 - smoothstep(u_reveal.y, u_reveal.y + sft, x)); o = c * r * u_alpha; } ''' COMMON = ''' uniform vec4 u_cam; // centre x, y, zoom, rotation uniform vec2 u_shake; uniform vec2 u_logical; // 1920, 1080 uniform float u_alpha; const float PI = 3.14159265358979; vec2 cam(vec2 p){ vec2 c = u_logical * 0.5; vec2 q = p - u_cam.xy; float cs = cos(u_cam.w), sn = sin(u_cam.w); q = vec2(cs * q.x - sn * q.y, sn * q.x + cs * q.y) * u_cam.z; return q + c + u_shake; } vec2 uncam(vec2 s){ vec2 c = u_logical * 0.5; vec2 q = (s - c - u_shake) / u_cam.z; float cs = cos(-u_cam.w), sn = sin(-u_cam.w); return vec2(cs * q.x - sn * q.y, sn * q.x + cs * q.y) + u_cam.xy; } vec2 to_ndc(vec2 p){ return vec2(p.x / u_logical.x * 2.0 - 1.0, 1.0 - p.y / u_logical.y * 2.0); } ''' def _mat(xf, skew): """xf = (tx, ty, rot, scale) or (tx, ty, (m00, m01, m10, m11)); skew = (x shear, y scale).""" if len(xf) == 3: m00, m01, m10, m11 = xf[2] else: c, s_ = math.cos(xf[2]) * xf[3], math.sin(xf[2]) * xf[3] m00, m01, m10, m11 = c, -s_, s_, c sh, sy = skew # M * [[1, sh], [0, sy]] a, b, c2, d = m00, m00 * sh + m01 * sy, m10, m10 * sh + m11 * sy return (a, c2, b, d) class Engine: def __init__(self, scale=1.0, samples=1, shutter=0.5, fps=60, libgl=None): libgl = libgl or os.environ.get('MGL_LIBGL') or 'opengl32.dll' self.ctx = moderngl.create_context(standalone=True, require=430, libgl=libgl) self.renderer = self.ctx.info['GL_RENDERER'] self.scale = scale self.W, self.H = int(LW * scale), int(LH * scale) self.samples, self.shutter, self.fps = samples, shutter, fps ctx = self.ctx self.progs = {} self.p_shape = self.program(SHAPE_VS, SHAPE_FS) self.p_tex = self.program(TEX_VS, TEX_FS) self.quad = ctx.buffer(np.array([0, 0, 1, 0, 0, 1, 1, 1], dtype='f4').tobytes()) self.inst = ctx.buffer(reserve=4096 * 40 * 4, dynamic=True) self.vao_shape = ctx.vertex_array(self.p_shape, [ (self.quad, '2f', 'a_corner'), (self.inst, '4f 4f 4f 4f 4f 4f 4f 4f 4f 4f/i', 'i_rect', 'i_xf', 'i_kind', 'i_p0', 'i_p1', 'i_p2', 'i_p3', 'i_fill', 'i_stroke', 'i_sw')]) self.vao_tex = ctx.vertex_array(self.p_tex, [(self.quad, '2f', 'a_corner')]) self.empty_vao = {} # targets self.t_scene = ctx.texture((self.W, self.H), 4, dtype='f2') self.f_scene = ctx.framebuffer([self.t_scene]) self.t_acc = ctx.texture((self.W, self.H), 4, dtype='f4') self.f_acc = ctx.framebuffer([self.t_acc]) self.t_out = ctx.texture((self.W, self.H), 4, dtype='f1') self.f_out = ctx.framebuffer([self.t_out]) self.bloom = [] w, h = self.W, self.H for i in range(7): w, h = max(w // 2, 1), max(h // 2, 1) t = ctx.texture((w, h), 4, dtype='f2') t.repeat_x = t.repeat_y = False self.bloom.append((t, ctx.framebuffer([t]))) for t in (self.t_scene, self.t_acc): t.repeat_x = t.repeat_y = False self.p_acc = self.program(VS_FULL, ACC_FS) self.p_bright = self.program(VS_FULL, BRIGHT_FS) self.p_down = self.program(VS_FULL, DOWN_FS) self.p_up = self.program(VS_FULL, UP_FS) self.p_final = self.program(VS_FULL, FINAL_FS) self.text_cache = OrderedDict() self.img_cache = {} self.rt_cache = {} self.batch = [] self.cam = (LW / 2, LH / 2, 1.0, 0.0) self.shake = (0.0, 0.0) self.alpha = 1.0 self.logical = (LW, LH) self.target_stack = [] # ---- programs def program(self, vs, fs): key = (vs, fs) if key not in self.progs: self.progs[key] = self.ctx.program(vertex_shader=preprocess(vs), fragment_shader=preprocess(fs)) return self.progs[key] def fs_program(self, src): return self.program(VS_FULL, src) def _set(self, prog, **u): for k, v in u.items(): if k in prog: prog[k].value = v def _common(self, prog): self._set(prog, u_cam=self.cam, u_shake=self.shake, u_logical=self.logical, u_alpha=self.alpha) # ---- render targets (offscreen layers in logical space) def layer(self, name, w=LW, h=LH): key = (name, w, h) if key not in self.rt_cache: t = self.ctx.texture((int(w * self.scale), int(h * self.scale)), 4, dtype='f2') t.build_mipmaps() t.filter = (moderngl.LINEAR_MIPMAP_LINEAR, moderngl.LINEAR) self.rt_cache[key] = (t, self.ctx.framebuffer([t])) return self.rt_cache[key] def push_target(self, name, w=LW, h=LH, clear=(0, 0, 0, 0)): """Draw into an offscreen layer of logical size w x h until pop_target(); returns its texture.""" self.flush() t, fb = self.layer(name, w, h) self.target_stack.append((fb, t, self.cam, self.shake, self.logical, self.alpha)) fb.use() fb.clear(*clear) self.logical = (w, h) self.cam = (w / 2, h / 2, 1.0, 0.0) self.shake = (0.0, 0.0) self.alpha = 1.0 return t def pop_target(self): self.flush() fb, t, self.cam, self.shake, self.logical, self.alpha = self.target_stack.pop() t.build_mipmaps() prev = self.target_stack[-1][0] if self.target_stack else self.f_scene prev.use() return t # ---- shapes def _shape(self, kind, rect, xf, p, fill, stroke=(0, 0, 0, 0), sw=0.0, glow=0.0, glow_k=0.0, extra=(0, 0)): pp = list(p) + [0.0] * (16 - len(p)) self.batch.append(('s', (rect, xf, (kind, extra[0], extra[1], 0), pp, fill, stroke, (sw, glow, glow_k, 0)))) def circle(self, x, y, r, fill, stroke=(0, 0, 0, 0), sw=0.0, glow=0.0, glow_k=0.0): self._shape(0, (-r, -r, r, r), (x, y, 0, 1), (r,), fill, stroke, sw, glow, glow_k) def rect(self, x, y, w, h, fill, r=0.0, rot=0.0, stroke=(0, 0, 0, 0), sw=0.0, glow=0.0, glow_k=0.0, s=1.0): """Centred rectangle (x, y = centre) with corner radius r.""" hw, hh = abs(w) / 2, abs(h) / 2 r = min(r, hw, hh) self._shape(1, (-hw, -hh, hw, hh), (x, y, rot, s), (hw, hh, r), fill, stroke, sw, glow, glow_k) def line(self, x0, y0, x1, y1, width, fill, w1=None, glow=0.0, glow_k=0.0): r0 = width / 2 r1 = (w1 if w1 is not None else width) / 2 rr = max(r0, r1) self._shape(2, (min(x0, x1) - rr, min(y0, y1) - rr, max(x0, x1) + rr, max(y0, y1) + rr), (0, 0, 0, 1), (x0, y0, x1, y1, r0, r1), fill, glow=glow, glow_k=glow_k) def poly(self, pts, fill, x=0.0, y=0.0, rot=0.0, s=1.0, stroke=(0, 0, 0, 0), sw=0.0, round_=0.0, glow=0.0, glow_k=0.0): assert 3 <= len(pts) <= 8 xs = [p[0] for p in pts] ys = [p[1] for p in pts] flat = [c for p in pts for c in p] self._shape(4, (min(xs) - round_, min(ys) - round_, max(xs) + round_, max(ys) + round_), (x, y, rot, s), flat, fill, stroke, sw, glow, glow_k, extra=(len(pts), round_)) def arc(self, x, y, r, th, a0, a1, fill, glow=0.0, glow_k=0.0): """Ring segment from angle a0 to a1 (radians, y down -> clockwise).""" if a1 - a0 >= 2 * math.pi - 1e-4: a0, a1 = -math.pi - 0.01, math.pi + 0.01 R = r + th self._shape(5, (-R, -R, R, R), (x, y, 0, 1), (r, th, a0, a1), fill, glow=glow, glow_k=glow_k) def ring(self, x, y, r, th, fill, glow=0.0, glow_k=0.0): self.arc(x, y, r, th, -math.pi, math.pi, fill, glow, glow_k) def polyline(self, pts, width, fill, glow=0.0, glow_k=0.0): for a, b in zip(pts, pts[1:]): self.line(a[0], a[1], b[0], b[1], width, fill, glow=glow, glow_k=glow_k) # ---- text def _text_tex(self, s, font, size, var, tracking): fsize = quant_size(size * self.scale) k = fsize / (size * self.scale) var = {a: round(v / 2) * 2 for a, v in var.items()} key = (s, font, round(fsize, 3), tuple(sorted(var.items())), round(tracking * self.scale * k, 2)) e = self.text_cache.get(key) if e is not None: self.text_cache.move_to_end(key) return e, fsize F = face(font) glyphs, adv = F.shape(s, fsize, var, tracking * self.scale * k) pad = int(fsize * 0.05) + 4 asc = int(math.ceil(fsize * 1.05)) desc = int(math.ceil(fsize * 0.35)) wpx = int(math.ceil(adv)) + 2 * pad + int(fsize * 0.3) hpx = asc + desc + 2 * pad canvas = np.zeros((hpx, wpx), np.uint8) ox, oy = pad + int(fsize * 0.15), pad + asc for gid, gx, gy, _ in glyphs: bm, left, top = F.raster(gid, fsize, var) if bm.size == 0: continue x0 = int(round(ox + gx)) + left y0 = int(round(oy + gy)) - top h, w = bm.shape xa, ya = max(x0, 0), max(y0, 0) xb, yb = min(x0 + w, wpx), min(y0 + h, hpx) if xb <= xa or yb <= ya: continue sub = canvas[ya:yb, xa:xb] np.maximum(sub, bm[ya - y0:yb - y0, xa - x0:xb - x0], out=sub) tex = self.ctx.texture((wpx, hpx), 1, canvas.tobytes()) tex.build_mipmaps() tex.filter = (moderngl.LINEAR_MIPMAP_LINEAR, moderngl.LINEAR) tex.repeat_x = tex.repeat_y = False e = (tex, wpx, hpx, ox, oy, adv, F.cap * fsize) self.text_cache[key] = e if len(self.text_cache) > 900: _, old = self.text_cache.popitem(last=False) old[0].release() return e, fsize def measure(self, s, font='archivo', size=100, wght=700, wdth=100, tracking=0.0): F = face(font) var = {'wght': wght, 'wdth': wdth} if font != 'mono' else {'wght': wght} return F.shape(s, size, var, tracking)[1] def text(self, s, x, y, size=100, color=(1, 1, 1, 1), font='archivo', wght=700, wdth=100, align='center', valign='cap', rot=0.0, s_=1.0, tracking=0.0, reveal=(0.0, 1.0, 0.0, 0.0), skew=0.0, sy=1.0, mat=None): """Draw a line of text. (x, y) is the anchor: align = left/center/right on the advance box; valign = 'cap' (centre of cap height), 'base' (baseline), 'top' (cap top). Returns the advance width.""" if not s: return 0.0 var = {'wght': wght, 'wdth': wdth} if font != 'mono' else {'wght': wght} (tex, wpx, hpx, ox, oy, adv, cap), fsize = self._text_tex(s, font, size, var, tracking) k = size / (fsize / self.scale) / self.scale # texture px -> logical px adv_l = adv * k ax = {'left': 0.0, 'center': adv_l / 2, 'right': adv_l}[align] ay = {'base': 0.0, 'cap': -cap * k / 2, 'top': -cap * k}[valign] x0 = -ox * k - ax y0 = -oy * k - ay xf = (x, y, mat) if mat is not None else (x, y, rot, s_) self.batch.append(('t', (tex, (x0, y0, x0 + wpx * k, y0 + hpx * k), xf, color, reveal, (skew, sy), 0))) return adv_l def image(self, tex, x, y, w, h, rot=0.0, color=(1, 1, 1, 1), s=1.0, reveal=(0.0, 1.0, 0.0, 0.0), mat=None): """Premultiplied RGBA texture (e.g. a pushed layer, which is stored bottom-up) centred at x, y.""" xf = (x, y, mat) if mat is not None else (x, y, rot, s) self.batch.append(('t', (tex, (-w / 2, h / 2, w / 2, -h / 2), xf, color, reveal, (0, 1), 1))) def glyph_runs(self, s, font='archivo', size=100, wght=700, wdth=100, tracking=0.0): """Per-character x offsets (logical px, pen start = 0) and total advance, for per-letter animation.""" F = face(font) var = {'wght': wght, 'wdth': wdth} if font != 'mono' else {'wght': wght} glyphs, adv = F.shape(s, size, var, tracking) xs = [0.0] * len(s) for gid, gx, gy, cl in glyphs: xs[cl] = gx ends = [] for i in range(len(s)): ends.append(xs[i + 1] if i + 1 < len(s) else adv) return xs, ends, adv # ---- fullscreen shader layer def fullscreen(self, prog, **uniforms): self.flush() self._common(prog) self._set(prog, u_res=(self.W, self.H), **uniforms) vao = self.empty_vao.get(id(prog)) if vao is None: vao = self.empty_vao[id(prog)] = self.ctx.vertex_array(prog, []) vao.render(moderngl.TRIANGLES, vertices=3) def bind_textures(self, prog, **tex): for i, (name, t) in enumerate(tex.items()): t.use(location=i + 1) if name in prog: prog[name].value = i + 1 # ---- batching def flush(self): if not self.batch: return ctx = self.ctx ctx.enable(moderngl.BLEND) ctx.blend_func = (moderngl.ONE, moderngl.ONE_MINUS_SRC_ALPHA) i = 0 while i < len(self.batch): kind = self.batch[i][0] j = i while j < len(self.batch) and self.batch[j][0] == kind and (kind == 's' or j == i): j += 1 if kind == 's': rows = [] for _, (rect, xf, kd, pp, fill, stroke, sw) in self.batch[i:j]: rows.append(list(rect) + list(xf) + list(kd) + pp + list(fill) + list(stroke) + list(sw)) data = np.array(rows, dtype='f4') if data.nbytes > self.inst.size: self.inst.orphan(data.nbytes * 2) self.inst.write(data.tobytes()) self._common(self.p_shape) self.vao_shape.render(moderngl.TRIANGLE_STRIP, vertices=4, instances=len(rows)) else: tex, rect, xf, color, reveal, skew, mode = self.batch[i][1] p = self.p_tex self._common(p) tex.use(location=0) p['u_tex'].value = 0 p['u_rect'].value = rect p['u_t'].value = xf[:2] p['u_m'].value = _mat(xf, skew) p['u_color'].value = color p['u_reveal'].value = reveal p['u_mode'].value = mode self.vao_tex.render(moderngl.TRIANGLE_STRIP, vertices=4) i = j self.batch = [] # ---- frame def begin_sub(self, clear): self.f_scene.use() self.f_scene.clear(*clear) self.logical = (LW, LH) self.cam = (LW / 2, LH / 2, 1.0, 0.0) self.shake = (0.0, 0.0) self.alpha = 1.0 def accumulate(self, weight, first): self.flush() ctx = self.ctx self.f_acc.use() if first: self.f_acc.clear(0, 0, 0, 0) ctx.enable(moderngl.BLEND) ctx.blend_func = (moderngl.ONE, moderngl.ONE) self.t_scene.use(location=0) self.p_acc['u_src'].value = 0 self.p_acc['u_w'].value = weight self._fs(self.p_acc) def _fs(self, prog): vao = self.empty_vao.get(id(prog)) if vao is None: vao = self.empty_vao[id(prog)] = self.ctx.vertex_array(prog, []) vao.render(moderngl.TRIANGLES, vertices=3) def post(self, P, frame_idx): ctx = self.ctx ctx.disable(moderngl.BLEND) # bloom t0, f0 = self.bloom[0] f0.use() self.t_acc.use(location=0) self.p_bright['u_src'].value = 0 self.p_bright['u_thresh'].value = P.get('bloom_thresh', 0.9) self.p_bright['u_texel'].value = (1 / self.W, 1 / self.H) self._fs(self.p_bright) for i in range(1, len(self.bloom)): src, _ = self.bloom[i - 1] dst, fb = self.bloom[i] fb.use() src.use(location=0) self.p_down['u_src'].value = 0 self.p_down['u_texel'].value = (1 / src.width, 1 / src.height) self._fs(self.p_down) ctx.enable(moderngl.BLEND) ctx.blend_func = (moderngl.ONE, moderngl.ONE) for i in range(len(self.bloom) - 1, 0, -1): src, _ = self.bloom[i] dst, fb = self.bloom[i - 1] fb.use() src.use(location=0) self.p_up['u_src'].value = 0 self.p_up['u_texel'].value = (1 / src.width, 1 / src.height) self._fs(self.p_up) ctx.disable(moderngl.BLEND) self.f_out.use() p = self.p_final self.t_acc.use(location=0) self.bloom[0][0].use(location=1) p['u_src'].value = 0 p['u_bloom'].value = 1 self._set(p, u_res=(self.W, self.H), u_scale=self.scale, u_bloom_k=P.get('bloom', 0.6), u_ca=P.get('ca', 0.0), u_vig=P.get('vignette', 0.35), u_grain=P.get('grain', 0.045) * float(os.environ.get('GRAIN_K', '0.6')), u_flash=P.get('flash', (0, 0, 0, 0)), u_invert=P.get('invert', 0.0), u_frame=float(frame_idx), u_exposure=P.get('exposure', 1.0), u_fade=P.get('fade', 0.0), u_scan=P.get('scan', 0.0), u_grain_rate=float(os.environ.get('GRAIN_RATE', '0'))) self._fs(p) def read(self): return self.t_out.read() ACC_FS = '''#version 430 in vec2 v_uv; uniform sampler2D u_src; uniform float u_w; out vec4 o; void main(){ o = texture(u_src, v_uv) * u_w; } ''' BRIGHT_FS = '''#version 430 in vec2 v_uv; uniform sampler2D u_src; uniform float u_thresh; uniform vec2 u_texel; out vec4 o; void main(){ vec3 c = vec3(0); for (int i = 0; i < 4; i++){ vec2 d = vec2((i & 1) == 0 ? -0.5 : 0.5, (i & 2) == 0 ? -0.5 : 0.5) * u_texel; c += texture(u_src, v_uv + d).rgb; } c *= 0.25; float l = max(c.r, max(c.g, c.b)); float knee = 0.25; float s = clamp(l - u_thresh + knee, 0.0, 2.0 * knee); s = s * s / (4.0 * knee + 1e-4); float w = max(s, l - u_thresh) / max(l, 1e-4); o = vec4(min(c * w, vec3(40.0)), 1); } ''' DOWN_FS = '''#version 430 in vec2 v_uv; uniform sampler2D u_src; uniform vec2 u_texel; out vec4 o; void main(){ vec2 t = u_texel; vec3 a = texture(u_src, v_uv + t * vec2(-2, -2)).rgb, b = texture(u_src, v_uv + t * vec2(0, -2)).rgb, c = texture(u_src, v_uv + t * vec2(2, -2)).rgb, d = texture(u_src, v_uv + t * vec2(-2, 0)).rgb, e = texture(u_src, v_uv).rgb, f = texture(u_src, v_uv + t * vec2(2, 0)).rgb, g = texture(u_src, v_uv + t * vec2(-2, 2)).rgb, h = texture(u_src, v_uv + t * vec2(0, 2)).rgb, i = texture(u_src, v_uv + t * vec2(2, 2)).rgb, j = texture(u_src, v_uv + t * vec2(-1, -1)).rgb, k = texture(u_src, v_uv + t * vec2(1, -1)).rgb, l = texture(u_src, v_uv + t * vec2(-1, 1)).rgb, m = texture(u_src, v_uv + t * vec2(1, 1)).rgb; vec3 r = e * 0.125 + (a + c + g + i) * 0.03125 + (b + d + f + h) * 0.0625 + (j + k + l + m) * 0.125; o = vec4(r, 1); } ''' UP_FS = '''#version 430 in vec2 v_uv; uniform sampler2D u_src; uniform vec2 u_texel; out vec4 o; void main(){ vec2 t = u_texel; vec3 r = texture(u_src, v_uv).rgb * 4.0; r += (texture(u_src, v_uv + vec2(-t.x, 0)).rgb + texture(u_src, v_uv + vec2(t.x, 0)).rgb + texture(u_src, v_uv + vec2(0, -t.y)).rgb + texture(u_src, v_uv + vec2(0, t.y)).rgb) * 2.0; r += texture(u_src, v_uv + vec2(-t.x, -t.y)).rgb + texture(u_src, v_uv + vec2(t.x, -t.y)).rgb + texture(u_src, v_uv + vec2(-t.x, t.y)).rgb + texture(u_src, v_uv + vec2(t.x, t.y)).rgb; o = vec4(r / 16.0 * 0.85, 1); } ''' FINAL_FS = '''#version 430 in vec2 v_uv; uniform sampler2D u_src; uniform sampler2D u_bloom; uniform vec2 u_res; uniform float u_scale; uniform float u_grain_rate, u_bloom_k, u_ca, u_vig, u_grain, u_invert, u_frame, u_exposure, u_fade, u_scan; uniform vec4 u_flash; out vec4 o; uint pcg(uint v){ uint s = v * 747796405u + 2891336453u; uint w = ((s >> ((s >> 28u) + 4u)) ^ s) * 277803737u; return (w >> 22u) ^ w; } float hash(vec3 p){ // integer hash: bit-identical on every GL implementation uvec3 q = uvec3(ivec3(p) + 65536); return float(pcg(q.x + pcg(q.y + pcg(q.z))) & 0xffffffu) / 16777216.0; } vec3 tonemap(vec3 c){ // linear below 0.8, smooth shoulder to 1 vec3 k = max(c - 0.8, 0.0); return min(c, 0.8) + 0.2 * (1.0 - exp(-k / 0.2)); } void main(){ vec2 uv = vec2(v_uv.x, 1.0 - v_uv.y); // flip so the readback is top row first vec2 d = uv - 0.5; vec3 c; if (u_ca > 0.0) { vec2 off = d * u_ca * 0.012; c = vec3(texture(u_src, uv + off).r, texture(u_src, uv).g, texture(u_src, uv - off).b); } else c = texture(u_src, uv).rgb; c += texture(u_bloom, uv).rgb * u_bloom_k; c *= u_exposure; c = tonemap(c); c = mix(c, vec3(1.0) - c, u_invert); c = mix(c, u_flash.rgb, u_flash.a); float v = 1.0 - u_vig * pow(length(d * vec2(1.0, 0.75)) * 1.25, 2.4); c *= clamp(v, 0.0, 1.0); if (u_scan > 0.0) c *= 1.0 - u_scan * (0.5 + 0.5 * sin(uv.y * u_res.y * 3.14159 / max(u_scale * 2.0, 1.0))); c *= 1.0 - u_fade; vec2 px = floor(vec2(gl_FragCoord.x, u_res.y - gl_FragCoord.y) / max(u_scale, 0.5)); float gf = u_grain_rate > 0.0 ? floor(u_frame / u_grain_rate) : 0.0; // 0 = a fixed print texture float g = hash(vec3(px, mod(gf, 97.0))) + hash(vec3(px + 17.0, mod(gf, 89.0))) - 1.0; float lum = dot(c, vec3(0.2126, 0.7152, 0.0722)); c += g * u_grain * (0.35 + 0.65 * (1.0 - lum)) * (0.6 + 0.4 * lum); c = clamp(c, 0.0, 1.0); vec3 s = mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c)); s += (hash(vec3(px, mod(gf, 61.0) + 3.0)) - 0.5) / 255.0; o = vec4(s, 1); } '''