Precision

Precision Qualifiers and Why They Matter on Mobile GPUs

highp, mediump and lowp state the minimum range and precision a variable needs, letting a GPU use cheaper 16-bit arithmetic. The GLSL ES 3.00 specification sets the floors:

Minimum requirements of the GLSL ES 3.00 precision qualifiers
Qualifier Float range Float precision Signed int range
highp IEEE 754 32-bit relative 2^-24 -2^31 to 2^31-1
mediump at least (-2^14, 2^14) relative 2^-10 -2^15 to 2^15-1
lowp at least (-2, 2) absolute 2^-8 -2^8 to 2^8-1

precision mediump float; sets the default for unqualified floats. The vertex shader defaults to highp; the fragment shader has no default float precision, so omitting the statement fails with "No precision specified for (float)". Ask the driver what it really provides:

Querying the precision each shader stage really providesJavaScript
for (const stage of ['VERTEX_SHADER', 'FRAGMENT_SHADER']) {
  const row = ['LOW_FLOAT', 'MEDIUM_FLOAT', 'HIGH_FLOAT'].map((type) => {
    const f = gl.getShaderPrecisionFormat(gl[stage], gl[type]);
    return `${type} 2^${f.rangeMax} ${f.precision} bits`;
  });
  console.log(stage.padEnd(15), row.join(' | '));
}
Output
VERTEX_SHADER   LOW_FLOAT 2^127 23 bits | MEDIUM_FLOAT 2^127 23 bits | HIGH_FLOAT 2^127 23 bits
FRAGMENT_SHADER LOW_FLOAT 2^127 23 bits | MEDIUM_FLOAT 2^127 23 bits | HIGH_FLOAT 2^127 23 bits

On the GTX 1650 everything is 32-bit, so precision bugs cannot show. SwiftShader 2,546 reports mediump and lowp as 2^15 with 10 bits, a half float's shape, and phone GPUs such as Arm Mali really compute mediump in 16 bits. Use highp for positions and texture coordinates, mediump for colors, and test on a phone.

The same pattern at highp and at an emulated 16-bit mediump, plus the driver's real precision formatsHTMLLive
<!doctype html>
<style>
  body { margin: 0; font: 12px system-ui, sans-serif; background: #f7f4ee; color: #333; }
  canvas { display: block; width: 100%; max-width: 600px; }
  .names { display: flex; max-width: 600px; text-align: center; }
  .names div { flex: 1; padding: 3px 0; }
  table { max-width: 600px; width: 100%; border-collapse: collapse; margin-top: 4px; font-size: 11px; }
  td, th { padding: 3px 5px; border-bottom: 1px solid #e3ddd0; text-align: left; }
</style>
<canvas id="view" width="600" height="200"></canvas>
<div class="names"><div>highp: 32-bit float (24-bit precision)</div><div>mediump on a phone: 16-bit float (10-bit precision)</div></div>
<table id="formats"></table>
<script>
const gl = document.getElementById('view').getContext('webgl2');
const vs = `#version 300 es
void main() { gl_Position = vec4(gl_VertexID == 1 ? 3.0 : -1.0, gl_VertexID == 2 ? 3.0 : -1.0, 0.0, 1.0); }`;
const fs = `#version 300 es
precision highp float;       // the fragment shader has no default float precision
uniform bool uHalf; uniform float uTime;
out vec4 outColor;

// Round x to a 10-bit mantissa, the way a 16-bit mediump register would store it.
float asHalf(float x) {
  if (!uHalf || x == 0.0) return x;
  float step = exp2(floor(log2(abs(x))) - 10.0);
  return round(x / step) * step;
}
void main() {
  vec2 local = mod(gl_FragCoord.xy, vec2(300.0, 200.0)) - vec2(150.0, 100.0);
  // A "world" coordinate far from the origin, like a texture coordinate on a big atlas.
  float world = asHalf(1500.0 + length(local) * 0.25 + uTime);
  float rings = 0.5 + 0.5 * sin(world * 3.0);
  vec3 color = mix(vec3(0.12, 0.37, 0.55), vec3(0.93, 0.9, 0.84), rings);
  outColor = vec4(color, 1.0);
}`;
const program = gl.createProgram();
for (const [type, src] of [[gl.VERTEX_SHADER, vs], [gl.FRAGMENT_SHADER, fs]]) {
  const s = gl.createShader(type);
  gl.shaderSource(s, src); gl.compileShader(s);
  if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
  gl.attachShader(program, s);
}
gl.linkProgram(program);
gl.useProgram(program);
gl.bindVertexArray(gl.createVertexArray());
gl.enable(gl.SCISSOR_TEST);
const uHalf = gl.getUniformLocation(program, 'uHalf');
const uTime = gl.getUniformLocation(program, 'uTime');
function frame(ms) {
  gl.uniform1f(uTime, ms * 0.002);
  [false, true].forEach((half, i) => {
    gl.scissor(i * 300 + (i ? 4 : 0), 0, 296, 200);    // left and right halves
    gl.uniform1i(uHalf, half);
    gl.drawArrays(gl.TRIANGLES, 0, 3);
  });
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

// Ask the driver what each qualifier really provides in each stage.
let rows = '<tr><th>stage</th><th>lowp</th><th>mediump</th><th>highp</th></tr>';
for (const stage of ['VERTEX_SHADER', 'FRAGMENT_SHADER']) {
  rows += `<tr><td>${stage}</td>` + ['LOW_FLOAT', 'MEDIUM_FLOAT', 'HIGH_FLOAT'].map(type => {
    const f = gl.getShaderPrecisionFormat(gl[stage], gl[type]);
    return `<td>range 2^${f.rangeMax}, ${f.precision} bits</td>`;
  }).join('') + '</tr>';
}
document.getElementById('formats').innerHTML = rows;
</script>