diff --git a/src/dispatch.c b/src/dispatch.c
index a593f256..7a2f9577 100644
--- a/src/dispatch.c
+++ b/src/dispatch.c
@@ -398,17 +398,27 @@ static void generate_shaders(pl_dispatch dp,
         ADD(pre, "precision highp int; \n");
     }
 
-    // textureLod() doesn't work on external/rect samplers, simply disable
-    // LOD sampling in this case. We don't currently support mipmaps anyway.
-    for (int i = 0; i < sh->descs.num; i++) {
-        if (pass_params->descriptors[i].type != PL_DESC_SAMPLED_TEX)
-            continue;
-        pl_tex tex = sh->descs.elem[i].binding.object;
-        if (tex->sampler_type != PL_SAMPLER_NORMAL) {
-            ADD(pre, "#define textureLod(t, p, b) texture(t, p) \n"
-                     "#define textureLodOffset(t, p, b, o)    \\\n"
-                     "        textureOffset(t, p, o)            \n");
-            break;
+    // GLSL < 130 compatibility: Use legacy texture functions
+    if (gpu->glsl.version < 130) {
+        ADD(pre, "// GLSL < 130 texture compatibility layer\n");
+        // Map modern texture() calls to legacy texture2D()
+        ADD(pre, "#define texture(t, p) texture2D(t, p)\n");
+        // For LOD sampling, use texture2DLod if available, otherwise just texture2D
+        ADD(pre, "#define textureLod(t, p, b) texture2D(t, p)\n");
+        ADD(pre, "#define textureLodOffset(t, p, b, o) texture2D(t, p)\n");
+    } else {
+        // textureLod() doesn't work on external/rect samplers, simply disable
+        // LOD sampling in this case. We don't currently support mipmaps anyway.
+        for (int i = 0; i < sh->descs.num; i++) {
+            if (pass_params->descriptors[i].type != PL_DESC_SAMPLED_TEX)
+                continue;
+            pl_tex tex = sh->descs.elem[i].binding.object;
+            if (tex->sampler_type != PL_SAMPLER_NORMAL) {
+                ADD(pre, "#define textureLod(t, p, b) texture(t, p) \n"
+                         "#define textureLodOffset(t, p, b, o)    \\\n"
+                         "        textureOffset(t, p, o)            \n");
+                break;
+            }
         }
     }
 
@@ -620,6 +630,9 @@ static void generate_shaders(pl_dispatch dp,
             if (has_loc) {
                 ADD(vert_head, "layout(location=%d) in %s "$";\n",
                     va->location, type, sh_ident_unpack(va->name));
+            } else if (gpu->glsl.version < 130) {
+                // GLSL < 130: use attribute instead of in for vertex inputs
+                ADD(vert_head, "attribute %s "$";\n", type, sh_ident_unpack(va->name));
             } else {
                 ADD(vert_head, "in %s "$";\n", type, sh_ident_unpack(va->name));
             }
@@ -639,6 +652,10 @@ static void generate_shaders(pl_dispatch dp,
                         va->location, type, id);
                     ADD(glsl, "layout(location=%d) in %s "$";\n",
                         va->location, type, id);
+                } else if (gpu->glsl.version < 130) {
+                    // GLSL < 130: use varying instead of in/out
+                    ADD(vert_head, "varying %s "$";\n", type, id);
+                    ADD(glsl, "varying %s "$";\n", type, id);
                 } else {
                     ADD(vert_head, "out %s "$";\n", type, id);
                     ADD(glsl, "in %s "$";\n", type, id);
@@ -652,7 +669,10 @@ static void generate_shaders(pl_dispatch dp,
         pl_hash_merge(&pass->signature, pl_str_builder_hash(vert_head));
         *out_vert_builder = vert_head;
 
-        if (has_loc) {
+        // GLSL < 130: use gl_FragColor instead of out_color
+        if (gpu->glsl.version < 130) {
+            // No output declaration needed, gl_FragColor is built-in
+        } else if (has_loc) {
             ADD(glsl, "layout(location=0) out vec4 out_color;\n");
         } else {
             ADD(glsl, "out vec4 out_color;\n");
@@ -676,7 +696,12 @@ static void generate_shaders(pl_dispatch dp,
     switch (pass_params->type) {
     case PL_PASS_RASTER:
         pl_assert(sh->output == PL_SHADER_SIG_COLOR);
-        ADD(glsl, "out_color = "$"();\n", sh->name);
+        // GLSL < 130: use gl_FragColor instead of out_color
+        if (gpu->glsl.version < 130) {
+            ADD(glsl, "gl_FragColor = "$"();\n", sh->name);
+        } else {
+            ADD(glsl, "out_color = "$"();\n", sh->name);
+        }
         break;
     case PL_PASS_COMPUTE:
         ADD(glsl, $"();\n", sh->name);
diff --git a/src/meson.build b/src/meson.build
index e93e800f..0ce5d847 100644
--- a/src/meson.build
+++ b/src/meson.build
@@ -206,6 +206,14 @@ endif
 defs = ''
 pc_vars = []
 
+# Detect endianness (important for PowerPC big-endian support)
+if host_machine.endian() == 'big'
+  defs += '#define PL_HAVE_BIG_ENDIAN 1\n'
+  message('Detected big-endian system (e.g., PowerPC)')
+else
+  defs += '#undef PL_HAVE_BIG_ENDIAN\n'
+endif
+
 foreach comp : components.keys()
   found = components.get(comp)
   varname = comp.underscorify().to_upper()
diff --git a/src/opengl/context.c b/src/opengl/context.c
index 8b38e652..1f126ff6 100644
--- a/src/opengl/context.c
+++ b/src/opengl/context.c
@@ -179,6 +179,39 @@ pl_opengl pl_opengl_create(pl_log log, const struct pl_opengl_params *params)
         goto error;
     }
 
+    // Alias GL_EXT_framebuffer_object functions to non-EXT names if ARB/core
+    // versions are not available. This is needed for GL 2.0 drivers that only
+    // provide the older EXT extension (e.g., NVIDIA on Mac OS X 10.6).
+#define ALIAS_FBO_EXT(name) \
+    if (!gl->name && gl->name##EXT) { \
+        gl->name = gl->name##EXT; \
+        PL_DEBUG(p, "Aliased " #name "EXT -> " #name); \
+    }
+    ALIAS_FBO_EXT(GenFramebuffers);
+    ALIAS_FBO_EXT(DeleteFramebuffers);
+    ALIAS_FBO_EXT(BindFramebuffer);
+    ALIAS_FBO_EXT(CheckFramebufferStatus);
+    ALIAS_FBO_EXT(FramebufferTexture1D);
+    ALIAS_FBO_EXT(FramebufferTexture2D);
+    ALIAS_FBO_EXT(FramebufferTexture3D);
+    ALIAS_FBO_EXT(FramebufferRenderbuffer);
+    ALIAS_FBO_EXT(GetFramebufferAttachmentParameteriv);
+    ALIAS_FBO_EXT(GenRenderbuffers);
+    ALIAS_FBO_EXT(DeleteRenderbuffers);
+    ALIAS_FBO_EXT(BindRenderbuffer);
+    ALIAS_FBO_EXT(RenderbufferStorage);
+    ALIAS_FBO_EXT(GetRenderbufferParameteriv);
+    ALIAS_FBO_EXT(IsFramebuffer);
+    ALIAS_FBO_EXT(IsRenderbuffer);
+#undef ALIAS_FBO_EXT
+
+    // Note: BlitFramebuffer is from GL_EXT_framebuffer_blit, not GL_EXT_framebuffer_object
+    // It may not be available on all GL 2.0 drivers
+    if (!gl->BlitFramebuffer && gl->BlitFramebufferEXT) {
+        gl->BlitFramebuffer = gl->BlitFramebufferEXT;
+        PL_DEBUG(p, "Aliased BlitFramebufferEXT -> BlitFramebuffer");
+    }
+
     const char *version = (const char *) gl->GetString(GL_VERSION);
     if (version) {
         const char *ver = version;
@@ -202,7 +235,7 @@ pl_opengl pl_opengl_create(pl_log log, const struct pl_opengl_params *params)
     PL_INFO(p, "    GL_RENDERER: %s", (char *) gl->GetString(GL_RENDERER));
 
     ext_arr_t exts = {0};
-    if (pl_gl->major >= 3) {
+    if (pl_gl->major >= 3 && gl->GetStringi) {
         gl->GetIntegerv(GL_NUM_EXTENSIONS, &exts.num);
         PL_ARRAY_RESIZE(pl_gl, exts, exts.num);
         for (int i = 0; i < exts.num; i++)
@@ -217,7 +250,7 @@ pl_opengl pl_opengl_create(pl_log log, const struct pl_opengl_params *params)
             PL_DEBUG(p, "        %s", exts.elem[i]);
     }
 
-    static const int gl_ver_req = 3;
+    static const int gl_ver_req = 2;
     if (pl_gl->major < gl_ver_req) {
         PL_FATAL(p, "OpenGL version too old (%d < %d), please use a newer "
                  "OpenGL implementation or downgrade libplacebo!",
diff --git a/src/opengl/formats.c b/src/opengl/formats.c
index 94154448..7419b810 100644
--- a/src/opengl/formats.c
+++ b/src/opengl/formats.c
@@ -20,6 +20,14 @@
 #include "formats.h"
 #include "utils.h"
 
+// GL 2.0 legacy texture format constants (may not be in GLAD with gl:compatibility=2.0)
+#ifndef GL_LUMINANCE
+#define GL_LUMINANCE 0x1909
+#endif
+#ifndef GL_LUMINANCE_ALPHA
+#define GL_LUMINANCE_ALPHA 0x190A
+#endif
+
 #ifdef PL_HAVE_UNIX
 static bool supported_fourcc(struct pl_gl *p, EGLint fourcc)
 {
@@ -77,6 +85,12 @@ const struct gl_format formats_norm8[] = {
     {GL_RGBA8,          RGBA,  U8,  FMT("rgba8",    8, UNORM, S|L|F|V)},
 };
 
+// GL 2.0: Only r8/rg8 from ARB_texture_rg (rgb8/rgba8 already in legacy formats)
+const struct gl_format formats_norm8_rg[] = {
+    {GL_R8,             R,     U8,  FMT("r8",       8, UNORM, S|L|F|V)},
+    {GL_RG8,            RG,    U8,  FMT("rg8",      8, UNORM, S|L|F|V)},
+};
+
 // Signed variants
 /* TODO: these are broken in mesa
 const struct gl_format formats_snorm8[] = {
@@ -133,6 +147,15 @@ const struct gl_format formats_float32[] = {
     {GL_RGBA32F,        RGBA,  FLT, FMT("rgba32f", 32, FLOAT, S|L|F|V)},
 };
 
+// 32-bit float formats for GL 2.0 without GL_ARB_texture_rg (only RGB/RGBA)
+// Note: NOT marked as renderable (F flag) because GL_ARB_texture_float does NOT
+// guarantee FBO support. GL_EXT_color_buffer_float/GL_ARB_color_buffer_float
+// would be needed for that, which old drivers typically don't have.
+const struct gl_format formats_float32_gl2[] = {
+    {GL_RGB32F,         RGB,   FLT, FMT("rgb32f",  32, FLOAT, S|L|V)},
+    {GL_RGBA32F,        RGBA,  FLT, FMT("rgba32f", 32, FLOAT, S|L|V)},
+};
+
 // 16-bit floating point texture formats
 const struct gl_format formats_float16[] = {
     {GL_R16F,           R,     FLT,  FMT("r16f",    16, FLOAT, S|L|F)},
@@ -205,12 +228,26 @@ const struct gl_format formats_uint[] = {
     {GL_RGBA32I,        RGBAI, I32, FMT("rgba32i", 32, SINT)},
 */
 
-// GL2 legacy formats
+// GL2 legacy formats (use sized internal formats for FBO compatibility)
+// Note: GL_LUMINANCE and GL_LUMINANCE_ALPHA are NOT included because they are
+// unsized formats that cannot be used as FBO attachments and are not blittable.
+// libplacebo requires formats that are renderable and blittable for proper operation.
+// Use rgba8/rgb8 for all texture operations on GL 2.0.
 const struct gl_format formats_legacy_gl2[] = {
-    {GL_RGB8,           RGB,   U8,  FMT("rgb8",     8, UNORM, S|L|V)},
-    {GL_RGBA8,          RGBA,  U8,  FMT("rgba8",    8, UNORM, S|L|V)},
-    {GL_RGB16,          RGB,   U16, FMT("rgb16",   16, UNORM, S|L|V)},
-    {GL_RGBA16,         RGBA,  U16, FMT("rgba16",  16, UNORM, S|L|V)},
+    {GL_RGB8,           RGB,               U8,                FMT("rgb8",     8, UNORM, S|L|F|V)},
+    {GL_RGBA8,          RGBA,              U8,                FMT("rgba8",    8, UNORM, S|L|F|V)},
+    {GL_RGB16,          RGB,               U16,               FMT("rgb16",   16, UNORM, S|L|F|V)},
+    {GL_RGBA16,         RGBA,              U16,               FMT("rgba16",  16, UNORM, S|L|F|V)},
+};
+
+// GL2 legacy single/dual component formats (sampleable only, NOT renderable)
+// These are needed for video plane uploads when GL_ARB_texture_rg is unavailable.
+// Note: GL_LUMINANCE swizzles to (L,L,L,1) and GL_LUMINANCE_ALPHA to (L,L,L,A)
+// in the shader, but libplacebo's component_mapping handles this correctly.
+const struct gl_format formats_luminance_gl2[] = {
+    // Note: NO 'F' flag - these cannot be FBO attachments
+    {GL_LUMINANCE,      GL_LUMINANCE,      GL_UNSIGNED_BYTE,  FMT("luminance",       8, UNORM, S|L|V)},
+    {GL_LUMINANCE_ALPHA,GL_LUMINANCE_ALPHA,GL_UNSIGNED_BYTE,  FMT("luminance_alpha", 8, UNORM, S|L|V)},
 };
 
 // GLES2 legacy formats
@@ -238,6 +275,25 @@ const struct gl_format formats_basic_vertex[] = {
     {GL_RGBA32F,        RGBA,  FLT, FMT("rgba32f", 32, FLOAT, V)},
 };
 
+// GL 2.0 vertex-only formats for 1/2 components (for systems without GL_ARB_texture_rg)
+// These use GL_LUMINANCE/GL_LUMINANCE_ALPHA as dummy internal/external formats
+// since they're only used for glVertexAttribPointer which only cares about
+// the type (GL_FLOAT) and num_components - not the texture format constants.
+// Note: Unique names (r32f_v, rg32f_v) to avoid conflicts with float texture formats.
+const struct gl_format formats_vertex_gl2_1_2comp[] = {
+    {GL_LUMINANCE,      GL_LUMINANCE,      GL_FLOAT, FMT("r32f_v",  32, FLOAT, V)},
+    {GL_LUMINANCE_ALPHA,GL_LUMINANCE_ALPHA,GL_FLOAT, FMT("rg32f_v", 32, FLOAT, V)},
+};
+
+// GL 2.0 vertex-only formats, all 4 components (for systems without GL_ARB_texture_rg
+// and without GL_ARB_texture_float)
+const struct gl_format formats_basic_vertex_gl2[] = {
+    {GL_LUMINANCE,      GL_LUMINANCE,      GL_FLOAT, FMT("r32f",    32, FLOAT, V)},
+    {GL_LUMINANCE_ALPHA,GL_LUMINANCE_ALPHA,GL_FLOAT, FMT("rg32f",   32, FLOAT, V)},
+    {GL_RGB,            GL_RGB,            GL_FLOAT, FMT("rgb32f",  32, FLOAT, V)},
+    {GL_RGBA,           GL_RGBA,           GL_FLOAT, FMT("rgba32f", 32, FLOAT, V)},
+};
+
 static void add_format(pl_gpu pgpu, const struct gl_format *gl_fmt)
 {
     struct pl_gpu_t *gpu = (struct pl_gpu_t *) pgpu;
@@ -252,10 +308,12 @@ static void add_format(pl_gpu pgpu, const struct gl_format *gl_fmt)
     switch (gl_fmt->fmt) {
     case GL_RED:
     case GL_RED_INTEGER:
+    case GL_LUMINANCE:
         fmt->num_components = 1;
         break;
     case GL_RG:
     case GL_RG_INTEGER:
+    case GL_LUMINANCE_ALPHA:
         fmt->num_components = 2;
         break;
     case GL_RGB:
@@ -372,6 +430,10 @@ static void add_format(pl_gpu pgpu, const struct gl_format *gl_fmt)
     if (fmt->caps & PL_FMT_CAP_SAMPLEABLE)
         fmt->gatherable = p->gather_comps >= fmt->num_components;
 
+    // Mask renderable/blittable if no FBOs available (GL 2.0 compatibility)
+    if (!p->has_fbos)
+        fmt->caps &= ~(PL_FMT_CAP_RENDERABLE | PL_FMT_CAP_BLITTABLE);
+
     bool host_readable = false;
     if (p->gl_ver && p->has_readback)
         host_readable = true;
@@ -444,8 +506,12 @@ bool gl_setup_formats(struct pl_gpu_t *gpu)
     }
 #endif
 
+    PL_INFO(gpu, "GL version: %d, GLES version: %d, has_fbos: %d",
+            p->gl_ver, p->gles_ver, p->has_fbos);
+
     if (p->gl_ver >= 30) {
         // Desktop GL3+ has everything
+        PL_INFO(gpu, "Using GL 3.0+ format path");
         DO_FORMATS(formats_norm8);
         DO_FORMATS(formats_bgra8);
         DO_FORMATS(formats_norm16);
@@ -460,8 +526,10 @@ bool gl_setup_formats(struct pl_gpu_t *gpu)
     if (p->gl_ver >= 21) {
         // If we have a reasonable set of extensions, we can enable most
         // things. Otherwise, pick simple fallback formats
+        bool has_texture_rg = pl_opengl_has_ext(p->gl, "GL_ARB_texture_rg");
+        PL_INFO(gpu, "Using GL 2.1 format path, has_texture_rg=%d", has_texture_rg);
         if (pl_opengl_has_ext(p->gl, "GL_ARB_texture_float") &&
-            pl_opengl_has_ext(p->gl, "GL_ARB_texture_rg") &&
+            has_texture_rg &&
             pl_opengl_has_ext(p->gl, "GL_ARB_framebuffer_object"))
         {
             DO_FORMATS(formats_norm8);
@@ -475,9 +543,28 @@ bool gl_setup_formats(struct pl_gpu_t *gpu)
                 DO_FORMATS(formats_half16);
             }
         } else {
-            // Fallback for GL2
+            // Fallback for GL 2.1 without full extension support
             DO_FORMATS(formats_legacy_gl2);
-            DO_FORMATS(formats_basic_vertex);
+            if (has_texture_rg) {
+                DO_FORMATS(formats_norm8_rg);
+            } else {
+                DO_FORMATS(formats_luminance_gl2);
+            }
+            if (pl_opengl_has_ext(p->gl, "GL_ARB_texture_float")) {
+                // Use GL2 float formats (no GL_R32F/GL_RG32F without texture_rg)
+                if (has_texture_rg) {
+                    DO_FORMATS(formats_float32);
+                } else {
+                    DO_FORMATS(formats_float32_gl2);
+                    DO_FORMATS(formats_vertex_gl2_1_2comp);
+                }
+            } else {
+                if (has_texture_rg) {
+                    DO_FORMATS(formats_basic_vertex);
+                } else {
+                    DO_FORMATS(formats_basic_vertex_gl2);
+                }
+            }
         }
         goto done;
     }
@@ -502,6 +589,42 @@ bool gl_setup_formats(struct pl_gpu_t *gpu)
         goto done;
     }
 
+    if (p->gl_ver >= 20) {
+        // Desktop GL 2.0: Use legacy formats (GL_LUMINANCE, GL_RGB, GL_RGBA)
+        // with optional modern formats if GL_ARB_texture_rg available
+        bool has_texture_rg = pl_opengl_has_ext(p->gl, "GL_ARB_texture_rg");
+        PL_INFO(gpu, "Using GL 2.0 format path, has_texture_rg=%d", has_texture_rg);
+        DO_FORMATS(formats_legacy_gl2);
+        if (has_texture_rg) {
+            // Modern single/dual-component formats (r8/rg8 only, not rgb8/rgba8)
+            DO_FORMATS(formats_norm8_rg);
+        } else {
+            // Fallback to GL_LUMINANCE for 1/2-component textures (video planes)
+            // These are sampleable but NOT renderable
+            DO_FORMATS(formats_luminance_gl2);
+        }
+        if (pl_opengl_has_ext(p->gl, "GL_ARB_texture_float")) {
+            // Float texture formats - use full set only if texture_rg is available
+            // (GL_R32F/GL_RG32F require GL_RED/GL_RG which come from texture_rg)
+            if (has_texture_rg) {
+                DO_FORMATS(formats_float32);
+            } else {
+                // Only RGB/RGBA float textures
+                DO_FORMATS(formats_float32_gl2);
+                // Plus vertex-only 1/2 component formats for vertex attributes
+                DO_FORMATS(formats_vertex_gl2_1_2comp);
+            }
+        } else {
+            // No float textures - just vertex-only float formats
+            if (has_texture_rg) {
+                DO_FORMATS(formats_basic_vertex);
+            } else {
+                DO_FORMATS(formats_basic_vertex_gl2);
+            }
+        }
+        goto done;
+    }
+
     if (p->gles_ver >= 20) {
         // GLES 2.0 only has some legacy fallback formats, with support for
         // float16 depending on GL_EXT_texture_norm16 being present
@@ -521,5 +644,15 @@ bool gl_setup_formats(struct pl_gpu_t *gpu)
     goto done;
 
 done:
+    // Debug: count renderable formats
+    {
+        int num_renderable = 0;
+        for (int i = 0; i < gpu->num_formats; i++) {
+            if (gpu->formats[i]->caps & PL_FMT_CAP_RENDERABLE)
+                num_renderable++;
+        }
+        PL_INFO(gpu, "Format setup complete: %d total formats, %d renderable",
+                gpu->num_formats, num_renderable);
+    }
     return gl_check_err(gpu, "gl_setup_formats");
 }
diff --git a/src/opengl/gpu.c b/src/opengl/gpu.c
index a515943f..c451deb9 100644
--- a/src/opengl/gpu.c
+++ b/src/opengl/gpu.c
@@ -30,6 +30,39 @@
 #include <sysinfoapi.h>
 #endif
 
+// Fallback definitions for OpenGL constants not available in GL 2.0
+// These will never be used at runtime due to extension checks, but are needed for compilation
+#ifndef GL_TEXTURE_RECTANGLE
+#define GL_TEXTURE_RECTANGLE GL_TEXTURE_RECTANGLE_ARB
+#endif
+#ifndef GL_MAX_PROGRAM_TEXTURE_GATHER_COMPONENTS_ARB
+#define GL_MAX_PROGRAM_TEXTURE_GATHER_COMPONENTS_ARB 0x8F9F
+#endif
+#ifndef GL_MIN_PROGRAM_TEXTURE_GATHER_OFFSET_ARB
+#define GL_MIN_PROGRAM_TEXTURE_GATHER_OFFSET_ARB 0x8E5E
+#endif
+#ifndef GL_MAX_PROGRAM_TEXTURE_GATHER_OFFSET_ARB
+#define GL_MAX_PROGRAM_TEXTURE_GATHER_OFFSET_ARB 0x8E5F
+#endif
+#ifndef GL_DYNAMIC_STORAGE_BIT
+#define GL_DYNAMIC_STORAGE_BIT 0x0100
+#endif
+#ifndef GL_MAP_PERSISTENT_BIT
+#define GL_MAP_PERSISTENT_BIT 0x0040
+#endif
+#ifndef GL_MAP_COHERENT_BIT
+#define GL_MAP_COHERENT_BIT 0x0080
+#endif
+#ifndef GL_CLIENT_STORAGE_BIT
+#define GL_CLIENT_STORAGE_BIT 0x0200
+#endif
+#ifndef GL_CLIENT_MAPPED_BUFFER_BARRIER_BIT
+#define GL_CLIENT_MAPPED_BUFFER_BARRIER_BIT 0x00004000
+#endif
+#ifndef GL_TIME_ELAPSED
+#define GL_TIME_ELAPSED 0x88BF
+#endif
+
 static const struct pl_gpu_fns pl_fns_gl;
 
 static void gl_gpu_destroy(pl_gpu gpu)
@@ -144,7 +177,7 @@ pl_gpu pl_gpu_create_gl(pl_log log, pl_opengl pl_gl, const struct pl_opengl_para
         }
     }
 
-    static const int glsl_ver_req = 130;
+    static const int glsl_ver_req = 110;
     if (glsl->version < glsl_ver_req) {
         PL_FATAL(gpu, "GLSL version too old (%d < %d), please use a newer "
                  "OpenGL implementation or downgrade libplacebo!",
@@ -152,7 +185,7 @@ pl_gpu pl_gpu_create_gl(pl_log log, pl_opengl pl_gl, const struct pl_opengl_para
         goto error;
     }
 
-    if (params->max_glsl_version && params->max_glsl_version >= glsl_ver_req) {
+    if (params->max_glsl_version && params->max_glsl_version >= 110) {
         glsl->version = PL_MIN(glsl->version, params->max_glsl_version);
         PL_INFO(gpu, "Restricting GLSL version to %d... new version is %d",
                 params->max_glsl_version, glsl->version);
@@ -246,6 +279,26 @@ pl_gpu pl_gpu_create_gl(pl_log log, pl_opengl pl_gl, const struct pl_opengl_para
     p->has_invalidate_tex = gl_test_ext(gpu, "GL_ARB_invalidate_subdata", 43, 0);
     p->has_queries = gl_test_ext(gpu, "GL_ARB_timer_query", 30, 0);
     p->has_storage = gl_test_ext(gpu, "GL_ARB_shader_image_load_store", 42, 31);
+
+    // WORKAROUND: Unconditionally enable FBOs on GL 2.0+ (extension query is broken)
+    // All GL 2.0+ drivers support FBOs via GL_EXT_framebuffer_object or ARB version
+    if (p->gl_ver >= 20) {
+        p->has_fbos = true;
+        PL_INFO(gpu, "Forcing FBO support on GL 2.0+ (extension detection unreliable)");
+    } else if (p->gles_ver >= 20) {
+        p->has_fbos = true;
+    } else {
+        // Try standard detection for older GL versions
+        p->has_fbos = gl_test_ext(gpu, "GL_ARB_framebuffer_object", 30, 20) ||
+                      gl_test_ext(gpu, "GL_EXT_framebuffer_object", 20, 0);
+    }
+
+    // GL_READ_FRAMEBUFFER / GL_DRAW_FRAMEBUFFER require ARB_framebuffer_object or GL 3.0+
+    // GL_EXT_framebuffer_object only has GL_FRAMEBUFFER
+    p->has_separate_fbo_bindings = gl_test_ext(gpu, "GL_ARB_framebuffer_object", 30, 30);
+
+    p->has_stride = gl_test_ext(gpu, "GL_EXT_unpack_subimage", 11, 30);
+    p->has_unpack_image_height = p->gl_ver >= 12 || p->gles_ver >= 30;
     p->has_readback = true;
 
     if (p->has_readback && p->gles_ver) {
@@ -277,9 +330,11 @@ pl_gpu pl_gpu_create_gl(pl_log log, pl_opengl pl_gl, const struct pl_opengl_para
 
     // We simply don't know, so make up some values
     limits->align_tex_xfer_offset = 32;
-    limits->align_tex_xfer_pitch = 4;
+    limits->align_tex_xfer_pitch = 1;
     limits->fragment_queues = 1;
     limits->compute_queues = glsl->compute ? 1 : 0;
+    if (p->has_stride)
+        limits->align_tex_xfer_pitch = 4;
 
     if (!gl_check_err(gpu, "pl_gpu_create_gl")) {
         PL_WARN(gpu, "Encountered errors while detecting GPU capabilities... "
diff --git a/src/opengl/gpu.h b/src/opengl/gpu.h
index 3681f30b..c7380060 100644
--- a/src/opengl/gpu.h
+++ b/src/opengl/gpu.h
@@ -59,6 +59,10 @@ struct pl_gl {
     bool has_readback;
     bool has_egl_storage;
     bool has_egl_import;
+    bool has_fbos;        // Framebuffer object support (EXT or ARB)
+    bool has_separate_fbo_bindings; // GL_ARB_framebuffer_object (has GL_READ/DRAW_FRAMEBUFFER)
+    bool has_stride;      // GL_EXT_unpack_subimage (GL_UNPACK_ROW_LENGTH support)
+    bool has_unpack_image_height; // GL 1.2+ or GLES 3.0+ (GL_UNPACK_IMAGE_HEIGHT)
     int gather_comps;
 };
 
@@ -92,6 +96,21 @@ static inline void _release_current(pl_gpu gpu)
 #define MAKE_CURRENT() _make_current(gpu)
 #define RELEASE_CURRENT() _release_current(gpu)
 
+// Helper macros for FBO binding targets
+// GL_EXT_framebuffer_object only has GL_FRAMEBUFFER
+// GL_ARB_framebuffer_object / GL 3.0+ has GL_READ_FRAMEBUFFER and GL_DRAW_FRAMEBUFFER
+static inline GLenum gl_read_fb_target(pl_gpu gpu)
+{
+    struct pl_gl *p = PL_PRIV(gpu);
+    return p->has_separate_fbo_bindings ? GL_READ_FRAMEBUFFER : GL_FRAMEBUFFER;
+}
+
+static inline GLenum gl_draw_fb_target(pl_gpu gpu)
+{
+    struct pl_gl *p = PL_PRIV(gpu);
+    return p->has_separate_fbo_bindings ? GL_DRAW_FRAMEBUFFER : GL_FRAMEBUFFER;
+}
+
 struct pl_tex_gl {
     GLenum target;
     GLuint texture;
diff --git a/src/opengl/gpu_pass.c b/src/opengl/gpu_pass.c
index 96f15e7d..97127413 100644
--- a/src/opengl/gpu_pass.c
+++ b/src/opengl/gpu_pass.c
@@ -305,10 +305,16 @@ pl_pass gl_pass_create(pl_gpu gpu, const struct pl_pass_params *params)
             break;
         }
         case PL_DESC_BUF_STORAGE: {
-            GLuint idx = gl->GetProgramResourceIndex(pass_gl->program,
-                                                     GL_SHADER_STORAGE_BLOCK,
-                                                     desc->name);
-            gl->ShaderStorageBlockBinding(pass_gl->program, idx, desc->binding);
+            // SSBOs require GL 4.3+ or GL_ARB_shader_storage_buffer_object
+            if (gpu->limits.max_ssbo_size > 0) {
+                GLuint idx = gl->GetProgramResourceIndex(pass_gl->program,
+                                                         GL_SHADER_STORAGE_BLOCK,
+                                                         desc->name);
+                gl->ShaderStorageBlockBinding(pass_gl->program, idx, desc->binding);
+            } else {
+                PL_ERR(gpu, "Pass uses SSBO descriptors but SSBOs are not supported!");
+                goto error;
+            }
             break;
         }
         case PL_DESC_BUF_TEXEL_UNIFORM:
@@ -479,6 +485,11 @@ static void update_desc(pl_gpu gpu, pl_pass pass, int index,
         return;
     }
     case PL_DESC_BUF_STORAGE: {
+        // Should have been caught during pass creation
+        if (gpu->limits.max_ssbo_size == 0) {
+            PL_ERR(gpu, "SSBO descriptor used but SSBOs not supported!");
+            return;
+        }
         pl_buf buf = db->object;
         struct pl_buf_gl *buf_gl = PL_PRIV(buf);
         gl->BindBufferRange(GL_SHADER_STORAGE_BUFFER, desc->binding, buf_gl->buffer,
@@ -524,6 +535,11 @@ static void unbind_desc(pl_gpu gpu, pl_pass pass, int index,
         gl->BindBufferBase(GL_UNIFORM_BUFFER, desc->binding, 0);
         return;
     case PL_DESC_BUF_STORAGE: {
+        // Should have been caught during pass creation
+        if (gpu->limits.max_ssbo_size == 0) {
+            PL_ERR(gpu, "SSBO descriptor used but SSBOs not supported!");
+            return;
+        }
         pl_buf buf = db->object;
         struct pl_buf_gl *buf_gl = PL_PRIV(buf);
         gl->BindBufferBase(GL_SHADER_STORAGE_BUFFER, desc->binding, 0);
@@ -568,10 +584,10 @@ void gl_pass_run(pl_gpu gpu, const struct pl_pass_run_params *params)
     switch (pass->params.type) {
     case PL_PASS_RASTER: {
         struct pl_tex_gl *target_gl = PL_PRIV(params->target);
-        gl->BindFramebuffer(GL_DRAW_FRAMEBUFFER, target_gl->fbo);
+        gl->BindFramebuffer(gl_draw_fb_target(gpu), target_gl->fbo);
         if (!pass->params.load_target && p->has_invalidate_fb) {
             GLenum fb = target_gl->fbo ? GL_COLOR_ATTACHMENT0 : GL_COLOR;
-            gl->InvalidateFramebuffer(GL_DRAW_FRAMEBUFFER, 1, &fb);
+            gl->InvalidateFramebuffer(gl_draw_fb_target(gpu), 1, &fb);
         }
 
         gl->Viewport(params->viewport.x0, params->viewport.y0,
@@ -681,7 +697,7 @@ void gl_pass_run(pl_gpu gpu, const struct pl_pass_run_params *params)
         gl->BindBuffer(GL_ARRAY_BUFFER, 0);
         gl->Disable(GL_SCISSOR_TEST);
         gl->Disable(GL_BLEND);
-        gl->BindFramebuffer(GL_DRAW_FRAMEBUFFER, 0);
+        gl->BindFramebuffer(gl_draw_fb_target(gpu), 0);
         break;
     }
 
diff --git a/src/opengl/gpu_tex.c b/src/opengl/gpu_tex.c
index ca488ee2..497b8c50 100644
--- a/src/opengl/gpu_tex.c
+++ b/src/opengl/gpu_tex.c
@@ -16,6 +16,11 @@
  */
 
 #include "gpu.h"
+
+// GL 2.0 compatibility: Map non-ARB constant to ARB version
+#ifndef GL_TEXTURE_RECTANGLE
+#define GL_TEXTURE_RECTANGLE GL_TEXTURE_RECTANGLE_ARB
+#endif
 #include "formats.h"
 #include "utils.h"
 
@@ -408,8 +413,9 @@ pl_tex gl_tex_create(pl_gpu gpu, const struct pl_tex_params *params)
             goto error;
         }
 
-        const GLenum target = p->gles_ver && p->gles_ver < 30 ?
-            GL_FRAMEBUFFER : GL_READ_FRAMEBUFFER;
+        // GL_READ_FRAMEBUFFER requires ARB_framebuffer_object or GL 3.0+
+        // GL_EXT_framebuffer_object only has GL_FRAMEBUFFER
+        const GLenum target = p->has_separate_fbo_bindings ? GL_READ_FRAMEBUFFER : GL_FRAMEBUFFER;
 
         gl->GenFramebuffers(1, &tex_gl->fbo);
         gl->BindFramebuffer(target, tex_gl->fbo);
@@ -486,14 +492,14 @@ static bool gl_fb_query(pl_gpu gpu, int fbo, struct pl_fmt_t *fmt,
         can_query = false; // can't query default framebuffer on GLES 2.0
 
     if (can_query) {
-        gl->BindFramebuffer(GL_DRAW_FRAMEBUFFER, fbo);
+        gl->BindFramebuffer(gl_draw_fb_target(gpu), fbo);
 
         GLenum obj = p->gles_ver ? GL_BACK : GL_BACK_LEFT;
         if (fbo != 0)
             obj = GL_COLOR_ATTACHMENT0;
 
         GLint type = 0;
-        gl->GetFramebufferAttachmentParameteriv(GL_DRAW_FRAMEBUFFER, obj,
+        gl->GetFramebufferAttachmentParameteriv(gl_draw_fb_target(gpu), obj,
                             GL_FRAMEBUFFER_ATTACHMENT_COMPONENT_TYPE, &type);
         switch (type) {
         case GL_FLOAT:                  fmt->type = PL_FMT_FLOAT; break;
@@ -504,16 +510,16 @@ static bool gl_fb_query(pl_gpu gpu, int fbo, struct pl_fmt_t *fmt,
         default:                        fmt->type = PL_FMT_UNKNOWN; break;
         }
 
-        gl->GetFramebufferAttachmentParameteriv(GL_DRAW_FRAMEBUFFER, obj,
+        gl->GetFramebufferAttachmentParameteriv(gl_draw_fb_target(gpu), obj,
                 GL_FRAMEBUFFER_ATTACHMENT_RED_SIZE, &fmt->component_depth[0]);
-        gl->GetFramebufferAttachmentParameteriv(GL_DRAW_FRAMEBUFFER, obj,
+        gl->GetFramebufferAttachmentParameteriv(gl_draw_fb_target(gpu), obj,
                 GL_FRAMEBUFFER_ATTACHMENT_GREEN_SIZE, &fmt->component_depth[1]);
-        gl->GetFramebufferAttachmentParameteriv(GL_DRAW_FRAMEBUFFER, obj,
+        gl->GetFramebufferAttachmentParameteriv(gl_draw_fb_target(gpu), obj,
                 GL_FRAMEBUFFER_ATTACHMENT_BLUE_SIZE, &fmt->component_depth[2]);
-        gl->GetFramebufferAttachmentParameteriv(GL_DRAW_FRAMEBUFFER, obj,
+        gl->GetFramebufferAttachmentParameteriv(gl_draw_fb_target(gpu), obj,
                 GL_FRAMEBUFFER_ATTACHMENT_ALPHA_SIZE, &fmt->component_depth[3]);
 
-        gl->BindFramebuffer(GL_DRAW_FRAMEBUFFER, 0);
+        gl->BindFramebuffer(gl_draw_fb_target(gpu), 0);
         gl_check_err(gpu, "gl_fb_query");
 
         if (!fmt->component_depth[0]) {
@@ -689,8 +695,9 @@ pl_tex pl_opengl_wrap(pl_gpu gpu, const struct pl_opengl_wrap_params *params)
                    dims < 3;
 
     if (can_fbo && !tex_gl->fbo) {
-        const GLenum target = p->gles_ver && p->gles_ver < 30 ?
-            GL_FRAMEBUFFER : GL_READ_FRAMEBUFFER;
+        // GL_READ_FRAMEBUFFER requires ARB_framebuffer_object or GL 3.0+
+        // GL_EXT_framebuffer_object only has GL_FRAMEBUFFER
+        const GLenum target = p->has_separate_fbo_bindings ? GL_READ_FRAMEBUFFER : GL_FRAMEBUFFER;
 
         gl->GenFramebuffers(1, &tex_gl->fbo);
         gl->BindFramebuffer(target, tex_gl->fbo);
@@ -788,9 +795,9 @@ void gl_tex_invalidate(pl_gpu gpu, pl_tex tex)
 
     if ((tex_gl->wrapped_fb || tex_gl->fbo) && p->has_invalidate_fb) {
         GLenum attachment = tex_gl->fbo ? GL_COLOR_ATTACHMENT0 : GL_COLOR;
-        gl->BindFramebuffer(GL_DRAW_FRAMEBUFFER, tex_gl->fbo);
-        gl->InvalidateFramebuffer(GL_DRAW_FRAMEBUFFER, 1, &attachment);
-        gl->BindFramebuffer(GL_DRAW_FRAMEBUFFER, 0);
+        gl->BindFramebuffer(gl_draw_fb_target(gpu), tex_gl->fbo);
+        gl->InvalidateFramebuffer(gl_draw_fb_target(gpu), 1, &attachment);
+        gl->BindFramebuffer(gl_draw_fb_target(gpu), 0);
     }
 
     gl_check_err(gpu, "gl_tex_invalidate");
@@ -807,8 +814,8 @@ void gl_tex_clear_ex(pl_gpu gpu, pl_tex tex, const union pl_clear_color color)
     struct pl_tex_gl *tex_gl = PL_PRIV(tex);
     pl_assert(tex_gl->fbo || tex_gl->wrapped_fb);
 
-    const GLenum target = p->gles_ver && p->gles_ver < 30 ?
-        GL_FRAMEBUFFER : GL_DRAW_FRAMEBUFFER;
+    // Use helper function that respects FBO extension availability
+    const GLenum target = gl_draw_fb_target(gpu);
 
     gl->BindFramebuffer(target, tex_gl->fbo);
 
@@ -853,8 +860,8 @@ void gl_tex_blit(pl_gpu gpu, const struct pl_tex_blit_params *params)
 
     pl_assert(src_gl->fbo || src_gl->wrapped_fb);
     pl_assert(dst_gl->fbo || dst_gl->wrapped_fb);
-    gl->BindFramebuffer(GL_READ_FRAMEBUFFER, src_gl->fbo);
-    gl->BindFramebuffer(GL_DRAW_FRAMEBUFFER, dst_gl->fbo);
+    gl->BindFramebuffer(gl_read_fb_target(gpu), src_gl->fbo);
+    gl->BindFramebuffer(gl_draw_fb_target(gpu), dst_gl->fbo);
 
     static const GLint filters[PL_TEX_SAMPLE_MODE_COUNT] = {
         [PL_TEX_SAMPLE_NEAREST] = GL_NEAREST,
@@ -866,8 +873,8 @@ void gl_tex_blit(pl_gpu gpu, const struct pl_tex_blit_params *params)
                         dst_rc.x0, dst_rc.y0, dst_rc.x1, dst_rc.y1,
                         GL_COLOR_BUFFER_BIT, filters[params->sample_mode]);
 
-    gl->BindFramebuffer(GL_READ_FRAMEBUFFER, 0);
-    gl->BindFramebuffer(GL_DRAW_FRAMEBUFFER, 0);
+    gl->BindFramebuffer(gl_read_fb_target(gpu), 0);
+    gl->BindFramebuffer(gl_draw_fb_target(gpu), 0);
     gl_check_err(gpu, "gl_tex_blit");
     RELEASE_CURRENT();
 }
@@ -921,15 +928,22 @@ bool gl_tex_upload(pl_gpu gpu, const struct pl_tex_transfer_params *params)
         gl->PixelStorei(GL_UNPACK_ALIGNMENT, get_alignment(params->row_pitch));
 
     int rows = pl_rect_h(params->rc);
-    if (misaligned) {
-        rows = 1;
-    } else if (stride_w != pl_rect_w(params->rc)) {
-        gl->PixelStorei(GL_UNPACK_ROW_LENGTH, stride_w);
+    if (stride_w != pl_rect_w(params->rc) || misaligned) {
+        if (p->has_stride && !misaligned) {
+            gl->PixelStorei(GL_UNPACK_ROW_LENGTH, stride_w);
+        } else {
+            rows = 1;
+        }
     }
 
     int imgs = pl_rect_d(params->rc);
-    if (stride_h != pl_rect_h(params->rc) || rows < stride_h)
-        gl->PixelStorei(GL_UNPACK_IMAGE_HEIGHT, stride_h);
+    if (stride_h != pl_rect_h(params->rc) || rows < stride_h) {
+        if (p->has_unpack_image_height) {
+            gl->PixelStorei(GL_UNPACK_IMAGE_HEIGHT, stride_h);
+        } else {
+            imgs = 1;
+        }
+    }
 
     gl->BindTexture(tex_gl->target, tex_gl->texture);
     gl_timer_begin(gpu, params->timer);
@@ -964,8 +978,10 @@ bool gl_tex_upload(pl_gpu gpu, const struct pl_tex_transfer_params *params)
     gl_timer_end(gpu, params->timer);
     gl->BindTexture(tex_gl->target, 0);
     gl->PixelStorei(GL_UNPACK_ALIGNMENT, 4);
-    gl->PixelStorei(GL_UNPACK_ROW_LENGTH, 0);
-    gl->PixelStorei(GL_UNPACK_IMAGE_HEIGHT, 0);
+    if (p->has_stride)
+        gl->PixelStorei(GL_UNPACK_ROW_LENGTH, 0);
+    if (p->has_unpack_image_height)
+        gl->PixelStorei(GL_UNPACK_IMAGE_HEIGHT, 0);
 
     if (buf) {
         gl->BindBuffer(GL_PIXEL_UNPACK_BUFFER, 0);
@@ -1043,17 +1059,20 @@ bool gl_tex_download(pl_gpu gpu, const struct pl_tex_transfer_params *params)
             gl->PixelStorei(GL_PACK_ALIGNMENT, get_alignment(params->row_pitch));
 
         int rows = pl_rect_h(params->rc);
-        if (misaligned) {
-            rows = 1;
-        } else if (stride_w != tex->params.w) {
-            gl->PixelStorei(GL_PACK_ROW_LENGTH, stride_w);
+        if (stride_w != tex->params.w || misaligned) {
+            if (p->has_stride && !misaligned) {
+                gl->PixelStorei(GL_PACK_ROW_LENGTH, stride_w);
+            } else {
+                rows = 1;
+            }
         }
 
         // No 3D framebuffers
         pl_assert(pl_rect_d(params->rc) == 1);
 
-        const GLenum target = p->gles_ver && p->gles_ver < 30 ?
-            GL_FRAMEBUFFER : GL_READ_FRAMEBUFFER;
+        // GL_READ_FRAMEBUFFER requires ARB_framebuffer_object or GL 3.0+
+        // GL_EXT_framebuffer_object only has GL_FRAMEBUFFER
+        const GLenum target = p->has_separate_fbo_bindings ? GL_READ_FRAMEBUFFER : GL_FRAMEBUFFER;
 
         gl->BindFramebuffer(target, tex_gl->fbo);
         for (int y = params->rc.y0; y < params->rc.y1; y += rows) {
@@ -1063,7 +1082,8 @@ bool gl_tex_download(pl_gpu gpu, const struct pl_tex_transfer_params *params)
         }
         gl->BindFramebuffer(target, 0);
         gl->PixelStorei(GL_PACK_ALIGNMENT, 4);
-        gl->PixelStorei(GL_PACK_ROW_LENGTH, 0);
+        if (p->has_stride)
+            gl->PixelStorei(GL_PACK_ROW_LENGTH, 0);
     } else if (is_copy) {
         // We're downloading the entire texture
         gl->BindTexture(tex_gl->target, tex_gl->texture);
diff --git a/src/opengl/include/glad/meson.build b/src/opengl/include/glad/meson.build
index 05b3f023..bc5f5301 100644
--- a/src/opengl/include/glad/meson.build
+++ b/src/opengl/include/glad/meson.build
@@ -18,7 +18,7 @@ glad = custom_target('gl.h',
   env: python_env,
   command: [
     python, '-m', 'glad', '--out-path=@OUTDIR@/../../',
-    '--reproducible', '--merge', '--api=gl:core,gles2,egl',
+    '--reproducible', '--merge', '--api=gl:compatibility=2.0,gles2,egl',
     '--extensions=' + ','.join(gl_extensions), 'c', '--header-only', '--mx'
   ] + (opengl_link.allowed() ? ['--loader'] : [])
 )
diff --git a/src/opengl/meson.build b/src/opengl/meson.build
index f753628c..a28ccb00 100644
--- a/src/opengl/meson.build
+++ b/src/opengl/meson.build
@@ -31,24 +31,37 @@ if opengl_build.allowed()
 
   gl_extensions = [
     'GL_AMD_pinned_memory',
+    # GL 4.4+ extensions - needed for compilation even on GL 2.0
     'GL_ARB_buffer_storage',
-    'GL_ARB_compute_shader',
-    'GL_ARB_framebuffer_object',
-    'GL_ARB_get_program_binary',
+    # GL 4.3+ extensions - needed for compilation even on GL 2.0
+    # (function pointers will be NULL if not available at runtime)
     'GL_ARB_invalidate_subdata',
-    'GL_ARB_pixel_buffer_object',
     'GL_ARB_program_interface_query',
-    'GL_ARB_shader_image_load_store',
     'GL_ARB_shader_storage_buffer_object',
-    'GL_ARB_sync',
-    'GL_ARB_texture_float',
+    # Compute shaders not needed for GL 2.0
+    # 'GL_ARB_compute_shader',
+    # GL 4.2+ extensions - needed for compilation even on GL 2.0
+    'GL_ARB_shader_image_load_store',
+    # GL 4.1+ extensions - needed for compilation even on GL 2.0
+    'GL_ARB_get_program_binary',
+    # GL 4.0+ extensions - needed for compilation even on GL 2.0
     'GL_ARB_texture_gather',
-    'GL_ARB_texture_rg',
+    # GL 3.3+ extensions - needed for compilation even on GL 2.0
     'GL_ARB_timer_query',
+    # GL 3.1+ extensions - needed for compilation even on GL 2.0
+    'GL_ARB_pixel_buffer_object',
     'GL_ARB_uniform_buffer_object',
+    # GL 2.x compatible extensions
+    'GL_ARB_framebuffer_object',
+    'GL_ARB_sync',
+    'GL_ARB_texture_float',
+    'GL_ARB_texture_rectangle',
+    'GL_ARB_texture_rg',
     'GL_ARB_vertex_array_object',
     'GL_ARB_half_float_pixel',
     'GL_EXT_EGL_image_storage',
+    'GL_EXT_framebuffer_object',  # GL 2.0+ FBO support (older than ARB version)
+    'GL_EXT_framebuffer_blit',    # BlitFramebuffer for GL 2.0 (older than ARB)
     'GL_EXT_color_buffer_float',
     'GL_EXT_texture3D',
     'GL_EXT_texture_format_BGRA8888',
diff --git a/src/renderer.c b/src/renderer.c
index 576528b5..088373e1 100644
--- a/src/renderer.c
+++ b/src/renderer.c
@@ -419,16 +419,32 @@ static void find_fbo_format(struct pass_state *pass)
         pass->fbofmt[4] = fmt;
 
         // Probe the right variant for each number of channels, falling
-        // back to the next biggest format
-        for (int c = 1; c < 4; c++) {
+        // back to the next biggest format. Must iterate downward so that
+        // fbofmt[c+1] is already set when we compute the fallback.
+        // Note: For maximum compatibility (especially on old GL 2.0 drivers),
+        // we strongly prefer 4-component RGBA formats for FBOs since RGB
+        // formats often have poor FBO support.
+        for (int c = 3; c >= 1; c--) {
             pass->fbofmt[c] = pl_find_fmt(rr->gpu, configs[i].type, c,
                                         configs[i].depth, 0, fmt->caps);
-            pass->fbofmt[c] = PL_DEF(pass->fbofmt[c], pass->fbofmt[c+1]);
+            // Prefer falling back to 4-component format directly for better
+            // FBO compatibility on older drivers
+            if (!pass->fbofmt[c])
+                pass->fbofmt[c] = pass->fbofmt[4];
+            if (!pass->fbofmt[c])
+                pass->fbofmt[c] = pass->fbofmt[c+1];
         }
+        PL_INFO(rr, "Found FBO format: %s (type=%d, depth=%d), fbofmt[1]=%s, fbofmt[2]=%s, fbofmt[3]=%s, fbofmt[4]=%s",
+                fmt->name, configs[i].type, configs[i].depth,
+                pass->fbofmt[1] ? pass->fbofmt[1]->name : "(null)",
+                pass->fbofmt[2] ? pass->fbofmt[2]->name : "(null)",
+                pass->fbofmt[3] ? pass->fbofmt[3]->name : "(null)",
+                pass->fbofmt[4] ? pass->fbofmt[4]->name : "(null)");
         return;
     }
 
-    PL_WARN(rr, "Found no renderable FBO format! Most features disabled");
+    PL_WARN(rr, "Found no renderable FBO format! Most features disabled. "
+            "Tried types: FLOAT@16, UNORM@16, SNORM@16, UNORM@8");
     rr->errors |= PL_RENDER_ERR_FBO;
 }
 
@@ -450,9 +466,14 @@ static pl_tex get_fbo(struct pass_state *pass, int w, int h, pl_fmt fmt,
     pl_renderer rr = pass->rr;
     const int n = pass->info.stage;
     comps = PL_DEF(comps, 4);
+    pl_fmt orig_fmt = fmt;
     fmt = PL_DEF(fmt, pass->fbofmt[comps]);
-    if (!fmt)
+    if (!fmt) {
+        PL_ERR(rr, "get_fbo: no format for comps=%d (orig_fmt=%p, fbofmt[1]=%p, fbofmt[2]=%p, fbofmt[3]=%p, fbofmt[4]=%p)",
+               comps, (void*)orig_fmt, (void*)pass->fbofmt[1], (void*)pass->fbofmt[2],
+               (void*)pass->fbofmt[3], (void*)pass->fbofmt[4]);
         return NULL;
+    }
 
     pl_assert(w && h);
     struct pl_tex_params params = {
@@ -1477,8 +1498,9 @@ static pl_fmt merge_fmt(struct pass_state *pass, const struct img *a,
     int min_depth = PL_MAX(a->repr.bits.sample_depth, b->repr.bits.sample_depth);
 
     // Only return formats that support all relevant caps of both formats
-    const enum pl_fmt_caps mask = PL_FMT_CAP_SAMPLEABLE | PL_FMT_CAP_LINEAR;
-    enum pl_fmt_caps req_caps = (fmta->caps & mask) | (fmtb->caps & mask);
+    // RENDERABLE is required since the merged plane needs to be rendered to FBO
+    const enum pl_fmt_caps mask = PL_FMT_CAP_SAMPLEABLE | PL_FMT_CAP_LINEAR | PL_FMT_CAP_RENDERABLE;
+    enum pl_fmt_caps req_caps = (fmta->caps & mask) | (fmtb->caps & mask) | PL_FMT_CAP_RENDERABLE;
     enum pl_fmt_type req_type = fmta->type;
 
     // If we have integer formats on input, convert now to FBO format.
diff --git a/src/shaders.c b/src/shaders.c
index 73e03ec8..1daef9c7 100644
--- a/src/shaders.c
+++ b/src/shaders.c
@@ -92,8 +92,8 @@ static void init_shader(pl_shader sh, const struct pl_shader_params *params)
 
 pl_shader pl_shader_alloc(pl_log log, const struct pl_shader_params *params)
 {
-    static const int glsl_ver_req = 130;
-    if (params && params->glsl.version && params->glsl.version < 130) {
+    static const int glsl_ver_req = 110;
+    if (params && params->glsl.version && params->glsl.version < 110) {
         pl_err(log, "Requested GLSL version %d too low (required: %d)",
                params->glsl.version, glsl_ver_req);
         return NULL;
@@ -208,7 +208,7 @@ struct pl_glsl_version sh_glsl(const pl_shader sh)
     if (SH_GPU(sh))
         return SH_GPU(sh)->glsl;
 
-    return (struct pl_glsl_version) { .version = 130 };
+    return (struct pl_glsl_version) { .version = 110 };
 }
 
 bool sh_try_compute(pl_shader sh, int bw, int bh, bool flex, size_t mem)
@@ -425,7 +425,12 @@ ident_t sh_const(pl_shader sh, struct pl_shader_const sc)
         GLSLH("const int "$" = %d; \n", id, *(int *) sc.data);
         return id;
     case PL_VAR_UINT:
-        GLSLH("const uint "$" = uint(%u); \n", id, *(unsigned int *) sc.data);
+        // GLSL < 130 doesn't have uint type, use int instead
+        if (sh_glsl(sh).version < 130) {
+            GLSLH("const int "$" = int(%u); \n", id, *(unsigned int *) sc.data);
+        } else {
+            GLSLH("const uint "$" = uint(%u); \n", id, *(unsigned int *) sc.data);
+        }
         return id;
     case PL_VAR_FLOAT:
         GLSLH("const float "$" = float(%f); \n", id, *(float *) sc.data);
@@ -959,26 +964,49 @@ ident_t sh_prng(pl_shader sh, bool temporal, ident_t *p_state)
     ident_t randfun = sh_fresh(sh, "rand"),
             state = sh_fresh(sh, "state");
 
-    // Based on pcg3d (http://jcgt.org/published/0009/03/02/)
-    GLSLP("#define prng_t uvec3\n");
-    GLSLH("vec3 "$"(inout uvec3 s) {                    \n"
-          "    s = 1664525u * s + uvec3(1013904223u);   \n"
-          "    s.x += s.y * s.z;                        \n"
-          "    s.y += s.z * s.x;                        \n"
-          "    s.z += s.x * s.y;                        \n"
-          "    s ^= s >> 16u;                           \n"
-          "    s.x += s.y * s.z;                        \n"
-          "    s.y += s.z * s.x;                        \n"
-          "    s.z += s.x * s.y;                        \n"
-          "    return vec3(s) * 1.0/float(0xFFFFFFFFu); \n"
-          "}                                            \n",
-          randfun);
-
-    if (temporal) {
-        GLSL("uvec3 "$" = uvec3(gl_FragCoord.xy, "$"); \n",
-             state, SH_UINT_DYN(SH_PARAMS(sh).index));
+    const struct pl_glsl_version glsl = sh_glsl(sh);
+
+    if (glsl.version < 130) {
+        // GLSL < 130: Use float-based PRNG (no uvec3 support)
+        // Based on simple hash function using trigonometry
+        GLSLP("#define prng_t vec3\n");
+        GLSLH("vec3 "$"(inout vec3 s) {                             \n"
+              "    vec3 p = fract(s * vec3(0.1031, 0.1030, 0.0973)); \n"
+              "    p += dot(p, p.yzx + 33.33);                       \n"
+              "    s = fract((p.xxy + p.yzz) * p.zyx);               \n"
+              "    return fract(s * vec3(12.9898, 78.233, 37.719));  \n"
+              "}                                                     \n",
+              randfun);
+
+        if (temporal) {
+            GLSL("vec3 "$" = vec3(gl_FragCoord.xy, float("$")); \n",
+                 state, SH_UINT_DYN(SH_PARAMS(sh).index));
+        } else {
+            GLSL("vec3 "$" = vec3(gl_FragCoord.xy, 0.0); \n", state);
+        }
     } else {
-        GLSL("uvec3 "$" = uvec3(gl_FragCoord.xy, 0.0); \n", state);
+        // GLSL >= 130: Use uvec3-based PRNG (pcg3d)
+        // Based on pcg3d (http://jcgt.org/published/0009/03/02/)
+        GLSLP("#define prng_t uvec3\n");
+        GLSLH("vec3 "$"(inout uvec3 s) {                    \n"
+              "    s = 1664525u * s + uvec3(1013904223u);   \n"
+              "    s.x += s.y * s.z;                        \n"
+              "    s.y += s.z * s.x;                        \n"
+              "    s.z += s.x * s.y;                        \n"
+              "    s ^= s >> 16u;                           \n"
+              "    s.x += s.y * s.z;                        \n"
+              "    s.y += s.z * s.x;                        \n"
+              "    s.z += s.x * s.y;                        \n"
+              "    return vec3(s) * 1.0/float(0xFFFFFFFFu); \n"
+              "}                                            \n",
+              randfun);
+
+        if (temporal) {
+            GLSL("uvec3 "$" = uvec3(gl_FragCoord.xy, "$"); \n",
+                 state, SH_UINT_DYN(SH_PARAMS(sh).index));
+        } else {
+            GLSL("uvec3 "$" = uvec3(gl_FragCoord.xy, 0.0); \n", state);
+        }
     }
 
     if (p_state)
diff --git a/src/shaders/colorspace.c b/src/shaders/colorspace.c
index 475af5ab..29b0d977 100644
--- a/src/shaders/colorspace.c
+++ b/src/shaders/colorspace.c
@@ -1866,6 +1866,11 @@ void pl_shader_color_map_ex(pl_shader sh, const struct pl_color_map_params *para
         }
 
         bool need_recovery = tone.input_max >= tone.output_max;
+        // GLSL < 130 doesn't support textureSize() needed by contrast recovery
+        if (sh_glsl(sh).version < 130 && need_recovery && params->contrast_recovery) {
+            PL_DEBUG(sh, "GLSL < 130: contrast recovery unavailable, skipping");
+            need_recovery = false;
+        }
         if (need_recovery && params->contrast_recovery && args->feature_map) {
             ident_t pos, pt;
             ident_t lowres = sh_bind(sh, args->feature_map, PL_TEX_ADDRESS_CLAMP,
diff --git a/src/shaders/custom_mpv.c b/src/shaders/custom_mpv.c
index 4ef08178..4af70c27 100644
--- a/src/shaders/custom_mpv.c
+++ b/src/shaders/custom_mpv.c
@@ -1153,7 +1153,17 @@ static bool bind_pass_tex(pl_shader sh, pl_str name,
     GLSLH("#define %.*s_raw "$" \n", PL_STR_FMT(name), id);
     GLSLH("#define %.*s_pos "$" \n", PL_STR_FMT(name), pos);
     GLSLH("#define %.*s_map "$"_map \n", PL_STR_FMT(name), pos);
-    GLSLH("#define %.*s_size vec2(textureSize("$", 0)) \n", PL_STR_FMT(name), id);
+    // GLSL < 130 doesn't support textureSize(), use uniform instead
+    if (sh_glsl(sh).version < 130) {
+        float size[2] = { ptex->tex->params.w, ptex->tex->params.h };
+        GLSLH("#define %.*s_size "$" \n", PL_STR_FMT(name),
+              sh_var(sh, (struct pl_shader_var) {
+                  .var = pl_var_vec2("tex_size"),
+                  .data = size,
+        }));
+    } else {
+        GLSLH("#define %.*s_size vec2(textureSize("$", 0)) \n", PL_STR_FMT(name), id);
+    }
     GLSLH("#define %.*s_pt "$" \n", PL_STR_FMT(name), pt);
 
     float off[2] = { ptex->rect.x0, ptex->rect.y0 };
diff --git a/src/shaders/deinterlacing.c b/src/shaders/deinterlacing.c
index 27b285e2..3ee25fdd 100644
--- a/src/shaders/deinterlacing.c
+++ b/src/shaders/deinterlacing.c
@@ -27,6 +27,12 @@ void pl_shader_deinterlace(pl_shader sh, const struct pl_deinterlace_source *src
 {
     params = PL_DEF(params, &pl_deinterlace_default_params);
 
+    // GLSL < 130 doesn't support textureSize() needed by deinterlacing
+    if (sh_glsl(sh).version < 130) {
+        PL_WARN(sh, "GLSL < 130: deinterlacing unavailable");
+        return;
+    }
+
     const struct pl_tex_params *texparams = &src->cur.top->params;
     if (!sh_require(sh, PL_SHADER_SIG_NONE, texparams->w, texparams->h))
         return;
diff --git a/src/shaders/dithering.c b/src/shaders/dithering.c
index 4485d110..df6ca580 100644
--- a/src/shaders/dithering.c
+++ b/src/shaders/dithering.c
@@ -165,11 +165,23 @@ void pl_shader_dither(pl_shader sh, int new_depth,
     goto done;
 
 fallback:
-    method = PL_DITHER_ORDERED_FIXED;
+    // GLSL < 130 doesn't support uvec2/uint types needed for ORDERED_FIXED
+    if (sh_glsl(sh).version < 130) {
+        PL_DEBUG(sh, "GLSL < 130: Using white noise dither instead of ordered fixed");
+        method = PL_DITHER_WHITE_NOISE;
+    } else {
+        method = PL_DITHER_ORDERED_FIXED;
+    }
     // fall through
 
 done: ;
 
+    // GLSL < 130 check: If still using ORDERED_FIXED, switch to white noise
+    if (method == PL_DITHER_ORDERED_FIXED && sh_glsl(sh).version < 130) {
+        PL_DEBUG(sh, "GLSL < 130: Forcing white noise dither (ordered fixed unsupported)");
+        method = PL_DITHER_WHITE_NOISE;
+    }
+
     int size = 0;
     if (lut) {
         size = lut_size;
diff --git a/src/shaders/lut.c b/src/shaders/lut.c
index b0124fcc..6ba5684f 100644
--- a/src/shaders/lut.c
+++ b/src/shaders/lut.c
@@ -435,7 +435,10 @@ next_dim: ; // `continue` out of the inner loop
         }
     }
 
-    bool can_uniform = gpu && gpu->limits.max_variable_comps >= size * params->comps;
+    // Reserve some headroom for other uniforms (matrices, textures coords, etc)
+    // This is especially important for GL 2.0 where max_variable_comps may be small
+    const size_t uniform_headroom = 256;
+    bool can_uniform = gpu && gpu->limits.max_variable_comps >= size * params->comps + uniform_headroom;
     bool can_literal = sh_glsl(sh).version > 110; // needed for literal arrays
     can_literal &= size <= SH_LUT_MAX_LITERAL_HARD && !params->dynamic;
 
diff --git a/src/shaders/sampling.c b/src/shaders/sampling.c
index 55e966d4..edcb1dfa 100644
--- a/src/shaders/sampling.c
+++ b/src/shaders/sampling.c
@@ -317,6 +317,12 @@ bool pl_shader_sample_bilinear(pl_shader sh, const struct pl_sample_src *src)
 
 bool pl_shader_sample_bicubic(pl_shader sh, const struct pl_sample_src *src)
 {
+    // GLSL < 130 doesn't support textureSize() needed by this shader
+    if (sh_glsl(sh).version < 130) {
+        PL_DEBUG(sh, "GLSL < 130: bicubic sampling unavailable, using fallback");
+        return false;
+    }
+
     ident_t tex, pos, pt;
     float rx, ry, scale;
     if (!setup_src(sh, src, &tex, &pos, &pt, &rx, &ry, NULL, &scale, true, LINEAR))
@@ -365,6 +371,12 @@ bool pl_shader_sample_bicubic(pl_shader sh, const struct pl_sample_src *src)
 
 bool pl_shader_sample_hermite(pl_shader sh, const struct pl_sample_src *src)
 {
+    // GLSL < 130 doesn't support textureSize() needed by this shader
+    if (sh_glsl(sh).version < 130) {
+        PL_DEBUG(sh, "GLSL < 130: hermite sampling unavailable, using fallback");
+        return false;
+    }
+
     ident_t tex, pos, pt;
     float rx, ry, scale;
     if (!setup_src(sh, src, &tex, &pos, &pt, &rx, &ry, NULL, &scale, true, LINEAR))
@@ -391,6 +403,12 @@ bool pl_shader_sample_hermite(pl_shader sh, const struct pl_sample_src *src)
 
 bool pl_shader_sample_gaussian(pl_shader sh, const struct pl_sample_src *src)
 {
+    // GLSL < 130 doesn't support textureSize() needed by this shader
+    if (sh_glsl(sh).version < 130) {
+        PL_DEBUG(sh, "GLSL < 130: gaussian sampling unavailable, using fallback");
+        return false;
+    }
+
     ident_t tex, pos, pt;
     float rx, ry, scale;
     if (!setup_src(sh, src, &tex, &pos, &pt, &rx, &ry, NULL, &scale, true, LINEAR))
@@ -436,6 +454,12 @@ bool pl_shader_sample_gaussian(pl_shader sh, const struct pl_sample_src *src)
 bool pl_shader_sample_oversample(pl_shader sh, const struct pl_sample_src *src,
                                  float threshold)
 {
+    // GLSL < 130 doesn't support textureSize() needed by this shader
+    if (sh_glsl(sh).version < 130) {
+        PL_DEBUG(sh, "GLSL < 130: oversample unavailable, using fallback");
+        return false;
+    }
+
     ident_t tex, pos, pt;
     float rx, ry, scale;
     if (!setup_src(sh, src, &tex, &pos, &pt, &rx, &ry, NULL, &scale, true, LINEAR))
@@ -593,6 +617,12 @@ bool pl_shader_sample_polar(pl_shader sh, const struct pl_sample_src *src,
         return false;
     }
 
+    // GLSL < 130 doesn't support textureSize() needed by this shader
+    if (sh_glsl(sh).version < 130) {
+        PL_DEBUG(sh, "GLSL < 130: polar sampling unavailable, using fallback");
+        return false;
+    }
+
     uint8_t cmask;
     float rx, ry, scalef;
     ident_t src_tex, pos, pt, scale;
@@ -956,6 +986,12 @@ bool pl_shader_sample_ortho2(pl_shader sh, const struct pl_sample_src *src,
         return false;
     }
 
+    // GLSL < 130 doesn't support uint types and textureSize() needed by this shader
+    if (sh_glsl(sh).version < 130) {
+        PL_DEBUG(sh, "GLSL < 130: ortho sampling unavailable, using fallback");
+        return false;
+    }
+
     pl_gpu gpu = SH_GPU(sh);
     pl_assert(gpu);
 
@@ -1112,6 +1148,15 @@ void pl_shader_distort(pl_shader sh, pl_tex src_tex, int out_w, int out_h,
     if (!sh_require(sh, PL_SHADER_SIG_NONE, out_w, out_h))
         return;
 
+    // GLSL < 130: Disable bicubic mode (needs textureSize()), fall back to bilinear
+    struct pl_distort_params compat_params;
+    if (sh_glsl(sh).version < 130 && params->bicubic) {
+        PL_DEBUG(sh, "GLSL < 130: distort bicubic unavailable, using bilinear");
+        compat_params = *params;
+        compat_params.bicubic = false;
+        params = &compat_params;
+    }
+
     const int src_w = src_tex->params.w, src_h = src_tex->params.h;
     float rx = 1.0f, ry = 1.0f;
     if (src_w > src_h) {


diff --git a/src/gpu.c b/src/gpu.c
index 130979f..cba23ef 100644
--- a/src/gpu.c
+++ b/src/gpu.c
@@ -303,6 +303,11 @@ bool pl_tex_recreate(pl_gpu gpu, pl_tex *tex, const struct pl_tex_params *params
         return true;
     }
 
+    if (!params->format) {
+        PL_ERR(gpu, "pl_tex_recreate: no texture format specified!");
+        return false;
+    }
+
     PL_DEBUG(gpu, "(Re)creating %dx%dx%d texture with format %s: %s",
              params->w, params->h, params->d, params->format->name,
              PL_DEF(params->debug_tag, "unknown"));
@@ -315,7 +320,13 @@ bool pl_tex_recreate(pl_gpu gpu, pl_tex *tex, const struct pl_tex_params *params
 
 void pl_tex_clear_ex(pl_gpu gpu, pl_tex dst, const union pl_clear_color color)
 {
-    require(dst->params.blit_dst);
+    // Also accept renderable targets: the GL backend clears via glClear()
+    // on a bound FBO, which needs no blit support. Required for the wrapped
+    // default framebuffer on GL 2.x, where gl_fb_query() cannot query the
+    // format (no ARB_framebuffer_object) and thus never sets
+    // PL_FMT_CAP_BLITTABLE, leaving blit_dst unset on the swapchain -- which
+    // made every letterbox clear fail (garbage bars around the video).
+    require(dst->params.blit_dst || dst->params.renderable);
 
     const struct pl_gpu_fns *impl = PL_PRIV(gpu);
     if (impl->tex_invalidate)
diff --git a/src/opengl/formats.c b/src/opengl/formats.c
index 7419b81..9d4a78e 100644
--- a/src/opengl/formats.c
+++ b/src/opengl/formats.c
@@ -545,6 +545,9 @@ bool gl_setup_formats(struct pl_gpu_t *gpu)
         } else {
             // Fallback for GL 2.1 without full extension support
             DO_FORMATS(formats_legacy_gl2);
+            // GL_BGRA as external format is core since GL 1.2; internal
+            // format is plain GL_RGBA8. Needed for mpv image subs/OSD.
+            DO_FORMATS(formats_bgra8);
             if (has_texture_rg) {
                 DO_FORMATS(formats_norm8_rg);
             } else {
@@ -595,6 +598,9 @@ bool gl_setup_formats(struct pl_gpu_t *gpu)
         bool has_texture_rg = pl_opengl_has_ext(p->gl, "GL_ARB_texture_rg");
         PL_INFO(gpu, "Using GL 2.0 format path, has_texture_rg=%d", has_texture_rg);
         DO_FORMATS(formats_legacy_gl2);
+        // GL_BGRA as external format is core since GL 1.2; internal
+        // format is plain GL_RGBA8. Needed for mpv image subs/OSD.
+        DO_FORMATS(formats_bgra8);
         if (has_texture_rg) {
             // Modern single/dual-component formats (r8/rg8 only, not rgb8/rgba8)
             DO_FORMATS(formats_norm8_rg);
diff --git a/src/shaders/colorspace.c b/src/shaders/colorspace.c
index 29b0d97..31e9648 100644
--- a/src/shaders/colorspace.c
+++ b/src/shaders/colorspace.c
@@ -323,7 +323,7 @@ void pl_shader_decode_color(pl_shader sh, struct pl_color_repr *repr,
         GLSL("// constant luminance conversion                              \n"
              "color.br = color.br * mix(vec2(1.5816, 0.9936),               \n"
              "                          vec2(1.9404, 1.7184),               \n"
-             "                          lessThanEqual(color.br, vec2(0.0))) \n"
+             "                          vec2(lessThanEqual(color.br, vec2(0.0)))) \n"
              "           + color.gg;                                        \n");
         // Expand channels to camera-linear light. This shader currently just
         // assumes everything uses the BT.2020 12-bit gamma function, since the
@@ -332,7 +332,7 @@ void pl_shader_decode_color(pl_shader sh, struct pl_color_repr *repr,
         GLSL("vec3 lin = mix(color.rgb * vec3(1.0/4.5),                        \n"
              "                pow((color.rgb + vec3(0.0993))*vec3(1.0/1.0993), \n"
              "                    vec3(1.0/0.45)),                             \n"
-             "                lessThanEqual(vec3(0.08145), color.rgb));        \n");
+             "                vec3(lessThanEqual(vec3(0.08145), color.rgb)));        \n");
         // Calculate the green channel from the expanded RYcB, and recompress to G'
         // The BT.2020 specification says Yc = 0.2627*R + 0.6780*G + 0.0593*B
         GLSL("color.g = (lin.g - 0.2627*lin.r - 0.0593*lin.b)*1.0/0.6780;   \n"
@@ -376,7 +376,7 @@ void pl_shader_decode_color(pl_shader sh, struct pl_color_repr *repr,
              "color.rgb = mix(vec3(4.0) * color.rgb * color.rgb,                \n"
              "                exp((color.rgb - vec3(%f)) * vec3(1.0/%f))        \n"
              "                    + vec3(%f),                                   \n"
-             "                lessThan(vec3(0.5), color.rgb));                  \n"
+             "                vec3(lessThan(vec3(0.5), color.rgb)));                  \n"
              // LMS matrix
              "color.rgb = mat3( 3.43661, -0.79133, -0.0259499,                  \n"
              "                 -2.50645,  1.98360, -0.0989137,                  \n"
@@ -384,7 +384,7 @@ void pl_shader_decode_color(pl_shader sh, struct pl_color_repr *repr,
             // HLG OETF
              "color.rgb = mix(vec3(0.5) * sqrt(color.rgb),                      \n"
              "                vec3(%f) * log(color.rgb - vec3(%f)) + vec3(%f),  \n"
-             "                lessThan(vec3(1.0), color.rgb));                  \n",
+             "                vec3(lessThan(vec3(1.0), color.rgb)));                  \n",
              HLG_C, HLG_A, HLG_B,
              HLG_A, HLG_B, HLG_C);
         break;
@@ -477,7 +477,7 @@ void pl_shader_encode_color(pl_shader sh, const struct pl_color_repr *repr)
         GLSL("vec3 lin = mix(color.rgb * vec3(1.0/4.5),                        \n"
              "                pow((color.rgb + vec3(0.0993))*vec3(1.0/1.0993), \n"
              "                    vec3(1.0/0.45)),                             \n"
-             "                lessThanEqual(vec3(0.08145), color.rgb));        \n");
+             "                vec3(lessThanEqual(vec3(0.08145), color.rgb)));        \n");
 
         // Compute Yc from RGB and compress to R'Y'cB'
         GLSL("color.g = dot(vec3(0.2627, 0.6780, 0.0593), lin);     \n"
@@ -489,7 +489,7 @@ void pl_shader_encode_color(pl_shader sh, const struct pl_color_repr *repr)
         GLSL("color.br = color.br - color.gg;                       \n"
              "color.br *= mix(vec2(1.0/1.5816, 1.0/0.9936),         \n"
              "                vec2(1.0/1.9404, 1.0/1.7184),         \n"
-             "                lessThanEqual(color.br, vec2(0.0)));  \n");
+             "                vec2(lessThanEqual(color.br, vec2(0.0))));  \n");
         break;
 
     case PL_COLOR_SYSTEM_BT_2100_PQ:;
@@ -512,13 +512,13 @@ void pl_shader_encode_color(pl_shader sh, const struct pl_color_repr *repr)
         GLSL("color.rgb = mix(vec3(4.0) * color.rgb * color.rgb,                \n"
              "                exp((color.rgb - vec3(%f)) * vec3(1.0/%f))        \n"
              "                    + vec3(%f),                                   \n"
-             "                lessThan(vec3(0.5), color.rgb));                  \n"
+             "                vec3(lessThan(vec3(0.5), color.rgb)));                  \n"
              "color.rgb = mat3(0.412109, 0.166748, 0.024170,                    \n"
              "                 0.523925, 0.720459, 0.075440,                    \n"
              "                 0.063965, 0.112793, 0.900394) * color.rgb;       \n"
              "color.rgb = mix(vec3(0.5) * sqrt(color.rgb),                      \n"
              "                vec3(%f) * log(color.rgb - vec3(%f)) + vec3(%f),  \n"
-             "                lessThan(vec3(1.0), color.rgb));                  \n",
+             "                vec3(lessThan(vec3(1.0), color.rgb)));                  \n",
              HLG_C, HLG_A, HLG_B,
              HLG_A, HLG_B, HLG_C);
         break;
@@ -616,7 +616,7 @@ void pl_shader_linearize(pl_shader sh, const struct pl_color_space *csp)
         GLSL("color.rgb = mix(color.rgb * vec3(1.0/12.92),               \n"
              "                pow((color.rgb + vec3(0.055))/vec3(1.055), \n"
              "                    vec3(2.4)),                            \n"
-             "                lessThan(vec3(0.04045), color.rgb));       \n");
+             "                vec3(lessThan(vec3(0.04045), color.rgb)));       \n");
         goto scale_out;
     case PL_COLOR_TRC_BT_1886: {
         const float lb = powf(csp_min, 1/2.4f);
@@ -649,7 +649,7 @@ void pl_shader_linearize(pl_shader sh, const struct pl_color_space *csp)
     case PL_COLOR_TRC_PRO_PHOTO:
         GLSL("color.rgb = mix(color.rgb * vec3(1.0/16.0),              \n"
              "                pow(color.rgb, vec3(1.8)),               \n"
-             "                lessThan(vec3(0.03125), color.rgb));     \n");
+             "                vec3(lessThan(vec3(0.03125), color.rgb)));     \n");
         goto scale_out;
     case PL_COLOR_TRC_ST428:
         GLSL("color.rgb = vec3(52.37/48.0) * pow(color.rgb, vec3(2.6));\n");
@@ -672,7 +672,7 @@ void pl_shader_linearize(pl_shader sh, const struct pl_color_space *csp)
              "color.rgb = mix(vec3(4.0) * color.rgb * color.rgb,        \n"
              "                exp((color.rgb - vec3(%f)) * vec3(1.0/%f))\n"
              "                    + vec3(%f),                           \n"
-             "                lessThan(vec3(0.5), color.rgb));          \n",
+             "                vec3(lessThan(vec3(0.5), color.rgb)));          \n",
              SH_FLOAT(1 - b), SH_FLOAT(b),
              HLG_C, HLG_A, HLG_B);
         // OOTF
@@ -685,7 +685,7 @@ void pl_shader_linearize(pl_shader sh, const struct pl_color_space *csp)
         GLSL("color.rgb = mix((color.rgb - vec3(0.125)) * vec3(1.0/5.6), \n"
              "    pow(vec3(10.0), (color.rgb - vec3(%f)) * vec3(1.0/%f)) \n"
              "              - vec3(%f),                                  \n"
-             "    lessThanEqual(vec3(0.181), color.rgb));                \n",
+             "    vec3(lessThanEqual(vec3(0.181), color.rgb)));                \n",
              VLOG_D, VLOG_C, VLOG_B);
         return;
     case PL_COLOR_TRC_S_LOG1:
@@ -697,7 +697,7 @@ void pl_shader_linearize(pl_shader sh, const struct pl_color_space *csp)
         GLSL("color.rgb = mix((color.rgb - vec3(%f)) * vec3(1.0/%f),      \n"
              "    (pow(vec3(10.0), (color.rgb - vec3(%f)) * vec3(1.0/%f)) \n"
              "              - vec3(%f)) * vec3(1.0/%f),                   \n"
-             "    lessThanEqual(vec3(%f), color.rgb));                    \n",
+             "    vec3(lessThanEqual(vec3(%f), color.rgb)));                    \n",
              SLOG_Q, SLOG_P, SLOG_C, SLOG_A, SLOG_B, SLOG_K2, SLOG_Q);
         return;
     case PL_COLOR_TRC_LINEAR:
@@ -748,7 +748,7 @@ void pl_shader_delinearize(pl_shader sh, const struct pl_color_space *csp)
         GLSL("color.rgb = mix(color.rgb * vec3(12.92),                        \n"
              "                vec3(1.055) * pow(color.rgb, vec3(1.0/2.4))     \n"
              "                    - vec3(0.055),                              \n"
-             "                lessThanEqual(vec3(0.0031308), color.rgb));     \n");
+             "                vec3(lessThanEqual(vec3(0.0031308), color.rgb)));     \n");
         return;
     case PL_COLOR_TRC_BT_1886: {
         const float lb = powf(csp_min, 1/2.4f);
@@ -784,7 +784,7 @@ void pl_shader_delinearize(pl_shader sh, const struct pl_color_space *csp)
     case PL_COLOR_TRC_PRO_PHOTO:
         GLSL("color.rgb = mix(color.rgb * vec3(16.0),                        \n"
              "                pow(color.rgb, vec3(1.0/1.8)),                 \n"
-             "                lessThanEqual(vec3(0.001953), color.rgb));     \n");
+             "                vec3(lessThanEqual(vec3(0.001953), color.rgb)));     \n");
         return;
     case PL_COLOR_TRC_PQ:
         GLSL("color.rgb *= vec3(1.0/%f);                         \n"
@@ -804,7 +804,7 @@ void pl_shader_delinearize(pl_shader sh, const struct pl_color_space *csp)
         // OETF
         GLSL("color.rgb = mix(vec3(0.5) * sqrt(color.rgb),                      \n"
              "                vec3(%f) * log(color.rgb - vec3(%f)) + vec3(%f),  \n"
-             "                lessThan(vec3(1.0), color.rgb));                  \n"
+             "                vec3(lessThan(vec3(1.0), color.rgb)));                  \n"
              "color.rgb = "$" * color.rgb + vec3("$");                          \n",
              HLG_A, HLG_B, HLG_C,
              SH_FLOAT(1 / (1 - b)), SH_FLOAT(-b / (1 - b)));
@@ -814,7 +814,7 @@ void pl_shader_delinearize(pl_shader sh, const struct pl_color_space *csp)
         GLSL("color.rgb = mix(vec3(5.6) * color.rgb + vec3(0.125),       \n"
              "                vec3(%f) * log(color.rgb + vec3(%f))       \n"
              "                    + vec3(%f),                            \n"
-             "                lessThanEqual(vec3(0.01), color.rgb));     \n",
+             "                vec3(lessThanEqual(vec3(0.01), color.rgb)));     \n",
              VLOG_C / M_LN10, VLOG_B, VLOG_D);
         return;
     case PL_COLOR_TRC_S_LOG1:
@@ -825,7 +825,7 @@ void pl_shader_delinearize(pl_shader sh, const struct pl_color_space *csp)
         GLSL("color.rgb = mix(vec3(%f) * color.rgb + vec3(%f),                \n"
              "                vec3(%f) * log(vec3(%f) * color.rgb + vec3(%f)) \n"
              "                    + vec3(%f),                                 \n"
-             "                lessThanEqual(vec3(0.0), color.rgb));           \n",
+             "                vec3(lessThanEqual(vec3(0.0), color.rgb)));           \n",
              SLOG_P, SLOG_Q, SLOG_A / M_LN10, SLOG_K2, SLOG_B, SLOG_C);
         return;
     case PL_COLOR_TRC_LINEAR:
diff --git a/src/shaders/dithering.c b/src/shaders/dithering.c
index df6ca58..120ba9b 100644
--- a/src/shaders/dithering.c
+++ b/src/shaders/dithering.c
@@ -273,7 +273,7 @@ done: ;
         }
 
         // Mix in the correct ratio corresponding to the offset and bias
-        GLSL("color = mix(low, high, greaterThan(offset, vec4(bias))); \n");
+        GLSL("color = mix(low, high, vec4(greaterThan(offset, vec4(bias)))); \n");
     } else {
         // Approximate each gamma segment as a straight line, this simplifies
         // the process of dithering down to a single scale and (biased) round.
diff --git a/src/shaders/lut.c b/src/shaders/lut.c
index 6ba5684..a6ddf15 100644
--- a/src/shaders/lut.c
+++ b/src/shaders/lut.c
@@ -818,6 +818,16 @@ next_dim: ; // `continue` out of the inner loop
 
 error:
     lut->error = true;
+    // Record the parameters of the failed attempt, so the `update` check
+    // above suppresses re-attempting (and re-logging the error) on every
+    // subsequent invocation with unchanged parameters
+    lut->vartype = vartype;
+    lut->fmt = params->fmt;
+    lut->width = params->width;
+    lut->height = params->height;
+    lut->depth = params->depth;
+    lut->comps = params->comps;
+    lut->signature = params->signature;
     pl_cache_obj_free(&obj);
     return NULL_IDENT;
 }


Add 16-bit luminance texture formats to the legacy GL2 fallback paths.

Fixes broken colors (green image, orange blow-outs) with 10-bit video
(HEVC Main 10 etc.) on vo=gpu-next / big-endian PowerPC.

Without any 16-bit 1/2-component texture format, mpv's vo_gpu_next
rejects every >8-bit YUV format (pl_plane_find_fmt finds no fmt with
texel_size 2 / host_bits {16}), so the player converts the frame with
libswscale first (FFmpeg's format scoring picks rgb48). That conversion
path mangles the frame on big-endian hosts. mpv's vo=gpu is unaffected
because its own ra_gl GL2 table has "l16"/"la16" (GL_LUMINANCE16) and
uploads 10-bit planes natively.

This gives vo_gpu_next the same native path: GL_UNSIGNED_SHORT client
data is interpreted in host byte order, so big-endian planes upload
correctly with no CPU conversion (also a sizeable perf win for 4K
10-bit on G4/G5). Old GPUs may internally store these at 8 bits per
component; that costs precision only, not correctness — identical to
what vo=gpu already does on the same driver.

--- a/src/opengl/formats.c	2026-08-04 18:42:52.560327974 +0000
+++ b/src/opengl/formats.c	2026-08-04 18:20:17.871444669 +0000
@@ -27,6 +27,12 @@
 #ifndef GL_LUMINANCE_ALPHA
 #define GL_LUMINANCE_ALPHA 0x190A
 #endif
+#ifndef GL_LUMINANCE16
+#define GL_LUMINANCE16 0x8042
+#endif
+#ifndef GL_LUMINANCE16_ALPHA16
+#define GL_LUMINANCE16_ALPHA16 0x8048
+#endif
 
 #ifdef PL_HAVE_UNIX
 static bool supported_fourcc(struct pl_gl *p, EGLint fourcc)
@@ -248,6 +254,13 @@
     // Note: NO 'F' flag - these cannot be FBO attachments
     {GL_LUMINANCE,      GL_LUMINANCE,      GL_UNSIGNED_BYTE,  FMT("luminance",       8, UNORM, S|L|V)},
     {GL_LUMINANCE_ALPHA,GL_LUMINANCE_ALPHA,GL_UNSIGNED_BYTE,  FMT("luminance_alpha", 8, UNORM, S|L|V)},
+    // 16-bit variants: needed for native upload of >8-bit video planes
+    // (yuv420p10 = HEVC Main 10, etc.). Without them mpv vo_gpu_next
+    // rejects 10-bit YUV and falls back to a libswscale conversion that
+    // is broken on big-endian hosts. GL_UNSIGNED_SHORT is host-byte-order,
+    // and this is the same internal format mpv vo_gpu uses ("l16"/"la16").
+    {GL_LUMINANCE16,    GL_LUMINANCE,      GL_UNSIGNED_SHORT, FMT("luminance16",       16, UNORM, S|L|V)},
+    {GL_LUMINANCE16_ALPHA16,GL_LUMINANCE_ALPHA,GL_UNSIGNED_SHORT, FMT("luminance_alpha16", 16, UNORM, S|L|V)},
 };
 
 // GLES2 legacy formats
