Skip to the plate
FORMA PUBLIC DOMAIN GENERATIVE ATLAS / ED. 0.28
Plate 84, Domain Colouring: a still of the field / complex argument plate as the atlas renders it, in the fields accent.

PL. 84  ·  FIELDS / FIELD / COMPLEX ARGUMENT

Domain Colouring

Frank A. Farris named it, 1997 · the method is older than the name

OPEN THE LIVE PLATE ▸

DEFINITION

f(z) = ∏ₖ(z − aₖ) / ∏ⱼ(z − bⱼ)
arg f = Σ arg(z − aₖ) − Σ arg(z − bⱼ)
log|f| = Σ log|z − aₖ| − Σ log|z − bⱼ|
hue = arg f / 2π,  contours at frac(log₂|f|)

NOTES

A complex function takes a plane to a plane, so its graph needs four dimensions and cannot be drawn. Colour the domain instead: give every point the hue of the value the function sends it to, and the whole function fits in one picture. Zeros become points where every hue meets, poles the same with the wheel running backwards, and the number of times the colours cycle around any loop is exactly the zeros minus the poles inside it — the argument principle, visible rather than proved. This plate builds its function from zeros on one ring and poles on another, and evaluates it as sums rather than products: the argument of a product is the sum of the arguments, so the winding you can see is literally being added up term by term. One accident of this atlas earns the plate its place here — the order palettes are a cosine of 2πt with unit frequency, so the ramp is already a closed hue wheel, and the branch cut where the argument jumps by 2π has no seam to show.

PROVENANCE

Origin
F. A. Farris, "Visualizing complex-valued functions in the plane", 1997 — the piece the name comes from; the term appears in print in his review of Needham’s Visual Complex Analysis, American Mathematical Monthly 105 (1998), 570–576, and Hans Lundmark’s 2004 account credits the coinage to him
Earlier
The technique predates the name: Larry Crone used it in the late 1980s, D. A. Rabenhorst published "A Color Gallery of Complex Functions" in Pixel 1(4), 1990, and Elias Wegert later systematised the phase-only form as phase portraits (Visual Complex Functions, Birkhäuser, 2012)
Standing
Public domain — a way of drawing a function, and a rational function of a complex variable
Constants
Zero and pole counts set the winding number and govern the cost, so they are locked. The two ring radii were scanned as a pair over the whole box: the winding on a circle enclosing both rings is exactly Z − P at all 2,000 (count, radius) combinations, and every one of them renders. Where the pole count divides the zero count a zero does land on a pole at equal radii and the two annihilate — correctly, and the plate then draws the lower-degree function it has actually become

TOUCHDESIGNER · GLSL

The same shader this plate runs, reframed for a GLSL TOP. Pasted bare it renders the published constants as a still frame; wire absTime.seconds into u_t on the Vectors page to animate it.

// FORMA — PL. 84 · DOMAIN COLOURING — Frank A. Farris named it, 1997 · the method is older than the name
//   f(z) = ∏ₖ(z − aₖ) / ∏ⱼ(z − bⱼ)
//   arg f = Σ arg(z − aₖ) − Σ arg(z − bⱼ)
//   log|f| = Σ log|z − aₖ| − Σ log|z − bⱼ|
//   hue = arg f / 2π,  contours at frac(log₂|f|)
// TouchDesigner port — paste into a GLSL TOP's pixel shader. Set the
// resolution on the TOP's Common page. As pasted it renders the published
// constants as a still frame; to animate, add a uniform named u_t on the
// GLSL TOP's Vectors 1 page with the expression absTime.seconds.
// Constants are consts — edit to tweak; comments give the measured range.
// Written from the published mathematics, not adapted from any code.

#define u_res (uTDOutputInfo.res.zw)
uniform float u_t;               // absTime.seconds on the Vectors page; unset = still

