Radial distortion

Why straight lines bend near the edge of a real lens — and how to reproduce the mapping in a shader.

Visible effect

Start with a grid

A regular grid makes the mapping impossible to hide.

Move away from the optical axis and the lines begin to bow.

Physics

Field-dependent magnification

Three field angles pass through a real lens. The central image point stays in place, while off-axis points land closer to the optical axis than their ideal positions. The same inward position error turns an even grid into a barrel-distorted grid.real lensimage planeequally spaced image pointsedge points move inward; lines bow outward

Solid green rays show the actual image positions; dashed grey rays show the ideal positions. A real lens does not keep exactly the same lateral magnification at every field angle, so off-axis points land at the wrong radius even when they remain sharp. Falling magnification moves them inward and produces barreldistortion; rising magnification moves them outward and producespincushion distortion.

Mathematics

Forward model, inverse sampling

Brown–Conrady does not simulate the glass. It fits the position error with a radial polynomial. Starting from an ideal normalized point:

A fragment shader works backwards: for every output pixel it finds the source coordinate to sample. Confusing the model direction with the sampling direction is the classic reason barrel and pincushion presets appear reversed.

Shader

Shader approximation

The playground compiles and uses the complete function below. Its job is deliberately narrow: receive a destination pixel coordinate in normalized [0, 1] texture space and return the source coordinate that should be sampled in that same space.

GLSL mapping function

radialSourceCoordinateGLSL
// WHAT: Find the undistorted source coordinate for a radial-distorted output.
// HOW: Iteratively invert a sixth-order radial polynomial in aspect-correct
// coordinates, then return the result to normalized [0, 1] texture space.
// WHY: Inverse mapping gives every output pixel a source sample and avoids the
// holes that appear when source pixels are pushed forward.
// The returned source UV may fall outside [0, 1]; the caller defines edge policy.
vec2 radialSourceCoordinate(
  vec2 destinationUv01,
  vec2 centerUv01,
  vec3 radialCoefficients,
  float widthOverHeight
) {
  // Measure a circular optical radius even when the texture is not square.
  vec2 destination = destinationUv01 - centerUv01;
  destination.x *= widthOverHeight;
  vec2 source = destination;

  for (int iteration = 0; iteration < 9; iteration += 1) {
    // Fixed-point inversion solves source * scale(source) = destination.
    float radius2 = dot(source, source);
    float scale = max(
      1.0 + radialCoefficients.x * radius2
        + radialCoefficients.y * radius2 * radius2
        + radialCoefficients.z * radius2 * radius2 * radius2,
      0.000001
    );
    source = destination / scale;
  }

  // Undo aspect correction before sampling the normalized source texture.
  source.x /= widthOverHeight;
  return centerUv01 + source;
}
Processing pipelineBoxes mark actual render-pass boundaries.
  1. Source texture
  2. Coordinate remapOne texture read per output pixel
    • Calculate the inverse radial mapping
    • Sample the source coordinate
  3. Display output

Why these steps are here

  1. Lines 1–7 — state the coordinate contract. destinationUv01 and centerUv01 are both normalized texture coordinates. The returned source coordinate uses the same coordinate system, although strong distortion can move it outside the texture bounds.
  2. Lines 9–10 — centre and correct the aspect ratio. Subtracting centerUv01 already produces signed coordinates around the optical centre; a separate conversion to[-1, 1] is unnecessary. Scaling x by width divided by height makes the radius circular rather than elliptical on a non-square target.
  3. Lines 13–22 — invert the forward polynomial. Starting at the destination radius, nine fixed-point iterations recover the ideal source radius that the forward model would move into this output pixel. k1 controls the broad low-order bend. k2and k3 increasingly concentrate their influence near the frame edge because they multiply r⁴ and r⁶.
  4. Lines 24–25 — undo the coordinate changes. The recovered source x is returned to texture-space units andcenterUv01 is added back.

Why the function returns a source coordinate

The polynomial is usually introduced as a forward camera model: ideal point → distorted point. A shader cannot safely push one source pixel into the output because several pixels may collide and leave holes. Rasterization visits every output pixel instead, so the runtime evaluates the mapping as an inverse sampling approximation and asks: “which source coordinate belongs here?”

No history buffer is required. Coefficients are meaningful only together with the coordinate normalization used by this function.

Notes

  • The polynomial imitates a mapping; it does not trace the actual lens.
  • Strong coefficients can fold coordinates or expose the source boundary.
  • Decentering, tangential distortion, and wavelength dependence are omitted here.
  • Calibration parameters from a camera may use a different normalization convention.

References

OpenCV camera calibration documentation — defines radial and tangential camera-distortion models and their calibration coefficients.

Edmund Optics — Distortion — explains field-dependent magnification, barrel and pincushion distortion, and calibration limits.