diff --git a/meson.build b/meson.build
index 46cf6d058820236d8ff29c98706be5760b44ec6d..ab5c46eacd4daea479640645c058e824fc9d601f 100644
--- a/meson.build
+++ b/meson.build
@@ -2,7 +2,7 @@ project('libplacebo', ['c', 'cpp'],
   license: 'LGPL2.1+',
   default_options: ['c_std=c99', 'cpp_std=c++11', 'warning_level=2'],
   meson_version: '>=0.49',
-  version: '2.64.0',
+  version: '2.65.0',
 )
 
 # Version number
diff --git a/src/include/libplacebo/shaders.h b/src/include/libplacebo/shaders.h
index d5d3e98765ff8759bf3d02edd14db723d82ab44b..cb2d80b11a65f32853f4a9bf65acefcce32f524e 100644
--- a/src/include/libplacebo/shaders.h
+++ b/src/include/libplacebo/shaders.h
@@ -118,6 +118,9 @@ uint64_t pl_shader_signature(const struct pl_shader *sh);
 enum pl_shader_sig {
     PL_SHADER_SIG_NONE = 0, // no input / void output
     PL_SHADER_SIG_COLOR,    // vec4 color (normalized so that 1.0 is the ref white)
+
+    // The following are only valid as input signatures:
+    PL_SHADER_SIG_SAMPLER2D, // (sampler2D src_tex, vec2 tex_coord) pair
 };
 
 // Represents a finalized shader fragment. This is not a complete shader, but a
