diff --git a/src/libcamera/shaders/meson.build b/src/libcamera/shaders/meson.build
index c409ff9b0..80b127d3d 100644
--- a/src/libcamera/shaders/meson.build
+++ b/src/libcamera/shaders/meson.build
@@ -7,6 +7,9 @@ shader_files = files([
     'bayer_unpacked.frag',
     'bayer_unpacked.vert',
     'identity.vert',
+    'mono_1x_packed.frag',
+    'mono_unpacked.frag',
+    'mono.vert',
 ])

 # Generate header from shaders
diff --git a/src/libcamera/shaders/mono.vert b/src/libcamera/shaders/mono.vert
new file mode 100644
index 000000000..474ef462c
--- /dev/null
+++ b/src/libcamera/shaders/mono.vert
@@ -0,0 +1,25 @@
+/* SPDX-License-Identifier: BSD-2-Clause */
+/*
+ * Copyright (C) 2026, Alain Cousinié
+ *
+ * Optimized vertex shader for MIPI CSI-2 packed monochrome formats
+ * Pre-calculates exact pixel coordinates to reduce Fragment Shader overhead
+ */
+
+attribute vec4 vertexIn;
+attribute vec2 textureIn;
+
+varying vec2 textureOut;
+varying vec2 v_pixelCoords;
+
+uniform mat4 proj_matrix;
+uniform float stride_factor;
+uniform vec2 tex_size;
+
+void main(void)
+{
+	gl_Position = proj_matrix * vertexIn;
+
+	textureOut = vec2(textureIn.x * stride_factor, textureIn.y);
+	v_pixelCoords = textureOut * tex_size;
+}
diff --git a/src/libcamera/shaders/mono_1x_packed.frag b/src/libcamera/shaders/mono_1x_packed.frag
new file mode 100644
index 000000000..d23c8be8d
--- /dev/null
+++ b/src/libcamera/shaders/mono_1x_packed.frag
@@ -0,0 +1,69 @@
+/* SPDX-License-Identifier: BSD-2-Clause */
+/*
+ * Copyright (C) 2026, Alain Cousinié
+ * Unified Fragment shader for MIPI CSI-2 packed monochrome formats (R10P / R12P)
+ */
+
+#ifdef GL_ES
+#ifdef GL_FRAGMENT_PRECISION_HIGH
+/* High precision prevents float rounding errors during bit-shifting math. */
+precision highp float;
+#else
+precision mediump float;
+#endif
+#endif
+
+varying vec2            textureOut;
+varying vec2            v_pixelCoords;
+
+uniform sampler2D       tex_y;
+uniform float           gamma;
+uniform float           contrastExp;
+uniform vec3            blacklevel;
+
+#define INV_TEX_WIDTH  (1.0 / TEX_WIDTH)
+#define INV_TEX_HEIGHT (1.0 / TEX_HEIGHT)
+
+void main(void)
+{
+	float px = floor(v_pixelCoords.x);
+	float py = floor(v_pixelCoords.y);
+
+	/* Compute absolute macro-block start byte and local pixel index. */
+	float blockStart  = floor(px / BLOCK_PIXELS) * BLOCK_BYTES;
+	float idx         = px - (blockStart * (BLOCK_PIXELS / BLOCK_BYTES));
+
+	/* Target the center of the dedicated MSB texel. */
+	float xMSB        = (blockStart + idx + 0.5) * INV_TEX_WIDTH;
+
+	/* Target the shared trailing byte containing the LSBs (1/8bits = 0.125) . */
+	float lsbOffset   = BLOCK_PIXELS + floor(idx * LSB_SIZE * 0.125);
+	float xLSB        = (blockStart + lsbOffset + 0.5) * INV_TEX_WIDTH;
+	float normY       = (py + 0.5) * INV_TEX_HEIGHT;
+
+	float byteHigh = floor(texture2D(tex_y, vec2(xMSB, normY)).r * 255.0 + 0.5);
+	float byteLow  = floor(texture2D(tex_y, vec2(xLSB, normY)).r * 255.0 + 0.5);
+
+	/* Isolate the pixel's LSB bits inside the shared byte */
+	float totalBitsPos   = idx * LSB_SIZE;
+	float bitStartInByte = totalBitsPos - (floor(totalBitsPos * 0.125) * 8.0);
+
+	float divisionFactor = pow(2.0, bitStartInByte);
+	float shiftedLsb     = floor(byteLow / divisionFactor);
+	float lsbBits        = shiftedLsb - (floor(shiftedLsb / LSB_MASK_VAL) * LSB_MASK_VAL);
+
+	/* Reconstruct full native intensity value. */
+	float rawValue = (byteHigh * LSB_MASK_VAL) + lsbBits;
+
+	float normalizedIntensity = rawValue * INV_MAX_VAL;
+	normalizedIntensity = clamp(normalizedIntensity - blacklevel.g, 0.0, 1.0);
+
+	/* Apply S-Curve contrast stretching. */
+	float centerDist = 1.0 - abs(2 * normalizedIntensity - 1.0);
+	float singlePow  = 0.5 * pow(centerDist, contrastExp);
+	normalizedIntensity = mix(singlePow, 1.0 - singlePow, step(0.5, normalizedIntensity));
+
+	/* Apply gamma correction and output. */
+	vec3 rgb = pow(vec3(normalizedIntensity), vec3(gamma));
+	gl_FragColor = vec4(rgb, 1.0);
+}
diff --git a/src/libcamera/shaders/mono_unpacked.frag b/src/libcamera/shaders/mono_unpacked.frag
new file mode 100644
index 000000000..9ea9a7b79
--- /dev/null
+++ b/src/libcamera/shaders/mono_unpacked.frag
@@ -0,0 +1,51 @@
+/* SPDX-License-Identifier: BSD-2-Clause */
+/*
+ * Copyright (C) 2026, Alain Cousinié
+ * Unified Fragment shader for unpacked monochrome formats (R8, R10, R12, R16)
+ */
+
+#ifdef GL_ES
+#ifdef GL_FRAGMENT_PRECISION_HIGH
+/* High precision prevents float rounding errors during bit-shifting math. */
+precision highp float;
+#else
+precision mediump float;
+#endif
+#endif
+
+varying vec2            textureOut;
+
+uniform sampler2D       tex_y;
+uniform float           gamma;
+uniform float           contrastExp;
+uniform vec3            blacklevel;
+
+void main(void)
+{
+	float rawValue = 0.0;
+
+#ifdef RAW8
+	/* 8-bit unpacked: single channel extraction. */
+	rawValue = floor(texture2D(tex_y, textureOut).r * 255.0 + 0.5);
+#else
+	/* Multi-byte unpacked: merge Red (low byte) and Green (high byte). */
+	vec4 texel = texture2D(tex_y, textureOut);
+
+	float byteLow  = floor(texel.r * 255.0 + 0.5);
+	float byteHigh = floor(texel.g * 255.0 + 0.5);
+
+	rawValue = (byteHigh * 256.0) + byteLow;
+#endif
+
+	float normalizedIntensity = rawValue * INV_MAX_VAL;
+	normalizedIntensity = clamp(normalizedIntensity - blacklevel.g, 0.0, 1.0);
+
+	/* S-Curve contrast stretching. */
+	float centerDist = 1.0 - abs(2.0 * normalizedIntensity - 1.0);
+	float singlePow  = 0.5 * pow(centerDist, contrastExp);
+	normalizedIntensity = mix(singlePow, 1.0 - singlePow, step(0.5, normalizedIntensity));
+
+	/* Gamma correction and output. */
+	vec3 rgb = pow(vec3(normalizedIntensity), vec3(gamma));
+	gl_FragColor = vec4(rgb, 1.0);
+}
