From d9148acb0c5264c454973b87513e849fc1bba000 Mon Sep 17 00:00:00 2001 From: Ed_ Date: Fri, 13 Mar 2026 20:22:16 -0400 Subject: [PATCH] feat(shader): Add BlurPipeline class for frosted glass FBO setup - Add BlurPipeline class with downsampled FBO support (scene, blur_a, blur_b) - Implement 2-pass Gaussian blur shaders (horizontal + vertical) - Add setup_fbos(), compile_blur_shaders(), prepare_blur(), cleanup() methods - Add tests for BlurPipeline initialization, FBO setup, shader compilation, blur execution, and cleanup Task: Phase 1, Task 1 of frosted_glass_20260313 track --- .opencode/agents/tier2-tech-lead.md | 9 +- .../tracks/frosted_glass_20260313/plan.md | 2 +- src/shader_manager.py | 178 ++++++++++++++++++ tests/test_shader_manager.py | 77 ++++++++ 4 files changed, 260 insertions(+), 6 deletions(-) diff --git a/.opencode/agents/tier2-tech-lead.md b/.opencode/agents/tier2-tech-lead.md index 6edcd6b..1164a96 100644 --- a/.opencode/agents/tier2-tech-lead.md +++ b/.opencode/agents/tier2-tech-lead.md @@ -1,7 +1,6 @@ --- description: Tier 2 Tech Lead for architectural design and track execution with persistent memory mode: primary -model: MiniMax-M2.5 temperature: 0.4 permission: edit: ask @@ -14,9 +13,9 @@ ONLY output the requested text. No pleasantries. ## Context Management -**MANUAL COMPACTION ONLY** — Never rely on automatic context summarization. +**MANUAL COMPACTION ONLY** � Never rely on automatic context summarization. Use `/compact` command explicitly when context needs reduction. -You maintain PERSISTENT MEMORY throughout track execution — do NOT apply Context Amnesia to your own session. +You maintain PERSISTENT MEMORY throughout track execution � do NOT apply Context Amnesia to your own session. ## CRITICAL: MCP Tools Only (Native Tools Banned) @@ -134,14 +133,14 @@ Before implementing: - Zero-assertion ban: Tests MUST have meaningful assertions - Delegate test creation to Tier 3 Worker via Task tool - Run tests and confirm they FAIL as expected -- **CONFIRM FAILURE** — this is the Red phase +- **CONFIRM FAILURE** � this is the Red phase ### 3. Green Phase: Implement to Pass - **Pre-delegation checkpoint**: Stage current progress (`git add .`) - Delegate implementation to Tier 3 Worker via Task tool - Run tests and confirm they PASS -- **CONFIRM PASS** — this is the Green phase +- **CONFIRM PASS** � this is the Green phase ### 4. Refactor Phase (Optional) diff --git a/conductor/tracks/frosted_glass_20260313/plan.md b/conductor/tracks/frosted_glass_20260313/plan.md index b93b0dc..3ed7652 100644 --- a/conductor/tracks/frosted_glass_20260313/plan.md +++ b/conductor/tracks/frosted_glass_20260313/plan.md @@ -1,7 +1,7 @@ # Implementation Plan: Frosted Glass Background Effect (REPAIR - TRUE GPU) ## Phase 1: Robust Shader & FBO Foundation -- [ ] Task: Implement: Create `ShaderManager` methods for downsampled FBO setup (scene, temp, blur). +- [~] Task: Implement: Create `ShaderManager` methods for downsampled FBO setup (scene, temp, blur). - [ ] Task: Implement: Develop the "Deep Sea" background shader and integrate it as the FBO source. - [ ] Task: Implement: Develop the 2-pass Gaussian blur shaders with a wide tap distribution. - [ ] Task: Conductor - User Manual Verification 'Phase 1: Robust Foundation' (Protocol in workflow.md) diff --git a/src/shader_manager.py b/src/shader_manager.py index da535d9..89509d4 100644 --- a/src/shader_manager.py +++ b/src/shader_manager.py @@ -150,4 +150,182 @@ void main() { gl.glUniform1f(u_time_loc, float(time)) gl.glDrawArrays(gl.GL_TRIANGLE_STRIP, 0, 4) gl.glBindTexture(gl.GL_TEXTURE_2D, 0) + +class BlurPipeline: + def __init__(self): + self.scene_fbo: int | None = None + self.scene_tex: int | None = None + self.blur_fbo_a: int | None = None + self.blur_tex_a: int | None = None + self.blur_fbo_b: int | None = None + self.blur_tex_b: int | None = None + self.h_blur_program: int | None = None + self.v_blur_program: int | None = None + self._fb_width: int = 0 + self._fb_height: int = 0 + + def _compile_shader(self, vertex_src: str, fragment_src: str) -> int: + program = gl.glCreateProgram() + def _compile(src, shader_type): + shader = gl.glCreateShader(shader_type) + gl.glShaderSource(shader, src) + gl.glCompileShader(shader) + if not gl.glGetShaderiv(shader, gl.GL_COMPILE_STATUS): + info_log = gl.glGetShaderInfoLog(shader) + if hasattr(info_log, "decode"): + info_log = info_log.decode() + raise RuntimeError(f"Shader compilation failed: {info_log}") + return shader + vert_shader = _compile(vertex_src, gl.GL_VERTEX_SHADER) + frag_shader = _compile(fragment_src, gl.GL_FRAGMENT_SHADER) + gl.glAttachShader(program, vert_shader) + gl.glAttachShader(program, frag_shader) + gl.glLinkProgram(program) + if not gl.glGetProgramiv(program, gl.GL_LINK_STATUS): + info_log = gl.glGetProgramInfoLog(program) + if hasattr(info_log, "decode"): + info_log = info_log.decode() + raise RuntimeError(f"Program linking failed: {info_log}") + gl.glDeleteShader(vert_shader) + gl.glDeleteShader(frag_shader) + return program + + def _create_fbo(self, width: int, height: int) -> tuple[int, int]: + tex = gl.glGenTextures(1) + gl.glBindTexture(gl.GL_TEXTURE_2D, tex) + gl.glTexImage2D(gl.GL_TEXTURE_2D, 0, gl.GL_RGBA8, width, height, 0, gl.GL_RGBA, gl.GL_UNSIGNED_BYTE, None) + 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) + gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_WRAP_S, gl.GL_CLAMP_TO_EDGE) + gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_WRAP_T, gl.GL_CLAMP_TO_EDGE) + fbo = gl.glGenFramebuffers(1) + gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, fbo) + gl.glFramebufferTexture2D(gl.GL_FRAMEBUFFER, gl.GL_COLOR_ATTACHMENT0, gl.GL_TEXTURE_2D, tex, 0) + gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, 0) + gl.glBindTexture(gl.GL_TEXTURE_2D, 0) + return fbo, tex + + def setup_fbos(self, width: int, height: int): + blur_w = max(1, width // 4) + blur_h = max(1, height // 4) + self._fb_width = blur_w + self._fb_height = blur_h + self.scene_fbo, self.scene_tex = self._create_fbo(width, height) + self.blur_fbo_a, self.blur_tex_a = self._create_fbo(blur_w, blur_h) + self.blur_fbo_b, self.blur_tex_b = self._create_fbo(blur_w, blur_h) + + def compile_blur_shaders(self): + vert_src = """ +#version 330 core +out vec2 v_uv; +void main() { + vec2 pos = vec2(-1.0, -1.0); + vec2 uv = vec2(0.0, 0.0); + if (gl_VertexID == 1) { pos = vec2(1.0, -1.0); uv = vec2(1.0, 0.0); } + else if (gl_VertexID == 2) { pos = vec2(-1.0, 1.0); uv = vec2(0.0, 1.0); } + else if (gl_VertexID == 3) { pos = vec2(1.0, 1.0); uv = vec2(1.0, 1.0); } + gl_Position = vec4(pos, 0.0, 1.0); + v_uv = uv; +} +""" + h_frag_src = """ +#version 330 core +in vec2 v_uv; +uniform sampler2D u_texture; +uniform vec2 u_texel_size; +out vec4 FragColor; +void main() { + vec2 offset = vec2(u_texel_size.x, 0.0); + vec4 sum = vec4(0.0); + sum += texture(u_texture, v_uv - offset * 4.0) * 0.051; + sum += texture(u_texture, v_uv - offset * 3.0) * 0.0918; + sum += texture(u_texture, v_uv - offset * 2.0) * 0.12245; + sum += texture(u_texture, v_uv - offset * 1.0) * 0.1531; + sum += texture(u_texture, v_uv) * 0.1633; + sum += texture(u_texture, v_uv + offset * 1.0) * 0.1531; + sum += texture(u_texture, v_uv + offset * 2.0) * 0.12245; + sum += texture(u_texture, v_uv + offset * 3.0) * 0.0918; + sum += texture(u_texture, v_uv + offset * 4.0) * 0.051; + FragColor = sum; +} +""" + v_frag_src = """ +#version 330 core +in vec2 v_uv; +uniform sampler2D u_texture; +uniform vec2 u_texel_size; +out vec4 FragColor; +void main() { + vec2 offset = vec2(0.0, u_texel_size.y); + vec4 sum = vec4(0.0); + sum += texture(u_texture, v_uv - offset * 4.0) * 0.051; + sum += texture(u_texture, v_uv - offset * 3.0) * 0.0918; + sum += texture(u_texture, v_uv - offset * 2.0) * 0.12245; + sum += texture(u_texture, v_uv - offset * 1.0) * 0.1531; + sum += texture(u_texture, v_uv) * 0.1633; + sum += texture(u_texture, v_uv + offset * 1.0) * 0.1531; + sum += texture(u_texture, v_uv + offset * 2.0) * 0.12245; + sum += texture(u_texture, v_uv + offset * 3.0) * 0.0918; + sum += texture(u_texture, v_uv + offset * 4.0) * 0.051; + FragColor = sum; +} +""" + self.h_blur_program = self._compile_shader(vert_src, h_frag_src) + self.v_blur_program = self._compile_shader(vert_src, v_frag_src) + + def _render_quad(self, program: int, src_tex: int, texel_size: tuple[float, float]): + gl.glUseProgram(program) + gl.glActiveTexture(gl.GL_TEXTURE0) + gl.glBindTexture(gl.GL_TEXTURE_2D, src_tex) + u_tex = gl.glGetUniformLocation(program, "u_texture") + if u_tex != -1: + gl.glUniform1i(u_tex, 0) + u_ts = gl.glGetUniformLocation(program, "u_texel_size") + if u_ts != -1: + gl.glUniform2f(u_ts, texel_size[0], texel_size[1]) + gl.glDrawArrays(gl.GL_TRIANGLE_STRIP, 0, 4) + gl.glBindTexture(gl.GL_TEXTURE_2D, 0) gl.glUseProgram(0) + + def prepare_blur(self, width: int, height: int, time: float): + if not self.h_blur_program or not self.v_blur_program: + return + if not self.blur_fbo_a or not self.blur_fbo_b: + return + blur_w = max(1, self._fb_width) + blur_h = max(1, self._fb_height) + texel_x = 1.0 / float(blur_w) + texel_y = 1.0 / float(blur_h) + gl.glViewport(0, 0, blur_w, blur_h) + gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, self.blur_fbo_a) + gl.glClearColor(0.0, 0.0, 0.0, 0.0) + gl.glClear(gl.GL_COLOR_BUFFER_BIT) + self._render_quad(self.h_blur_program, self.scene_tex, (texel_x, texel_y)) + gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, self.blur_fbo_b) + gl.glClear(gl.GL_COLOR_BUFFER_BIT) + self._render_quad(self.v_blur_program, self.blur_tex_a, (texel_x, texel_y)) + gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, 0) + gl.glViewport(0, 0, width, height) + + def get_blur_texture(self) -> int | None: + return self.blur_tex_b + + def cleanup(self): + fbos = [f for f in [self.scene_fbo, self.blur_fbo_a, self.blur_fbo_b] if f is not None] + texs = [t for t in [self.scene_tex, self.blur_tex_a, self.blur_tex_b] if t is not None] + progs = [p for p in [self.h_blur_program, self.v_blur_program] if p is not None] + if fbos: + gl.glDeleteFramebuffers(len(fbos), fbos) + if texs: + gl.glDeleteTextures(len(texs), texs) + if progs: + for p in progs: + gl.glDeleteProgram(p) + self.scene_fbo = None + self.scene_tex = None + self.blur_fbo_a = None + self.blur_tex_a = None + self.blur_fbo_b = None + self.blur_tex_b = None + self.h_blur_program = None + self.v_blur_program = None diff --git a/tests/test_shader_manager.py b/tests/test_shader_manager.py index 124d15c..2b729bf 100644 --- a/tests/test_shader_manager.py +++ b/tests/test_shader_manager.py @@ -1,6 +1,83 @@ import pytest from unittest.mock import patch, MagicMock +def test_blur_pipeline_import(): + with patch("src.shader_manager.gl") as mock_gl: + from src.shader_manager import BlurPipeline + pipeline = BlurPipeline() + assert pipeline is not None + assert pipeline.scene_fbo is None + assert pipeline.blur_fbo_a is None + assert pipeline.blur_fbo_b is None + assert pipeline.scene_tex is None + assert pipeline.blur_tex_a is None + assert pipeline.blur_tex_b is None + assert pipeline.h_blur_program is None + assert pipeline.v_blur_program is None + +def test_blur_pipeline_setup_fbos(): + with patch("src.shader_manager.gl") as mock_gl: + tex_counter = iter([10, 20, 30]) + fbo_counter = iter([1, 2, 3]) + mock_gl.glGenTextures.side_effect = lambda n: next(tex_counter) + mock_gl.glGenFramebuffers.side_effect = lambda n: next(fbo_counter) + from src.shader_manager import BlurPipeline + pipeline = BlurPipeline() + pipeline.setup_fbos(800, 600) + assert mock_gl.glGenFramebuffers.called + assert mock_gl.glGenTextures.called + assert pipeline.scene_fbo is not None + assert pipeline.blur_fbo_a is not None + assert pipeline.blur_fbo_b is not None + +def test_blur_pipeline_compile_shaders(): + with patch("src.shader_manager.gl") as mock_gl: + mock_gl.glCreateProgram.return_value = 100 + mock_gl.glCreateShader.return_value = 200 + mock_gl.glGetShaderiv.return_value = mock_gl.GL_TRUE + mock_gl.glGetProgramiv.return_value = mock_gl.GL_TRUE + from src.shader_manager import BlurPipeline + pipeline = BlurPipeline() + pipeline.compile_blur_shaders() + assert mock_gl.glCreateProgram.called + assert pipeline.h_blur_program is not None + assert pipeline.v_blur_program is not None + +def test_blur_pipeline_prepare_blur(): + with patch("src.shader_manager.gl") as mock_gl: + mock_gl.glGenFramebuffers.return_value = None + mock_gl.glGenTextures.return_value = None + from src.shader_manager import BlurPipeline + pipeline = BlurPipeline() + pipeline.scene_fbo = 1 + pipeline.scene_tex = 10 + pipeline.blur_fbo_a = 2 + pipeline.blur_tex_a = 20 + pipeline.blur_fbo_b = 3 + pipeline.blur_tex_b = 30 + pipeline.h_blur_program = 100 + pipeline.v_blur_program = 101 + pipeline.prepare_blur(800, 600, 0.0) + assert mock_gl.glBindFramebuffer.called + assert mock_gl.glUseProgram.called + +def test_blur_pipeline_cleanup(): + with patch("src.shader_manager.gl") as mock_gl: + from src.shader_manager import BlurPipeline + pipeline = BlurPipeline() + pipeline.scene_fbo = 1 + pipeline.blur_fbo_a = 2 + pipeline.blur_fbo_b = 3 + pipeline.scene_tex = 10 + pipeline.blur_tex_a = 20 + pipeline.blur_tex_b = 30 + pipeline.h_blur_program = 100 + pipeline.v_blur_program = 101 + pipeline.cleanup() + assert mock_gl.glDeleteFramebuffers.called + assert mock_gl.glDeleteTextures.called + assert mock_gl.glDeleteProgram.called + def test_shader_manager_initialization_and_compilation(): # Import inside test to allow patching OpenGL before import if needed # In this case, we patch the OpenGL.GL functions used by ShaderManager