diff --git a/src/include/libplacebo/shaders/sampling.h b/src/include/libplacebo/shaders/sampling.h
index bc208cdf3b5c0444af33e293930ee829e37e2e7a..8a3a41f82158fa0056f0ee00b070b68294a155cc 100644
--- a/src/include/libplacebo/shaders/sampling.h
+++ b/src/include/libplacebo/shaders/sampling.h
@@ -29,11 +29,26 @@
 
 // Common parameters for sampling operations
 struct pl_sample_src {
+    // There are two mutually exclusive ways of providing the source to sample
+    // from:
+    //
+    // 1. Provide the texture and sampled region directly. This generates
+    // a shader with input signature `PL_SHADER_SIG_NONE`, which binds the
+    // texture as a descriptor (and the coordinates as a vertex attribute)
     const struct pl_tex *tex; // texture to sample
     struct pl_rect2df rect;   // sub-rect to sample from (optional)
-    int components;           // number of components to sample (optional)
-    int new_w, new_h;         // dimensions of the resulting output (optional)
-    float scale;              // factor to multiply into sampled signal (optional)
+
+    // 2. Have the shader take it as an argument. Doing this requires
+    // specifying the missing metadata of the texture backing the sampler, so
+    // that the shader generation can generate the correct code. In particular,
+    // the important fields include the texture dimensions and the sample mode.
+    struct pl_tex_params sampler_params;
+    float sampled_w, sampled_h; // dimensions of the sampled region (optional)
+
+    // Common metadata for both sampler input types:
+    int components;   // number of components to sample (optional)
+    int new_w, new_h; // dimensions of the resulting output (optional)
+    float scale;      // factor to multiply into sampled signal (optional)
 };
 
 struct pl_deband_params {
diff --git a/src/shaders.c b/src/shaders.c
index 2584c49f42652cabfc6926dca7ee15e396c63cb7..6a4137179169d8d8451d5763a7d8a76dce781c56 100644
--- a/src/shaders.c
+++ b/src/shaders.c
@@ -345,16 +345,17 @@ void pl_shader_append_bstr(struct pl_shader *sh, enum pl_shader_buf buf,
     bstr_xappend(sh, &sh->buffers[buf], str);
 }
 
+static const char *insigs[] = {
+    [PL_SHADER_SIG_NONE]        = "",
+    [PL_SHADER_SIG_COLOR]       = "vec4 color",
+    [PL_SHADER_SIG_SAMPLER2D]   = "sampler2D src_tex, vec2 tex_coord",
+};
+
 static const char *outsigs[] = {
     [PL_SHADER_SIG_NONE]  = "void",
     [PL_SHADER_SIG_COLOR] = "vec4",
 };
 
-static const char *insigs[] = {
-    [PL_SHADER_SIG_NONE]  = "",
-    [PL_SHADER_SIG_COLOR] = "vec4 color",
-};
-
 static const char *retvals[] = {
     [PL_SHADER_SIG_NONE]  = "",
     [PL_SHADER_SIG_COLOR] = "return color;",
@@ -874,7 +875,8 @@ next_dim: ; // `continue` out of the inner loop
             pos_macros[i] = sh_lut_pos(sh, sizes[i]);
 
         GLSLH("#define %s(pos) (%s(%s, %s(\\\n",
-              name, sh_tex_fn(sh, lut->weights.tex), tex, types[texdim - 1]);
+              name, sh_tex_fn(sh, lut->weights.tex->params),
+              tex, types[texdim - 1]);
 
         for (int i = 0; i < texdim; i++) {
             char sep = i == 0 ? ' ' : ',';
@@ -953,15 +955,3 @@ const char *sh_bvec(const struct pl_shader *sh, int dims)
     pl_assert(dims > 0 && dims < PL_ARRAY_SIZE(bvecs));
     return sh_glsl(sh).version >= 130 ? bvecs[dims] : vecs[dims];
 }
-
-const char *sh_tex_fn(const struct pl_shader *sh, const struct pl_tex *tex)
-{
-    static const char *suffixed[] = {
-        [1] = "texture1D",
-        [2] = "texture2D",
-        [3] = "texture3D",
-    };
-
-    int dims = pl_tex_params_dimension(tex->params);
-    return sh_glsl(sh).version >= 130 ? "texture" : suffixed[dims];
-}
diff --git a/src/shaders.h b/src/shaders.h
index 3c7b3d20d4e59174ef9673b64f20a6c1b38ff451..ee969f0e70951ef6e8d6efe59255102520041cb0 100644
--- a/src/shaders.h
+++ b/src/shaders.h
@@ -209,4 +209,15 @@ const char *sh_bvec(const struct pl_shader *sh, int dims);
 
 // Returns the appropriate `texture`-equivalent function for the shader and
 // given texture.
-const char *sh_tex_fn(const struct pl_shader *sh, const struct pl_tex *tex);
+static inline const char *sh_tex_fn(const struct pl_shader *sh,
+                                    const struct pl_tex_params params)
+{
+    static const char *suffixed[] = {
+        [1] = "texture1D",
+        [2] = "texture2D",
+        [3] = "texture3D",
+    };
+
+    int dims = pl_tex_params_dimension(params);
+    return sh_glsl(sh).version >= 130 ? "texture" : suffixed[dims];
+}
diff --git a/src/shaders/custom.c b/src/shaders/custom.c
index 33056c01d6035089c9590b67dceffd89c98c2b78..2c5a26fda8fc7ae38005c4de001f7bf724d887a0 100644
--- a/src/shaders/custom.c
+++ b/src/shaders/custom.c
@@ -778,7 +778,7 @@ static bool bind_pass_tex(struct pl_shader *sh, struct bstr name,
 
     // Sampling function boilerplate
     GLSLH("#define %.*s_tex(pos) (%f * vec4(%s(%s, pos))) \n",
-          BSTR_P(name), scale, sh_tex_fn(sh, ptex->tex), id);
+          BSTR_P(name), scale, sh_tex_fn(sh, ptex->tex->params), id);
     GLSLH("#define %.*s_texOff(off) (%.*s_tex(%s + %s * vec2(off))) \n",
           BSTR_P(name), BSTR_P(name), pos, pt);
 
@@ -936,7 +936,7 @@ static struct pl_hook_res hook_hook(void *priv, const struct pl_hook_params *par
                     GLSLH("#define %.*s %s \n", BSTR_P(texname), id);
                     GLSLH("#define %.*s_tex(pos) (%s(%s, pos)) \n",
                           BSTR_P(texname),
-                          sh_tex_fn(sh, p->lut_textures[j].tex), id);
+                          sh_tex_fn(sh, p->lut_textures[j].tex->params), id);
                     goto next_bind;
                 }
             }
diff --git a/src/shaders/sampling.c b/src/shaders/sampling.c
index 9625e2e9f06fb1440d4a32c93b2255311d87e948..63dd68f5b31700ed600d4fbb26a0a94f9d2d26f8 100644
--- a/src/shaders/sampling.c
+++ b/src/shaders/sampling.c
@@ -25,20 +25,38 @@ const struct pl_deband_params pl_deband_default_params = {
     .grain      = 6.0,
 };
 
