PL. 83 · FIELDS / FIELD / BEAT FREQUENCY
Moiré Interference
Lord Rayleigh, 1874 · the effect is far older than its physics
OPEN THE LIVE PLATE ▸DEFINITION
g(θ) = ½ + ½·tanh(s·cos θ)/tanh s θ₁ = 2πf·(p·n̂₁), θ₂ = 2πfr·(p·n̂₂) − φ T(p) = g(θ₁)·g(θ₂) fringes where θ₁ − θ₂ ≡ 0 (mod 2π), spacing d / 2sin(α/2)
NOTES
Lay one ruled grating over another, turn it a hair, and a pattern appears that is in neither of them. The product of two periodic transmissions contains their difference frequency, and only the difference is coarse enough for an eye to resolve: at a rotation α the fringes stand d/2sin(α/2) apart, which at four degrees is fourteen times the ruling. That factor runs the other way too — slide one grating by a hair and the fringes sweep fourteen times as far, which is why moiré is a measuring instrument and not only a nuisance. Rayleigh used exactly this in 1874 to test gratings too fine to inspect under a microscope: compare one against a copy and its errors are magnified into something visible. The word is much older than the physics — it comes through French from mohair, the watered silk whose pressed layers do this by accident. This plate shows the product and nothing else; the beat is inside it, and extracting the difference term directly would be printing the answer instead of the phenomenon.
PROVENANCE
- Origin
- Lord Rayleigh, "On the manufacture and theory of diffraction-gratings", Philosophical Magazine (Series 4) 47, No. 310, 1874, pp. 81–93 and 193–205 — the first account of fringes from two superposed line families, and the first use of them as a test
- Earlier
- The effect was a textile artefact for centuries before it was optics; "moiré" reaches English via French mouaire from Arabic mukhayyar (mohair), recorded by Pepys in 1660, the adjective by 1823
- Standing
- Public domain — 19th-century optics
- Constants
- Fringes across the plate are pitch·√(1 + r² − 2r·cos α), so the rotation and the pitch ratio drive one quantity between them; either alone carries the beat. Rotation sets the fringe spacing and not the fringe contrast, which is why the whole angle range is live. The pitch ceiling is the pixel grid, not the mathematics
- Source
- doi:10.1080/14786447408640996
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. 83 · MOIRÉ INTERFERENCE — Lord Rayleigh, 1874 · the effect is far older than its physics
// g(θ) = ½ + ½·tanh(s·cos θ)/tanh s
// θ₁ = 2πf·(p·n̂₁), θ₂ = 2πfr·(p·n̂₂) − φ
// T(p) = g(θ₁)·g(θ₂)
// fringes where θ₁ − θ₂ ≡ 0 (mod 2π), spacing d / 2sin(α/2)
// 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.737; // 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_pitch = 28.0; // rulings across the plate · live 12 .. 48
const float p_angle = 5.0; // α — rotation between gratings (deg) · live 1.5 .. 20
const float p_ratio = 1.0; // r — pitch of B relative to A · live 0.9 .. 1.1
const float p_sharp = 1.2; // s — profile, sinusoid → bars · live 0.2 .. 4
const float p_speed = 0.04; // drift of grating B (pitches/s) · live 0.01 .. 0.4
/* 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;
}
float moire_grating(float phase, float sharp, float norm){
return 0.5 + 0.5 * tanh(sharp * cos(phase)) / norm;
}
vec3 plate(vec2 uv){
float ar = u_res.y / u_res.x;
float bearing = u_phase * 3.141592653589793;
float a = p_angle * 3.141592653589793 / 180.0;
float c1 = cos(bearing), s1 = sin(bearing);
float c2 = cos(bearing + a), s2 = sin(bearing + a);
float f = p_pitch * 6.283185307179586;
float slide = u_t * p_speed * 6.283185307179586;
float norm = tanh(p_sharp);
float y = uv.y * ar;
float g1 = moire_grating(f * (uv.x * c1 + y * s1), p_sharp, norm);
float g2 = moire_grating(f * p_ratio * (uv.x * c2 + y * s2) - slide, p_sharp, norm);
return ramp(0.50 + 0.58 * sqrt(g1 * g2));
}
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. 83 · MOIRÉ INTERFERENCE — Lord Rayleigh, 1874 · the effect is far older than its physics
// g(θ) = ½ + ½·tanh(s·cos θ)/tanh s
// θ₁ = 2πf·(p·n̂₁), θ₂ = 2πfr·(p·n̂₂) − φ
// T(p) = g(θ₁)·g(θ₂)
// fringes where θ₁ − θ₂ ≡ 0 (mod 2π), spacing d / 2sin(α/2)
// 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_moire : ImageComputationKernel<ePixelWise>
{
Image<eWrite> dst;
param:
float u_t; // seconds; 0 is the still frame
float p_pitch; // rulings across the plate · live 12 .. 48
float p_angle; // α — rotation between gratings (deg) · live 1.5 .. 20
float p_ratio; // r — pitch of B relative to A · live 0.9 .. 1.1
float p_sharp; // s — profile, sinusoid → bars · live 0.2 .. 4
float p_speed; // drift of grating B (pitches/s) · live 0.01 .. 0.4
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_pitch, "p_pitch", 28.0f);
defineParam(p_angle, "p_angle", 5.0f);
defineParam(p_ratio, "p_ratio", 1.0f);
defineParam(p_sharp, "p_sharp", 1.2f);
defineParam(p_speed, "p_speed", 0.04f);
}
void init(){
u_res = float2(float(dst.bounds.width()), float(dst.bounds.height()));
u_phase = 0.737f; // 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;
}
float moire_grating(float phase, float sharp, float norm){
return 0.5f + 0.5f * forma_tanh(sharp * cos(phase)) / norm;
}
float3 plate(float2 uv){
float ar = u_res.y / u_res.x;
float bearing = u_phase * 3.141592653589793f;
float a = p_angle * 3.141592653589793f / 180.0f;
float c1 = cos(bearing);
float s1 = sin(bearing);
float c2 = cos(bearing + a);
float s2 = sin(bearing + a);
float f = p_pitch * 6.283185307179586f;
float slide = u_t * p_speed * 6.283185307179586f;
float norm = forma_tanh(p_sharp);
float y = uv.y * ar;
float g1 = moire_grating(f * (uv.x * c1 + y * s1), p_sharp, norm);
float g2 = moire_grating(f * p_ratio * (uv.x * c2 + y * s2) - slide, p_sharp, norm);
return ramp(0.50f + 0.58f * sqrt(g1 * g2));
}
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);
}
};