Electric Field Potential and the Relaxation Method
The distribution of electric potential in space is governed by Poisson's equation — an elliptic partial differential equation. The relaxation method is straightforward, GPU-parallelisable, and produces beautiful visualisations of equipotential lines and field lines in just a few hundred lines of code.
1. Laplace and Poisson Equations
Electric potential φ is related to the field E = −∇φ. From Gauss's law ∇·E = ρ/ε₀ we get:
∇²φ = −ρ/ε₀ (Poisson's equation)
∇²φ = 0 (Laplace's equation, if ρ = 0)
where ∇² = ∂²/∂x² + ∂²/∂y² + ∂²/∂z² — the Laplacian operator
Laplace's equation describes the potential in a charge-free region — between conductors with fixed potentials. Poisson's equation is the general case (charges present). Both are of elliptic type: the solution is "smooth" and depends on boundary conditions across the entire boundary.
Physical Interpretation
Minimum Variance
The solution to Laplace's equation minimises ∫|∇φ|²dV. This means: the potential at every point equals the average over its neighbours (maximum modulus principle).
Uniqueness
By the uniqueness theorem, fixed Dirichlet or Neumann boundary conditions yield a unique solution. This property guarantees convergence of iterations.
Analogues
The same equation describes steady-state heat conduction (T instead of φ), potential fluid flow, equilibrium diffusion, and minimal surfaces.
Numerical Methods
FDM (finite differences), FEM (finite elements), BEM (boundary elements). For simple geometries, FDM is the simplest choice.
2. Finite-Difference Discretisation
We divide the domain into a uniform 2D grid with step h. The Laplacian is approximated by the five-point stencil:
∇²φ ≈ (φ_{i+1,j} + φ_{i-1,j} + φ_{i,j+1} + φ_{i,j-1} − 4φ_{i,j}) / h²
Approximation error: O(h²) — second-order accuracy
Substituting into Poisson's equation ∇²φ = f (right-hand side = −ρ/ε): we get a linear system A·φ = b, where A is a sparse matrix (5 non-zero elements per row in 2D). Dimension N × N for an N×N grid. Direct solution (LU) is expensive — O(N³). Iterative methods — O(N²) per iteration, converging in O(N) – O(N log N) iterations.
3. Jacobi Method
The simplest iterative method: express φ_{i,j} through its neighbours using old values:
φ^{k+1}_{i,j} = (φ^k_{i+1,j} + φ^k_{i-1,j} + φ^k_{i,j+1} + φ^k_{i,j-1} − h²·f_{i,j}) / 4
Convergence: spectral radius ρ_J ≈ 1 − π²/(2N²) for a square N×N grid. Iterations for error ε: k ≈ N² ln(1/ε) / π² ∝ N². For N=100 and ε=10⁻⁶ — ~28 000 iterations. Slow!
Why Jacobi Maps Naturally to WebGL
Each texture pixel = one grid node. Jacobi is perfectly parallel — new values are independent of each other. One pass = one draw call in a ping-pong framebuffer.
// GLSL fragment shader — one Jacobi iteration
precision highp float;
uniform sampler2D uPhi; // current φ approximation
uniform sampler2D uRho; // charge density ρ (normalised)
uniform vec2 uTexelSize; // 1/width, 1/height
uniform float uH2; // h² (grid step squared)
varying vec2 vUV;
void main() {
float n = texture2D(uPhi, vUV + vec2(0., uTexelSize.y)).r;
float s = texture2D(uPhi, vUV - vec2(0., uTexelSize.y)).r;
float e = texture2D(uPhi, vUV + vec2(uTexelSize.x, 0.)).r;
float w = texture2D(uPhi, vUV - vec2(uTexelSize.x, 0.)).r;
float rho = texture2D(uRho, vUV).r;
gl_FragColor = vec4((n + s + e + w + uH2 * rho) / 4.0, 0., 0., 1.);
}
4. Gauss-Seidel Method
Unlike Jacobi: immediately use new neighbour values as soon as they are computed (left and lower neighbours are already updated):
φ^{k+1}_{i,j} = (φ^{k+1}_{i-1,j} + φ^k_{i+1,j} + φ^{k+1}_{i,j-1} + φ^k_{i,j+1} − h²·f) / 4
Convergence is twice as fast as Jacobi: ρ_GS ≈ ρ_J². In practice, half the iterations for the same error. GPU challenge: pixel dependencies — requires a checkerboard update order ("red-black Gauss-Seidel").
Red-Black Ordering
Split the grid into "red" (i+j even) and "black" (i+j odd) nodes. Red nodes depend only on black and vice versa. Two passes per iteration:
function redBlackGS(phi, rho, N, h2, iterations) {
for (let it = 0; it < iterations; it++) {
for (let color = 0; color < 2; color++) { // 0=red, 1=black
for (let i = 1; i < N - 1; i++) {
for (let j = 1; j < N - 1; j++) {
if ((i + j) % 2 !== color) continue;
const idx = i * N + j;
phi[idx] = (
phi[(i+1) * N + j] + phi[(i-1) * N + j] +
phi[i * N + j+1] + phi[i * N + j-1] +
h2 * rho[idx]
) / 4;
}
}
}
}
}
5. Successive Over-Relaxation (SOR)
SOR (Successive Over-Relaxation) improves Gauss-Seidel by introducing a relaxation parameter ω ∈ (1, 2):
φ^{k+1}_{i,j} = (1−ω)·φ^k_{i,j} + ω·φ^{GS}_{i,j}
where φ^{GS}_{i,j} — the Gauss-Seidel value
Optimum: ω_opt = 2 / (1 + sin(π/N)) ≈ 2 − 2π/N for large N
For N=100: ω_opt ≈ 1.939
With ω = ω_opt the iteration count drops from O(N²) to O(N) — a fundamentally different order! For N=100 this means ~28 000 → ~300 iterations.
| Method | Iterations (N=100, ε=10⁻⁶) | Complexity |
|---|---|---|
| Jacobi | ~28 000 | O(N²) |
| Gauss-Seidel | ~14 000 | O(N²) |
| SOR (ω_opt) | ~280 | O(N) |
| Multigrid (V-cycle) | ~15 | O(N log N) |
class PoissonSolverSOR {
constructor(N, h = 1.0 / N) {
this.N = N;
this.h2 = h * h;
this.omega = 2 / (1 + Math.sin(Math.PI / N)); // ω_opt
this.phi = new Float64Array(N * N); // potential
this.rho = new Float64Array(N * N); // ρ/ε₀
this.fixed = new Uint8Array(N * N); // Dirichlet BC mask
}
setCharge(i, j, value) { this.rho[i * this.N + j] = -value; }
setFixed(i, j, value) {
const idx = i * this.N + j;
this.phi[idx] = value;
this.fixed[idx] = 1;
}
step(iterations = 1) {
const { N, h2, omega, phi, rho, fixed } = this;
for (let it = 0; it < iterations; it++) {
for (let color = 0; color < 2; color++) {
for (let i = 1; i < N - 1; i++) {
for (let j = 1; j < N - 1; j++) {
if ((i + j) % 2 !== color) continue;
const idx = i * N + j;
if (fixed[idx]) continue;
const gs = (
phi[(i+1)*N+j] + phi[(i-1)*N+j] +
phi[i*N+j+1] + phi[i*N+j-1] - h2 * rho[idx]
) / 4;
phi[idx] += omega * (gs - phi[idx]);
}
}
}
}
}
residual() {
const { N, h2, phi, rho, fixed } = this;
let max = 0;
for (let i = 1; i < N - 1; i++) {
for (let j = 1; j < N - 1; j++) {
const idx = i * N + j;
if (fixed[idx]) continue;
const lap = (
phi[(i+1)*N+j] + phi[(i-1)*N+j] +
phi[i*N+j+1] + phi[i*N+j-1] - 4*phi[idx]
) / h2;
max = Math.max(max, Math.abs(lap + rho[idx]));
}
}
return max;
}
}
6. Dirichlet and Neumann Boundary Conditions
Dirichlet (1st kind)
φ = const on the boundary. Conductors with fixed potential. Implementation: restore boundary node values after each iteration.
Neumann (2nd kind)
∂φ/∂n = const on the boundary. Insulated conductor (normal component of E = 0 → ∂φ/∂n = 0). Approximated with ghost cells.
Robin (3rd kind)
α·φ + β·∂φ/∂n = γ. Surface electric charge or absorption condition. Linear combination of the previous two.
Conductor patch
Conductor — a connected region with constant φ and zero internal charges (follows automatically from Laplace's equation).
Ghost Cells for Neumann Condition
∂φ/∂n = 0 on the right boundary (j = N-1):
φ_{i, N} = φ_{i, N-2} (symmetry across the boundary)
Finite-difference form: φ_{i, N-1} ≈ φ_{i, N-2}
7. Equipotential Lines and Field Lines: JS Implementation
Equipotential Lines
Isoline at level c: the set of points where φ(x,y) = c. The Marching Squares algorithm iterates over grid squares and determines the level-crossing configuration:
function marchingSquaresIsolines(grid, N, levels) {
const segments = [];
for (const level of levels) {
for (let i = 0; i < N - 1; i++) {
for (let j = 0; j < N - 1; j++) {
const v = [
grid[i*N+j], grid[i*N+j+1],
grid[(i+1)*N+j], grid[(i+1)*N+j+1]
];
const code = (+(v[0]>level)) | (+(v[1]>level))<<1
| (+(v[2]>level))<<2 | (+(v[3]>level))<<3;
if (code === 0 || code === 15) continue; // inside or outside
const pts = msLookup(code, v, level, i, j); // lookup table
if (pts) segments.push(...pts);
}
}
}
return segments;
}
Electric Field Lines
A field line is a curve tangent to the vector E = −∇φ at every point. Integrate using RK4 from a starting point (or a set of points around charges):
function traceFieldLine(phi, N, startI, startJ, stepSize = 0.5, maxSteps = 2000) {
const path = [[startI, startJ]];
let [ci, cj] = [startI, startJ];
const grad = (i, j) => {
const gx = (phi[Math.round(i+1)*N + Math.round(j)]
- phi[Math.round(i-1)*N + Math.round(j)]) / 2;
const gy = (phi[Math.round(i)*N + Math.round(j+1)]
- phi[Math.round(i)*N + Math.round(j-1)]) / 2;
return [gx, gy];
};
for (let s = 0; s < maxSteps; s++) {
const [gx, gy] = grad(ci, cj);
const mag = Math.hypot(gx, gy);
if (mag < 1e-6) break;
ci -= stepSize * gx / mag; // E = -∇φ → negate (line from +)
cj -= stepSize * gy / mag;
if (ci < 0 || ci >= N || cj < 0 || cj >= N) break;
path.push([ci, cj]);
}
return path;
}
8. Multigrid and Applications
Multigrid
The reason SOR is slow: it only eliminates "short-wave" errors per iteration, while "long-wave" (smooth) errors persist. Multigrid fixes this: it moves to coarser grids where long-wave components become short-wave and are quickly suppressed (V-cycle or W-cycle):
V-cycle: Relax → Restrict → Coarse solve → Prolongate → Relax
Complexity: O(N²) total — effectively O(1) iterations!
Capacitor
Two parallel plates with φ = ±1. Field lines are parallel. Equal equipotential spacing demonstrates a uniform field.
Point Charge
Spherical symmetry: φ ∝ 1/r. Equipotentials are circles, field lines are radial. Tests discretisation accuracy.
Dipole
+q and −q. Classic dipolar field line pattern. SOR converges in ~300 iterations on a 256×256 grid.
Charge Near a Conductor
Method of images. FDM gives the same result, but automatically for arbitrary conductor shapes.