Quick answer: Promote color and gradient calculations to highp and add a 1/255 dither at output. Eliminates visible banding on Adreno and Mali with negligible perf cost.
Your title screen shows a smooth dark-blue gradient on iPhone and on desktop. On a Pixel or Samsung device, the same gradient has distinct vertical bands of color — you can count maybe 30 visible steps where there should be 256. The art is correct; the rendering is being quantized too aggressively.
Why Mobile Banding Happens
Many Android GPUs — Adreno especially, and Mali to a lesser extent — perform fragment shader math at mediump (16-bit float) precision by default. A 16-bit float has only ~3 decimal digits of precision. For colors in the 0–1 range, that’s precision of about 1/512. When you compute a slow gradient (alpha rising from 0 to 0.1 over 1000 pixels), the intermediate values quantize to 50 distinct steps. The final 8-bit output reflects those steps as visible bands.
Fix 1: Promote Color Math to highp
precision mediump float; // fragment shader default
in highp vec2 v_uv;
uniform highp vec3 u_color_a;
uniform highp vec3 u_color_b;
void main() {
highp float t = v_uv.y;
highp vec3 color = mix(u_color_a, u_color_b, t);
gl_FragColor = vec4(color, 1.0);
}
The gradient math runs at 32-bit precision; banding from precision loss disappears. Cost: highp fragment work is slower than mediump on most Android GPUs, but for small color operations the perf impact is sub-1%.
Fix 2: Dither Before Quantization
Even at highp precision, the 8-bit output framebuffer can’t represent 256 shades smoothly. Add per-pixel noise to randomize quantization boundaries:
highp float dither(highp vec2 uv) {
return fract(sin(dot(uv * 512.0, vec2(12.9898, 78.233))) * 43758.5453);
}
void main() {
highp vec3 color = mix(u_color_a, u_color_b, v_uv.y);
color += (dither(gl_FragCoord.xy) - 0.5) / 255.0;
gl_FragColor = vec4(color, 1.0);
}
The dither shifts each pixel by ±0.5/255 before quantization. Adjacent pixels with very similar source colors are pushed across the quantization boundary in different directions, replacing hard bands with noisy transitions. The noise is below human perception threshold but eliminates banding.
Fix 3: Higher Bit Depth Framebuffer
For HDR pipelines or scenes where dithering isn’t enough, switch the color attachment to GL_RGBA16F instead of GL_RGBA8. 16-bit per channel = 65,536 shades per color, far more than human eyes can distinguish.
Memory bandwidth doubles, which hurts on mobile (mobile GPUs are typically bandwidth-bound). Use selectively — one HDR scene buffer with tone-mapping, then resolve to 8-bit for display.
In Unity URP, enable HDR in the Universal Renderer asset; in Unreal, the default mobile pipeline already uses an HDR intermediate.
Fix 4: Cap the Banding-Sensitive Operations
If you have a long, dark gradient (the bands are most visible in dark colors due to perceptual non-linearity), break the gradient into discrete steps deliberately rather than trying to render a smooth one. A “cel shaded” quantized sky reads as artistic intent rather than precision loss.
Verifying
Build and test on representative devices — ideally one Adreno (Pixel, Samsung S series) and one Mali (older Samsung, Huawei). Capture a screenshot of the worst gradient. Compare before and after. The banding should be replaced with subtle noise that’s invisible from normal viewing distance.
“Banding is a precision problem. Either raise the precision, mask it with noise, or quantize it intentionally.”
Dithering is essentially free perf-wise and fixes 90% of banding. Add it to every fullscreen pass that draws gradients.