Shader constants
diff --git a/sparse_strips/vello_hybrid/Cargo.toml b/sparse_strips/vello_hybrid/Cargo.toml
index 300cfc0..d5179ae 100644
--- a/sparse_strips/vello_hybrid/Cargo.toml
+++ b/sparse_strips/vello_hybrid/Cargo.toml
@@ -55,7 +55,7 @@
 wgpu_default = ["wgpu", "wgpu/default"]
 # Add support for text rendering.
 text = ["vello_common/text"]
-webgl = ["dep:js-sys", "dep:web-sys", "dep:vello_sparse_shaders", "vello_sparse_shaders/glsl"]
+webgl = ["dep:js-sys", "dep:web-sys", "dep:vello_sparse_shaders", "vello_sparse_shaders/gles"]
 
 [lints]
 workspace = true
diff --git a/sparse_strips/vello_hybrid/src/render/common.rs b/sparse_strips/vello_hybrid/src/render/common.rs
index 596ea12..75f95a1 100644
--- a/sparse_strips/vello_hybrid/src/render/common.rs
+++ b/sparse_strips/vello_hybrid/src/render/common.rs
@@ -39,14 +39,6 @@
     pub width: u32,
     /// Height of the rendering target.
     pub height: u32,
-    /// Height of a strip in the rendering.
-    pub strip_height: u32,
-    /// Number of trailing zeros in `alphas_tex_width` (log2 of width).
-    /// Pre-calculated on CPU since downlevel targets do not support `firstTrailingBit`.
-    pub alphas_tex_width_bits: u32,
-    /// Number of trailing zeros in the encoded paints texture width (log2 of width).
-    /// Pre-calculated on CPU since downlevel targets do not support `firstTrailingBit`.
-    pub encoded_paints_tex_width_bits: u32,
     /// A horizontal offset to apply to strips.
     pub strip_offset_x: i32,
     /// A vertical offset to apply to strips.
@@ -70,6 +62,8 @@
     /// and just apply the second negation manually in case we render to the final output surface
     /// in the WebGL backend.
     pub negate_ndc: u32,
+    /// Padding to ensure 16-byte alignment.
+    pub _padding: [u32; 3],
 }
 
 /// A GPU strip instance for rendering.
diff --git a/sparse_strips/vello_hybrid/src/render/webgl.rs b/sparse_strips/vello_hybrid/src/render/webgl.rs
index 371ce8c..1b98801 100644
--- a/sparse_strips/vello_hybrid/src/render/webgl.rs
+++ b/sparse_strips/vello_hybrid/src/render/webgl.rs
@@ -31,8 +31,8 @@
             GPU_ENCODED_IMAGE_SIZE_TEXELS, GPU_LINEAR_GRADIENT_SIZE_TEXELS,
             GPU_RADIAL_GRADIENT_SIZE_TEXELS, GPU_SWEEP_GRADIENT_SIZE_TEXELS, GpuEncodedImage,
             GpuEncodedPaint, GpuLinearGradient, GpuRadialGradient, GpuSweepGradient,
-            pack_image_offset, pack_image_params, pack_image_size, pack_radial_kind_and_swapped,
-            pack_texture_width_and_extend_mode, pack_tint,
+            pack_image_offset, pack_image_params, pack_image_size,
+            pack_radial_kind_and_swapped, pack_texture_width_and_extend_mode, pack_tint,
         },
     },
     scene::Scene,
@@ -56,7 +56,8 @@
     pixmap::Pixmap,
     tile::Tile,
 };