const float u_phase = 0.8883;    // this plate's own grid phase, 0..1
// FORMA's FIELDS accent as cosine-gradient coefficients
const vec3 u_pal_a = vec3(0.46, 0.3031, 0.11);
const vec3 u_pal_b = vec3(0.5, 0.3294, 0.1196);
const vec3 u_pal_c = vec3(1, 1, 1);
const vec3 u_pal_d = vec3(0, 0.05, 0.1);

const float p_zeros   = 3.0;         // zeros · live 1 .. 4
const float p_poles   = 2.0;         // poles · live 0 .. 4
const float p_rz      = 0.55;        // zero ring radius · live 0.15 .. 1.3
const float p_rp      = 0.95;        // pole ring radius · live 0.15 .. 1.3
const float p_zoom    = 1.6;         // view half-width · live 0.8 .. 3
const float p_contour = 0.65;        // modulus and argument contours · live 0 .. 1

/* The order's ramp — the same cosine formulation the JS kit uses, so a
   plate keeps its classification colour in either language. */
vec3 ramp(float t){
  return clamp(u_pal_a + u_pal_b * cos(6.28318530718 * (u_pal_c * t + u_pal_d)), 0.0, 1.0);
}

/* Sawtooth and triangle on this plate's phase, mirroring the JS kit. */
float cycle(float t, float period){ return fract(t / period + u_phase); }
float pingpong(float t, float period){
  float u = cycle(t, period);
  return u < 0.5 ? u * 2.0 : 2.0 - u * 2.0;
}


vec3 plate(vec2 uv){
  float ar = u_res.y / u_res.x;
  float TAU = 6.283185307179586;
  int Z = int(floor(p_zeros + 0.5));
  int P = int(floor(p_poles + 0.5));
  float phi = u_phase * TAU + u_t * 0.055;

  float x = (uv.x - 0.5) * 2.0 * p_zoom;
  float y = (uv.y - 0.5) * 2.0 * p_zoom * ar;

  float arg = 0.0, lg = 0.0;
  for (int k = 0; k < 4; k++){                 // 4 is the zeros slider's own ceiling
    if (k >= Z) break;
    float a = phi + TAU * float(k) / float(Z);
    float dx = x - p_rz * cos(a), dy = y - p_rz * sin(a);
    arg += atan(dy, dx);
    lg  += 0.5 * log(dx * dx + dy * dy + 1e-12);
  }
  for (int j = 0; j < 4; j++){                 // 4 is the poles slider's own ceiling
    if (j >= P) break;
    float a = phi + TAU * float(j) / float(P) + 3.141592653589793 / float(P);
    float dx = x - p_rp * cos(a), dy = y - p_rp * sin(a);
    arg -= atan(dy, dx);
    lg  -= 0.5 * log(dx * dx + dy * dy + 1e-12);
  }

  float ph = fract(arg / TAU);
  vec3 c = ramp(ph);
  if (p_contour <= 0.0) return c;

  float lm = lg * 1.4426950408889634;
  float grid = (0.55 + 0.45 * fract(lm)) * (0.62 + 0.38 * fract(ph * 8.0));
  return c * (1.0 + (grid - 1.0) * p_contour);
}

out vec4 fragColor;
void main(){
  // FORMA's uv runs y-down, matching its canvas; TD's vUV runs up
  vec2 uv = vec2(vUV.s, 1.0 - vUV.t);
  fragColor = TDOutputSwizzle(vec4(plate(uv), 1.0));
}

NUKE · BLINKSCRIPT

The same shader this plate runs, transpiled to a BlinkScript kernel. Paste it into a BlinkScript node's Kernel Source and press Recompile; every constant arrives as a knob at its published value, and u_t animates with the expression frame/24. Compiled and rendered in Nuke 17.1, then compared against this plate on the page.