+static inline struct pl_tex_params src_params(const struct pl_sample_src *src)
+{
+    return src->tex ? src->tex->params : src->sampler_params;
+}
+
 // Helper function to compute the src/dst sizes and upscaling ratios
 static bool setup_src(struct pl_shader *sh, const struct pl_sample_src *src,
                       ident_t *src_tex, ident_t *pos, ident_t *size, ident_t *pt,
                       float *ratio_x, float *ratio_y, int *components,
                       float *scale, bool resizeable, const char **fn)
 {
-    pl_assert(pl_tex_params_dimension(src->tex->params) == 2);
-    float src_w = pl_rect_w(src->rect);
-    float src_h = pl_rect_h(src->rect);
-    src_w = PL_DEF(src_w, src->tex->params.w);
-    src_h = PL_DEF(src_h, src->tex->params.h);
+    pl_assert(pl_tex_params_dimension(src_params(src)) == 2);
+
+    enum pl_shader_sig sig;
+    float src_w, src_h;
+    if (src->tex) {
+        sig = PL_SHADER_SIG_NONE;
+        src_w = pl_rect_w(src->rect);
+        src_h = pl_rect_h(src->rect);
+    } else {
+        sig = PL_SHADER_SIG_SAMPLER2D;
+        src_w = src->sampled_w;
+        src_h = src->sampled_h;
+    }
+
+    src_w = PL_DEF(src_w, src_params(src).w);
+    src_h = PL_DEF(src_h, src_params(src).h);
+    pl_assert(src_w && src_h);
 
     int out_w = PL_DEF(src->new_w, roundf(fabs(src_w)));
     int out_h = PL_DEF(src->new_h, roundf(fabs(src_h)));
+    pl_assert(out_w && out_h);
 
     if (ratio_x)
         *ratio_x = out_w / fabs(src_w);
@@ -48,33 +66,60 @@ static bool setup_src(struct pl_shader *sh, const struct pl_sample_src *src,
         *scale = PL_DEF(src->scale, 1.0);
 
     if (components) {
-        const struct pl_fmt *fmt = src->tex->params.format;
-        *components = PL_DEF(src->components, fmt->num_components);
+        int tex_comps = src_params(src).format->num_components;
+        *components = PL_DEF(src->components, tex_comps);
     }
 
     if (resizeable)
         out_w = out_h = 0;
-    if (!sh_require(sh, PL_SHADER_SIG_NONE, out_w, out_h))
+    if (!sh_require(sh, sig, out_w, out_h))
         return false;
 
-    struct pl_rect2df rect = {
-        .x0 = src->rect.x0,
-        .y0 = src->rect.y0,
-        .x1 = src->rect.x0 + src_w,
-        .y1 = src->rect.y0 + src_h,
-    };
+    if (src->tex) {
+        struct pl_rect2df rect = {
+            .x0 = src->rect.x0,
+            .y0 = src->rect.y0,
+            .x1 = src->rect.x0 + src_w,
+            .y1 = src->rect.y0 + src_h,
+        };
+
+        if (fn)
+            *fn = sh_tex_fn(sh, src->tex->params);
+
+        *src_tex = sh_bind(sh, src->tex, "src_tex", &rect, pos, size, pt);
+    } else {
+        int tex_w = src->sampler_params.w,
+            tex_h = src->sampler_params.h;
+        pl_assert(tex_w && tex_h);
+
+        if (size) {
+            *size = sh_var(sh, (struct pl_shader_var) {
+                .var = pl_var_vec2("tex_size"),
+                .data = &(float[2]) { tex_w, tex_h },
+            });
+        }
 
-    if (fn)
-        *fn = sh_tex_fn(sh, src->tex);
+        if (pt) {
+            *pt = sh_var(sh, (struct pl_shader_var) {
+                .var = pl_var_vec2("tex_pt"),
+                .data = &(float[2]) { 1.0 / tex_w, 1.0 / tex_h },
+            });
+        }
+
+        if (fn)
+            *fn = sh_tex_fn(sh, src->sampler_params);
+
+        *src_tex = "src_tex";
+        *pos = "tex_coord";
+    }
 
-    *src_tex = sh_bind(sh, src->tex, "src_tex", &rect, pos, size, pt);
     return true;
 }
 
 void pl_shader_deband(struct pl_shader *sh, const struct pl_sample_src *src,
                       const struct pl_deband_params *params)
 {
-    if (src->tex->params.sample_mode != PL_TEX_SAMPLE_LINEAR) {
+    if (src_params(src).sample_mode != PL_TEX_SAMPLE_LINEAR) {
         SH_FAIL(sh, "Debanding requires sample_mode = PL_TEX_SAMPLE_LINEAR!");
         return;
     }
@@ -174,7 +219,7 @@ static void bicubic_calcweights(struct pl_shader *sh, const char *t, const char
 
 bool pl_shader_sample_bicubic(struct pl_shader *sh, const struct pl_sample_src *src)
 {
-    if (src->tex->params.sample_mode != PL_TEX_SAMPLE_LINEAR) {
+    if (src_params(src).sample_mode != PL_TEX_SAMPLE_LINEAR) {
         SH_FAIL(sh, "Trying to use fast bicubic sampling from a texture without "
                 "PL_TEX_SAMPLE_LINEAR");
         return false;
@@ -313,16 +358,28 @@ bool pl_shader_sample_polar(struct pl_shader *sh,
     }
 
     const struct pl_gpu *gpu = SH_GPU(sh);
-    const struct pl_tex *tex = src->tex;
-    pl_assert(gpu && tex);
+    pl_assert(gpu);
 
     bool has_compute = gpu->caps & PL_GPU_CAP_COMPUTE && !params->no_compute;
+    if (!src->tex && has_compute) {
+        // FIXME: Could maybe solve this by communicating the wbase from
+        // invocation 0 to the rest of the workgroup using shmem, which would
+        // also allow us to avoid the use of the hacky %s_map below.
+        PL_WARN(sh, "Combining pl_shader_sample_polar with the sampler2D "
+                "interface prevents the use of compute shaders, which is a "
+                "potentially massive performance hit. If you're sure you want "
+                "this, set `params.no_compute` to suppress this warning.");
+        has_compute = false;
+    }
+
     bool flipped = src->rect.x0 > src->rect.x1 || src->rect.y0 > src->rect.y1;
     if (flipped && has_compute) {
+        // FIXME: I'm sure this case could actually be supported with some
+        // extra math in the positional calculations, should implement it
         PL_WARN(sh, "Trying to use a flipped src.rect with polar sampling! "
                 "This prevents the use of compute shaders, which is a "
                 "potentially massive performance hit. If you're really sure you "
-                "want this, set params.no_compute to suppress this warning.");
+                "want this, set `params.no_compute` to suppress this warning.");
         has_compute = false;
     }
 
@@ -514,18 +571,17 @@ bool pl_shader_sample_ortho(struct pl_shader *sh, int pass,
     }
 
     const struct pl_gpu *gpu = SH_GPU(sh);
-    const struct pl_tex *tex = src->tex;
-    pl_assert(gpu && tex);
+    pl_assert(gpu);
 
     struct pl_sample_src srcfix = *src;
     switch (pass) {
     case PL_SEP_VERT:
         srcfix.rect.x0 = 0;
-        srcfix.rect.x1 = srcfix.new_w = tex->params.w;
+        srcfix.rect.x1 = srcfix.new_w = src_params(src).w;
         break;
     case PL_SEP_HORIZ:
         srcfix.rect.y0 = 0;
-        srcfix.rect.y1 = srcfix.new_h = tex->params.h;
+        srcfix.rect.y1 = srcfix.new_h = src_params(src).h;
         break;
     case PL_SEP_PASSES:
     default:
diff --git a/src/tests/dummy.c b/src/tests/dummy.c
index 48ae80afb42d9f6cb9d5d69195de749b7bfd2d5b..1b84a204078660fb71d7299c810893b8517dda15 100644
--- a/src/tests/dummy.c
+++ b/src/tests/dummy.c
@@ -48,6 +48,16 @@ int main()
             printf("lut[%d] = %f\n", i, data[i]);
     }
 
+    // Try out generation of the sampler2D interface
+    src.sampler_params = dummy->params;
+    src.tex = NULL;
+
+    pl_shader_reset(sh, &(struct pl_shader_params) { .gpu = gpu });
+    REQUIRE(pl_shader_sample_polar(sh, &src, &filter_params));
+    REQUIRE((res = pl_shader_finalize(sh)));
+    REQUIRE(res->input == PL_SHADER_SIG_SAMPLER2D);
+    printf("generated sampler2D shader:\n\n%s\n", res->glsl);
+
     pl_shader_free(&sh);
     pl_shader_obj_destroy(&lut);
     pl_tex_destroy(gpu, &dummy);