-use vello_sparse_shaders::{clear_slots, filters, render_strips};
+use vello_sparse_shaders::ShaderConstants;
+use vello_sparse_shaders::gles::{clear_slots, filters, render_strips};
 use web_sys::wasm_bindgen::{JsCast, JsValue};
 use web_sys::{
     WebGl2RenderingContext, WebGlBuffer, WebGlFramebuffer, WebGlProgram, WebGlTexture,
@@ -764,11 +765,17 @@
         filter_context: &FilterContext,
         slot_count: usize,
     ) -> Self {
-        let strip_program = create_shader_program(
-            &gl,
-            render_strips::VERTEX_SOURCE,
-            render_strips::FRAGMENT_SOURCE,
-        );
+        let max_texture_dimension_2d = get_max_texture_dimension_2d(&gl);
+        let shader_constants = ShaderConstants {
+            strip_height: u32::from(Tile::HEIGHT),
+            alphas_tex_width_bits: max_texture_dimension_2d.trailing_zeros(),
+            encoded_paints_tex_width_bits: max_texture_dimension_2d.trailing_zeros(),
+            gradient_tex_width_bits: max_texture_dimension_2d.trailing_zeros(),
+        };
+
+        let vs_source = render_strips::vertex_source(&shader_constants);
+        let fs_source = render_strips::fragment_source(&shader_constants);
+        let strip_program = create_shader_program(&gl, &vs_source, &fs_source);
         let clear_program = create_shader_program(
             &gl,
             clear_slots::VERTEX_SOURCE,
@@ -1097,12 +1104,10 @@
                 let config = Config {
                     width: new_render_size.width,
                     height: new_render_size.height,
-                    strip_height: u32::from(Tile::HEIGHT),
-                    alphas_tex_width_bits: max_texture_dimension_2d.trailing_zeros(),
-                    encoded_paints_tex_width_bits: max_texture_dimension_2d.trailing_zeros(),
                     strip_offset_x: 0,
                     strip_offset_y: 0,
                     negate_ndc: u32::from(negate_ndc),
+                    ..Config::zeroed()
                 };
 
                 gl.bind_buffer(
@@ -1123,13 +1128,11 @@
                 let slot_config = Config {
                     width: u32::from(WideTile::WIDTH),
                     height: u32::from(Tile::HEIGHT) * total_slots,
-                    strip_height: u32::from(Tile::HEIGHT),
-                    alphas_tex_width_bits: max_texture_dimension_2d.trailing_zeros(),
-                    encoded_paints_tex_width_bits: max_texture_dimension_2d.trailing_zeros(),
                     strip_offset_x: 0,
                     strip_offset_y: 0,
                     // Always use y-down when rendering to slots.
                     negate_ndc: 0,
+                    ..Config::zeroed()
                 };
 
                 gl.bind_buffer(
@@ -2024,20 +2027,10 @@
                 let config = Config {
                     width: atlas_width,
                     height: atlas_height,
-                    strip_height: u32::from(Tile::HEIGHT),
-                    alphas_tex_width_bits: self
-                        .programs
-                        .resources
-                        .max_texture_dimension_2d
-                        .trailing_zeros(),
-                    encoded_paints_tex_width_bits: self
-                        .programs
-                        .resources
-                        .max_texture_dimension_2d
-                        .trailing_zeros(),
                     strip_offset_x,
                     strip_offset_y,
                     negate_ndc: 0,
+                    ..Config::zeroed()
                 };
                 let buf = &self.programs.resources.filter_config_buffer;
                 self.gl
diff --git a/sparse_strips/vello_hybrid/src/render/wgpu.rs b/sparse_strips/vello_hybrid/src/render/wgpu.rs
index 3aa9419..482b4af 100644
--- a/sparse_strips/vello_hybrid/src/render/wgpu.rs
+++ b/sparse_strips/vello_hybrid/src/render/wgpu.rs
@@ -53,6 +53,7 @@
     pixmap::Pixmap,
     tile::Tile,
 };
+use vello_sparse_shaders::ShaderConstants;
 use wgpu::{
     BindGroup, BindGroupLayout, BlendState, Buffer, ColorTargetState, ColorWrites, CommandEncoder,
     Device, Extent3d, PipelineCompilationOptions, Queue, RenderPassColorAttachment,
@@ -1018,9 +1019,19 @@
                 }],
             });
 
+        let max_texture_dimension_2d = device.limits().max_texture_dimension_2d;
+        let shader_constants = ShaderConstants {
+            strip_height: Tile::HEIGHT.into(),
+            alphas_tex_width_bits: max_texture_dimension_2d.trailing_zeros(),
+            encoded_paints_tex_width_bits: max_texture_dimension_2d.trailing_zeros(),
+            gradient_tex_width_bits: max_texture_dimension_2d.trailing_zeros(),
+        };
+
         let strip_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
             label: Some("Strip Shader"),
-            source: wgpu::ShaderSource::Wgsl(vello_sparse_shaders::wgsl::RENDER_STRIPS.into()),
+            source: wgpu::ShaderSource::Wgsl(
+                vello_sparse_shaders::wgsl::render_strips(&shader_constants).into(),
+            ),
         });
 
         let clear_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