// FORMA — PL. 84 · DOMAIN COLOURING — Frank A. Farris named it, 1997 · the method is older than the name
//   f(z) = ∏ₖ(z − aₖ) / ∏ⱼ(z − bⱼ)
//   arg f = Σ arg(z − aₖ) − Σ arg(z − bⱼ)
//   log|f| = Σ log|z − aₖ| − Σ log|z − bⱼ|
//   hue = arg f / 2π,  contours at frac(log₂|f|)
// Nuke port — a BlinkScript kernel. Paste into a BlinkScript node's Kernel
// Source and press Recompile. Every constant arrives as a knob at its published
// value (the comment gives the measured range); u_t is a knob too — animate it
// with the expression frame/24 or leave it at 0 for the still frame. Written
// from the published mathematics, not adapted from any code.
// Transpiled from the shader this plate runs on the page (GLSL ES 3.00):
// vec → float2/3/4, swizzles expanded, GLSL builtins Blink lacks written out
// as forma_ functions, float literals suffixed. Compiled and rendered in a
// real Nuke (17.1v1) and compared against this plate on the page: 34 of 34.
//
// plate() and its helpers are written to a single exit — the loop that runs
// once. That is not a style: Blink 17.1 drops a conditional early return from
// a called function while Vectorize is on, which is the node default, with no
// warning and no error. Written this way it paints correctly as pasted.