@@ -1310,10 +1321,8 @@
                 width: u32::from(WideTile::WIDTH),
                 height: u32::from(Tile::HEIGHT) * slot_count as u32,
             },
-            device.limits().max_texture_dimension_2d,
         );
 
-        let max_texture_dimension_2d = device.limits().max_texture_dimension_2d;
         const INITIAL_ALPHA_TEXTURE_HEIGHT: u32 = 1;
         let alphas_texture = Self::create_alphas_texture(
             device,
@@ -1326,7 +1335,6 @@
                 width: render_target_config.width,
                 height: render_target_config.height,
             },
-            max_texture_dimension_2d,
         );
 
         let AtlasConfig {
@@ -1489,22 +1497,16 @@
         })
     }
 
-    fn create_config_buffer(
-        device: &Device,
-        render_size: &RenderSize,
-        alpha_texture_width: u32,
-    ) -> Buffer {
+    fn create_config_buffer(device: &Device, render_size: &RenderSize) -> Buffer {
         device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
             label: Some("Config Buffer"),
             contents: bytemuck::bytes_of(&Config {
                 width: render_size.width,
                 height: render_size.height,
-                strip_height: Tile::HEIGHT.into(),
-                alphas_tex_width_bits: alpha_texture_width.trailing_zeros(),
-                encoded_paints_tex_width_bits: alpha_texture_width.trailing_zeros(),
                 strip_offset_x: 0,
                 strip_offset_y: 0,
                 negate_ndc: 0,
+                ..Config::zeroed()
             }),
             usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
         })
@@ -1763,7 +1765,7 @@
         self.maybe_resize_alphas_tex(device, max_texture_dimension_2d, alphas.len());
         self.maybe_resize_encoded_paints_tex(device, max_texture_dimension_2d, paint_idxs);
         self.maybe_resize_filter_tex(device, max_texture_dimension_2d, filter_context);
-        self.maybe_update_config_buffer(queue, max_texture_dimension_2d, new_render_size);
+        self.maybe_update_config_buffer(queue, new_render_size);
 
         self.upload_alpha_texture(queue, alphas);
         self.upload_encoded_paints_texture(queue, encoded_paints);
@@ -1943,19 +1945,16 @@
     fn maybe_update_config_buffer(
         &mut self,
         queue: &Queue,
-        max_texture_dimension_2d: u32,
         new_render_size: &RenderSize,
     ) {
         if self.render_size != *new_render_size {
             let config = Config {
                 width: new_render_size.width,
                 height: new_render_size.height,
-                strip_height: Tile::HEIGHT.into(),
-                alphas_tex_width_bits: max_texture_dimension_2d.trailing_zeros(),
-                encoded_paints_tex_width_bits: max_texture_dimension_2d.trailing_zeros(),
                 strip_offset_x: 0,
                 strip_offset_y: 0,
                 negate_ndc: 0,
+                ..Config::zeroed()
             };
             let mut buffer = queue
                 .write_buffer_with(&self.resources.view_config_buffer, 0, SIZE_OF_CONFIG)
@@ -2277,24 +2276,10 @@
                             contents: bytemuck::bytes_of(&Config {
                                 width: atlas_size.width,
                                 height: atlas_size.height,
-                                strip_height: Tile::HEIGHT.into(),
-                                alphas_tex_width_bits: self
-                                    .programs
-                                    .resources
-                                    .alphas_texture
-                                    .size()
-                                    .width
-                                    .trailing_zeros(),
-                                encoded_paints_tex_width_bits: self
-                                    .programs
-                                    .resources
-                                    .alphas_texture
-                                    .size()
-                                    .width
-                                    .trailing_zeros(),
                                 strip_offset_x,
                                 strip_offset_y,
                                 negate_ndc: 0,
+                                ..Config::zeroed()
                             }),
                             usage: wgpu::BufferUsages::UNIFORM,
                         });
diff --git a/sparse_strips/vello_sparse_shaders/Cargo.toml b/sparse_strips/vello_sparse_shaders/Cargo.toml
index 40e4a6c..11c39bc 100644
--- a/sparse_strips/vello_sparse_shaders/Cargo.toml
+++ b/sparse_strips/vello_sparse_shaders/Cargo.toml
@@ -2,7 +2,7 @@
 name = "vello_sparse_shaders"
 # When updating, also update the version in the workspace dependency in the root Cargo.toml
 version = "0.0.7"
-description = "Provide compilation of wgsl to glsl to support the WebGL `vello_hybrid` backend."
+description = "Provide compilation of wgsl to glsl es to support the WebGL `vello_hybrid` backend."
 categories = ["rendering", "graphics"]
 keywords = ["2d", "vector-graphics"]
 edition.workspace = true
@@ -23,7 +23,7 @@
 naga = { workspace = true, features = ["wgsl-in", "glsl-out"], optional = true }
 
 [features]
-glsl = ["dep:naga"]
+gles = ["dep:naga"]
 
 [lints]
 workspace = true
diff --git a/sparse_strips/vello_sparse_shaders/build.rs b/sparse_strips/vello_sparse_shaders/build.rs
index 90218ef..26d0e25 100644
--- a/sparse_strips/vello_sparse_shaders/build.rs
+++ b/sparse_strips/vello_sparse_shaders/build.rs
@@ -9,15 +9,15 @@
 use std::path::{Path, PathBuf};
 
 #[allow(warnings)]
-#[cfg(feature = "glsl")]
+#[cfg(feature = "gles")]
 #[path = "src/compile.rs"]
 mod compile;
 #[allow(warnings)]
-#[cfg(feature = "glsl")]
+#[cfg(feature = "gles")]
 #[path = "src/types.rs"]
 mod types;
 
-#[cfg(feature = "glsl")]
+#[cfg(feature = "gles")]
 use compile::compile_wgsl_shader;
 
 // TODO: Format the generated code via `rustfmt`.
@@ -76,13 +76,10 @@
 
     // Implementation for creating a CompiledGlsl struct per shader assuming the standard entry
     // names of `vs_main` and `fs_main`.
-    #[cfg(feature = "glsl")]
+    #[cfg(feature = "gles")]
     {
-        writeln!(
-            buf,
-            "/// Build time GLSL shaders derived from wgsl shaders."
-        )
-        .unwrap();
+        writeln!(buf, "/// Build-time GLSL ES shaders derived from WGSL shaders.").unwrap();
+        writeln!(buf, "pub mod gles {{").unwrap();
 
         for (shader_name, shader_source) in shader_infos {
             let compiled = compile_wgsl_shader(shader_source, "vs_main", "fs_main");
@@ -90,6 +87,8 @@
             let generated_code = compiled.to_generated_code(shader_name);
             writeln!(buf, "{generated_code}").unwrap();
         }
+
+        writeln!(buf, "}}").unwrap();
     }
 }
 
diff --git a/sparse_strips/vello_sparse_shaders/shaders/render_strips.wgsl b/sparse_strips/vello_sparse_shaders/shaders/render_strips.wgsl
index 30f558f..e546e49 100644
--- a/sparse_strips/vello_sparse_shaders/shaders/render_strips.wgsl
+++ b/sparse_strips/vello_sparse_shaders/shaders/render_strips.wgsl
@@ -105,16 +105,6 @@
     width: u32,
     // Height of the rendering target
     height: u32,
-    // Height of a strip in the rendering
-    // CAUTION: When changing this value, you must also update the fragment shader's
-    // logic to handle the new strip height.
-    strip_height: u32,
-    // Number of trailing zeros in alphas_tex_width (log2 of width).
-    // Pre-calculated on CPU since WebGL2 doesn't support `firstTrailingBit`.
-    alphas_tex_width_bits: u32,
-    // Number of trailing zeros in encoded_paints_tex_width (log2 of width).
-    // Pre-calculated on CPU since WebGL2 doesn't support `firstTrailingBit`.
-    encoded_paints_tex_width_bits: u32,
     // An offset to apply to the strip.
     //
     // In most cases, these will be zero. However,
@@ -127,6 +117,17 @@
     ndc_y_negate: u32,
 }
 
+// Shader-lifetime constants whose placeholder values (0u / 1u) are replaced
+// with device-specific literals at renderer init time via string substitution.
+//
+// NOTE: Since we transpile this shader to GLES at build-time, we cannot use WGPU
+// shader constants because, to transpile a shader of that type to GLES, requires
+// Naga, which we want to avoid adding to the binary.
+const STRIP_HEIGHT: u32 = 0u;
+const ALPHAS_TEX_WIDTH_BITS: u32 = 0u;
+const ENCODED_PAINTS_TEX_WIDTH_BITS: u32 = 0u;
+const GRADIENT_TEX_WIDTH_BITS: u32 = 0u;
+
 // A `StripInstance` can represent either a **normal strip** (representing a sparse fill or alpha fill of height
 // Tile::HEIGHT) or a **rect strip** (an entire rectangle rendered as a single quad, with anti-aliasing support).
 // The two modes are distinguished by RECT_STRIP_FLAG (bit 31 of `paint_and_rect_flag`).