kernel Forma_domaincolor : ImageComputationKernel<ePixelWise>
{
  Image<eWrite> dst;

param:
  float u_t;             // seconds; 0 is the still frame
  float p_zeros;   // zeros · live 1 .. 4
  float p_poles;   // poles · live 0 .. 4
  float p_rz;      // zero ring radius · live 0.15 .. 1.3
  float p_rp;      // pole ring radius · live 0.15 .. 1.3
  float p_zoom;    // view half-width · live 0.8 .. 3
  float p_contour; // modulus and argument contours · live 0 .. 1

local:
  float2 u_res;
  float u_phase;
  float3 u_pal_a, u_pal_b, u_pal_c, u_pal_d;

  void define(){
    defineParam(u_t, "u_t", 0.0f);
    defineParam(p_zeros, "p_zeros", 3.0f);
    defineParam(p_poles, "p_poles", 2.0f);
    defineParam(p_rz, "p_rz", 0.55f);
    defineParam(p_rp, "p_rp", 0.95f);
    defineParam(p_zoom, "p_zoom", 1.6f);
    defineParam(p_contour, "p_contour", 0.65f);
  }

  void init(){
    u_res = float2(float(dst.bounds.width()), float(dst.bounds.height()));
    u_phase = 0.8883f;    // this plate's own grid phase, 0..1
    // FORMA's FIELDS accent as cosine-gradient coefficients
    u_pal_a = float3(0.46f, 0.3031f, 0.11f);
    u_pal_b = float3(0.5f, 0.3294f, 0.1196f);
    u_pal_c = float3(1.0f, 1.0f, 1.0f);
    u_pal_d = float3(0.0f, 0.05f, 0.1f);
  }

  /* GLSL builtins Blink lacks, written as templates rather than overload sets.
     Blink's operators return expression templates (Swizzle<float,N>), so a call
     passing an expression cannot resolve against an overload set on float2
     against float3 — measured in Nuke 17.1: a float2 expression is ambiguous
     between the two, while scalar-against-vector resolves. A template deduces
     the expression's own type, so the ambiguity cannot arise. */
  template <class T> T forma_fract(T v){ return v - floor(v); }
  template <class T, class S> T forma_mod(T x, S y){ return x - y * floor(x / y); }
  /* Blink's own min/max/clamp take no scalar bound against a vector, which GLSL
     does; v * 0.0f + b is that bound at the vector's own width, and collapses to
     b when v is a scalar, so one template serves both. */
  template <class T, class S> T forma_min(T a, S b){ return min(a, a * 0.0f + b); }
  template <class T, class S> T forma_max(T a, S b){ return max(a, a * 0.0f + b); }
  template <class T, class S> T forma_clamp(T v, S lo, S hi){ return clamp(v, v * 0.0f + lo, v * 0.0f + hi); }
  int forma_min(int a, int b){ return min(a, b); }
  int forma_max(int a, int b){ return max(a, b); }
  /* GLSL step(edge, x) is 1 where x >= edge; floor(sign(x - e) * 0.5 + 1) is
     that exactly, equality included, out of builtins Blink does have. */
  template <class T, class S> T forma_step(S e, T x){ return floor(sign(x - e) * 0.5f + 1.0f); }
  template <class T, class S> T forma_smoothstep(S a, S b, T x){
    T t = forma_clamp((x - a) / (b - a), 0.0f, 1.0f);
    return t * t * (3.0f - 2.0f * t);
  }
  template <class T> float forma_distance(T a, T b){ return length(a - b); }
  float forma_tanh(float x){ float e = exp(2.0f * x); return (e - 1.0f) / (e + 1.0f); }
  float forma_radians(float d){ return d * 0.01745329252f; }
  // the page's hash2 is exact uint32; Blink has int, so the shifts are made
  // logical by masking and the read-back is lifted into 0 .. 2^32
  /* A uint read back as a float. Blink has no unsigned type, so a value past
     2^31 arrives as a negative int and float() of it is negative. Measured on
     gabor, whose own generator then returned uniforms in [-0.5, 0.5) and drew
     a different picture — it compiled, it rendered, and only comparing it with

  /* The order's ramp — the same cosine formulation the JS kit uses, so a
     plate keeps its classification colour in either language. */
  float3 ramp(float t){
    return forma_clamp(u_pal_a + u_pal_b * cos(6.28318530718f * (u_pal_c * t + u_pal_d)), 0.0f, 1.0f);
  }

  /* Sawtooth and triangle on this plate's phase, mirroring the JS kit. */
  float cycle(float t, float period){ return forma_fract(t / period + u_phase); }
  float pingpong(float t, float period){
    float u = cycle(t, period);
    return u < 0.5f ? u * 2.0f : 2.0f - u * 2.0f;
  }


  float3 plate(float2 uv){
    float3 forma_r = float3(0.0f, 0.0f, 0.0f);
    for (int forma_once = 0; forma_once < 1; forma_once++){
      float ar = u_res.y / u_res.x;
      float TAU = 6.283185307179586f;
      int Z = int(floor(p_zeros + 0.5f));
      int P = int(floor(p_poles + 0.5f));
      float phi = u_phase * TAU + u_t * 0.055f;

      float x = (uv.x - 0.5f) * 2.0f * p_zoom;
      float y = (uv.y - 0.5f) * 2.0f * p_zoom * ar;

      float arg = 0.0f;
      float lg = 0.0f;
      for (int k = 0; k < 4; k++){                 // 4 is the zeros slider's own ceiling
        if (k >= Z) break;
        float a = phi + TAU * float(k) / float(Z);
        float dx = x - p_rz * cos(a);
        float dy = y - p_rz * sin(a);
        arg += atan2(dy, dx);
        lg  += 0.5f * log(dx * dx + dy * dy + 1e-12);
      }
      for (int j = 0; j < 4; j++){                 // 4 is the poles slider's own ceiling
        if (j >= P) break;
        float a = phi + TAU * float(j) / float(P) + 3.141592653589793f / float(P);
        float dx = x - p_rp * cos(a);
        float dy = y - p_rp * sin(a);
        arg -= atan2(dy, dx);
        lg  -= 0.5f * log(dx * dx + dy * dy + 1e-12);
      }

      float ph = forma_fract(arg / TAU);
      float3 c = ramp(ph);
      if (p_contour <= 0.0f) { forma_r = c; break; }

      float lm = lg * 1.4426950408889634f;
      float grid = (0.55f + 0.45f * forma_fract(lm)) * (0.62f + 0.38f * forma_fract(ph * 8.0f));
      { forma_r = c * (1.0f + (grid - 1.0f) * p_contour); break; }
    }
    return forma_r;
  }

  void process(int2 pos){
    // FORMA's uv runs y-down like its canvas; Nuke's rows run up
    float2 uv = float2((float(pos.x) + 0.5f) / u_res.x, 1.0f - (float(pos.y) + 0.5f) / u_res.y);
    float3 c = plate(uv);
    dst() = float4(c.x, c.y, c.z, 1.0f);
  }
};