@@ -263,7 +264,7 @@
     let dense_width = instance.widths_or_rect_height >> 16u;
 
     let is_rect = (instance.paint_and_rect_flag & RECT_STRIP_FLAG) != 0u;
-    var height = config.strip_height;
+    var height = STRIP_HEIGHT;
     if is_rect {
         height = dense_width;
         out.dense_end_or_rect_size = width | (dense_width << 16u);
@@ -350,15 +351,13 @@
         // channel encodes the alpha values for a single column within a strip.
         // Divide x by 4 to get the texel position.
         let alphas_index = x;
-        let tex_dimensions = textureDimensions(alphas_texture);
-        let alphas_tex_width = tex_dimensions.x;
         // Which texel contains the alpha values for this column
         let texel_index = alphas_index / 4u;
         // Which channel (R,G,B,A) in the texel contains the alpha values for this column
         let channel_index = alphas_index % 4u;
         // Calculate texel coordinates
-        let tex_x = texel_index & (alphas_tex_width - 1u);
-        let tex_y = texel_index >> config.alphas_tex_width_bits;
+        let tex_x = texel_index & ((1u << ALPHAS_TEX_WIDTH_BITS) - 1u);
+        let tex_y = texel_index >> ALPHAS_TEX_WIDTH_BITS;
 
         // Load all 4 channels from the texture
         let rgba_values = textureLoad(alphas_texture, vec2<u32>(tex_x, tex_y), 0);
@@ -520,7 +519,7 @@
         // within the wide tile slot! Therefore, we need to subtract the strip
         // offset here.
         let clip_x = u32(i32(in.position.x) - config.strip_offset_x) & 0xFFu;
-        let clip_y = (u32(i32(sample_y) - config.strip_offset_y) & 3u) + in.payload * config.strip_height;
+        let clip_y = (u32(i32(sample_y) - config.strip_offset_y) & 3u) + in.payload * STRIP_HEIGHT;
         let clip_in_color = textureLoad(clip_input_texture, vec2(clip_x, clip_y), 0);
 
         // Extract opacity from first 8 bits (quantized from [0, 255])
@@ -540,11 +539,11 @@
         // See the comment above for why we need to subtract the strip offset.
         let clip_x = u32(i32(in.position.x) - config.strip_offset_x) & 0xFFu;
         let clip_y_in_strip = u32(i32(sample_y) - config.strip_offset_y) & 3u;
-        let src_y = clip_y_in_strip + src_slot * config.strip_height;
+        let src_y = clip_y_in_strip + src_slot * STRIP_HEIGHT;
         let src_color = textureLoad(clip_input_texture, vec2(clip_x, src_y), 0);
 
         // Read destination color from slot
-        let dest_y = clip_y_in_strip + dest_slot * config.strip_height;
+        let dest_y = clip_y_in_strip + dest_slot * STRIP_HEIGHT;
         let dest_color = textureLoad(clip_input_texture, vec2(clip_x, dest_y), 0);
 
         final_color = blend_mix_compose(dest_color, src_color * opacity * alpha, compose_mode, mix_mode);
@@ -856,8 +855,8 @@
 // Convert a flat texel index to 2D texture coordinates for the encoded paints texture.
 fn encoded_paint_coord(flat_idx: u32) -> vec2<u32> {
     return vec2<u32>(
-        flat_idx & ((1u << config.encoded_paints_tex_width_bits) - 1u),
-        flat_idx >> config.encoded_paints_tex_width_bits
+        flat_idx & ((1u << ENCODED_PAINTS_TEX_WIDTH_BITS) - 1u),
+        flat_idx >> ENCODED_PAINTS_TEX_WIDTH_BITS
     );
 }
 
@@ -1105,9 +1104,8 @@
     // Calculate absolute position in flat gradient texture
     let flat_coord = gradient_start + t_offset;
     // Convert flat coordinate to 2D texture coordinate
-    let gradient_tex_width = textureDimensions(gradient_texture).x;
-    let tex_x = flat_coord % gradient_tex_width;
-    let tex_y = flat_coord / gradient_tex_width;
+    let tex_x = flat_coord & ((1u << GRADIENT_TEX_WIDTH_BITS) - 1u);
+    let tex_y = flat_coord >> GRADIENT_TEX_WIDTH_BITS;
     // Sample from the gradient texture at calculated position
     let gradient_color = textureLoad(gradient_texture, vec2<u32>(tex_x, tex_y), 0);
     return gradient_color;
diff --git a/sparse_strips/vello_sparse_shaders/src/lib.rs b/sparse_strips/vello_sparse_shaders/src/lib.rs
index 6932063..25a3b59 100644
--- a/sparse_strips/vello_sparse_shaders/src/lib.rs
+++ b/sparse_strips/vello_sparse_shaders/src/lib.rs
@@ -3,9 +3,75 @@
 
 //! This is a utility library to help integrate `vello_hybrid` WebGPU wgsl shaders into glsl.
 
-#[cfg(feature = "glsl")]
+extern crate alloc;
+
+#[cfg(feature = "gles")]
 mod compile;
-#[cfg(feature = "glsl")]
+mod specialize;
+#[cfg(feature = "gles")]
 mod types;
 
-include!(concat!(env!("OUT_DIR"), "/compiled_shaders.rs"));
+pub use specialize::ShaderConstants;
+
+mod generated {
+    include!(concat!(env!("OUT_DIR"), "/compiled_shaders.rs"));
+}
+
+/// WGSL shader sources.
+pub mod wgsl {
+    use alloc::string::String;
+
+    use crate::ShaderConstants;
+
+    /// Returns specialised WGSL source.
+    pub fn render_strips(constants: &ShaderConstants) -> String {
+        constants.specialize_wgsl(super::generated::wgsl::RENDER_STRIPS)
+    }
+
+    /// WGSL source for the `clear_slots` shader.
+    pub const CLEAR_SLOTS: &str = super::generated::wgsl::CLEAR_SLOTS;
+
+    /// WGSL source for the `filters` shader.
+    pub const FILTERS: &str = super::generated::wgsl::FILTERS;
+}
+
+/// GLSL ES shader sources.
+#[cfg(feature = "gles")]
+pub mod gles {
+    /// GLSL ES sources for the `render_strips` shader.
+    pub mod render_strips {
+        use alloc::string::String;
+
+        use crate::ShaderConstants;
+
+        /// Returns specialised GLSL ES vertex source.
+        pub fn vertex_source(constants: &ShaderConstants) -> String {
+            constants.specialize_glsl(crate::generated::gles::render_strips::VERTEX_SOURCE)
+        }
+
+        /// Returns specialised GLSL ES fragment source.
+        pub fn fragment_source(constants: &ShaderConstants) -> String {
+            constants.specialize_glsl(crate::generated::gles::render_strips::FRAGMENT_SOURCE)
+        }
+
+        /// Vertex-stage reflection metadata.
+        pub mod vertex {
+            pub use crate::generated::gles::render_strips::vertex::*;
+        }
+
+        /// Fragment-stage reflection metadata.
+        pub mod fragment {
+            pub use crate::generated::gles::render_strips::fragment::*;
+        }
+    }
+
+    /// GLSL ES sources for the `clear_slots` shader.
+    pub mod clear_slots {
+        pub use crate::generated::gles::clear_slots::*;
+    }
+
+    /// GLSL ES sources for the `filters` shader.
+    pub mod filters {
+        pub use crate::generated::gles::filters::*;
+    }
+}
diff --git a/sparse_strips/vello_sparse_shaders/src/specialize.rs b/sparse_strips/vello_sparse_shaders/src/specialize.rs
new file mode 100644
index 0000000..63273fb
--- /dev/null
+++ b/sparse_strips/vello_sparse_shaders/src/specialize.rs
@@ -0,0 +1,113 @@
+// Copyright 2026 the Vello Authors
+// SPDX-License-Identifier: Apache-2.0 OR MIT
+
+//! Shader specialization via string substitution.
+//!
+//! The WGSL shader source declares shader build time constants with placeholder
+//! values. At shader build time (during runtime on the app), [`ShaderConstants::specialize_wgsl`]
+//! (and [`ShaderConstants::specialize_glsl`] for the WebGL backend) replace these
+//! placeholders with device-specific literals, turning them into compile-time constants.
+//!
+//! Shader constants are supported by Naga, but would require to be shipped in the binary
+//! at runtime for Naga to perform the substitution. We intentionally created `vello_sparse_shaders`
+//! to avoid this binary bloat, so instead we perform this substitution ourselves.
+
+use alloc::format;
+use alloc::string::String;
+
+/// Values that are constant for the lifetime of a renderer. At shader build time their
+/// placeholder `const` declarations in the shader source are replaced with literals
+/// via string substitution.
+///
+/// For example, for `ShaderConstants` with `strip_height` set to `16`,
+/// ```wgsl
+/// const STRIP_HEIGHT: u32 = 0u;
+/// ```
+/// Is string replaced with:
+/// ```wgsl
+/// const STRIP_HEIGHT: u32 = 16u;
+/// ```
+#[derive(Debug, Clone)]
+pub struct ShaderConstants {
+    /// Height of a strip.
+    pub strip_height: u32,
+    /// log2 of the alphas texture width (for bit-shift addressing).
+    pub alphas_tex_width_bits: u32,
+    /// log2 of the encoded-paints texture width (for bit-shift addressing).
+    pub encoded_paints_tex_width_bits: u32,
+    /// log2 of the gradient texture width (for bit-shift addressing).
+    pub gradient_tex_width_bits: u32,
+}
+
+/// Assumes that each replacement is expected to appear at most once and to be fully contained within a single line.
+fn replace_all_once(source: &str, replacements: &[(&str, &str)]) -> String {
+    let mut result = String::with_capacity(source.len() + 128);
+    for (i, line) in source.split('\n').enumerate() {
+        if i > 0 {
+            result.push('\n');
+        }
+        if let Some((pat, rep)) = replacements.iter().find(|(pat, _)| line.contains(pat)) {
+            result.push_str(&line.replacen(pat, rep, 1));
+        } else {
+            result.push_str(line);
+        }
+    }
+    result
+}
+
+impl ShaderConstants {
+    /// Specialize a WGSL shader source by replacing build time `const` declarations.
+    pub(crate) fn specialize_wgsl(&self, source: &str) -> String {
+        let strip_height = format!("const STRIP_HEIGHT: u32 = {}u;", self.strip_height);
+        let alphas = format!(
+            "const ALPHAS_TEX_WIDTH_BITS: u32 = {}u;",
+            self.alphas_tex_width_bits
+        );
+        let paints = format!(
+            "const ENCODED_PAINTS_TEX_WIDTH_BITS: u32 = {}u;",
+            self.encoded_paints_tex_width_bits
+        );
+        let gradient = format!(
+            "const GRADIENT_TEX_WIDTH_BITS: u32 = {}u;",
+            self.gradient_tex_width_bits
+        );
+
+        replace_all_once(
+            source,
+            &[
+                ("const STRIP_HEIGHT: u32 = 0u;", &strip_height),
+                ("const ALPHAS_TEX_WIDTH_BITS: u32 = 0u;", &alphas),
+                ("const ENCODED_PAINTS_TEX_WIDTH_BITS: u32 = 0u;", &paints),
+                ("const GRADIENT_TEX_WIDTH_BITS: u32 = 0u;", &gradient),
+            ],
+        )
+    }
+
+    /// Specialize a GLSL shader source by replacing build time `const` declarations.
+    #[cfg(feature = "gles")]
+    pub(crate) fn specialize_glsl(&self, source: &str) -> String {
+        let strip_height = format!("const uint STRIP_HEIGHT = {}u;", self.strip_height);
+        let alphas = format!(
+            "const uint ALPHAS_TEX_WIDTH_BITS = {}u;",
+            self.alphas_tex_width_bits
+        );
+        let paints = format!(
+            "const uint ENCODED_PAINTS_TEX_WIDTH_BITS = {}u;",
+            self.encoded_paints_tex_width_bits
+        );
+        let gradient = format!(
+            "const uint GRADIENT_TEX_WIDTH_BITS = {}u;",
+            self.gradient_tex_width_bits
+        );
+
+        replace_all_once(
+            source,
+            &[
+                ("const uint STRIP_HEIGHT = 0u;", &strip_height),
+                ("const uint ALPHAS_TEX_WIDTH_BITS = 0u;", &alphas),
+                ("const uint ENCODED_PAINTS_TEX_WIDTH_BITS = 0u;", &paints),
+                ("const uint GRADIENT_TEX_WIDTH_BITS = 0u;", &gradient),
+            ],
+        )
+    }
+}