In 1963, meteorologist Edward Lorenz sought to model atmospheric convection using simplified fluid equations. Instead of periodic weather, his computer stumbled upon an infinite, non-repeating geometric object living in 3D phase space: The Lorenz Strange Attractor. In this deep dive, we derive the Lorenz equations from the Navier-Stokes equations, prove phase space volume contraction, and simulate a 36-particle swarm in 3D using Manim.
Figure 1: 3D orbital simulation of a 36-particle ensemble tracing out the fractal butterfly lobes in ℝ³.
1. From Navier-Stokes to Rayleigh-Bénard Convection
Consider a horizontal fluid layer of depth $H$ heated from below and cooled from above, creating a temperature gradient $\Delta T$. Under the Boussinesq approximation (where density variations are neglected except in the buoyancy force), the governing equations are:
$$\nabla \cdot \mathbf{u} = 0$$
Navier-Stokes (Momentum):
$$\frac{\partial \mathbf{u}}{\partial t} + (\mathbf{u} \cdot \nabla)\mathbf{u} = -\frac{1}{\rho_0}\nabla p + \nu \nabla^2 \mathbf{u} + g \alpha \theta \hat{\mathbf{z}}$$
Heat Conduction & Convection:
$$\frac{\partial \theta}{\partial t} + (\mathbf{u} \cdot \nabla)\theta = \frac{\Delta T}{H} w + \kappa \nabla^2 \theta$$
where $\mathbf{u} = (u, 0, w)$ is velocity, $\theta$ is temperature deviation, $\nu$ is kinematic viscosity, and $\kappa$ is thermal diffusivity.
2. Fourier Truncation: Lorenz’s 3-Mode Model
Lorenz expanded the stream function $\psi(x, z, t)$ (where $u = -\partial \psi / \partial z$ and $w = \partial \psi / \partial x$) and the temperature perturbation $\theta(x, z, t)$ into spatial Fourier modes, truncating at the lowest non-linear orders:
$$\psi(x, z, t) = \frac{\sqrt{2}\kappa(1 + a^2)}{a} X(t) \sin\left(\frac{\pi a x}{H}\right) \sin\left(\frac{\pi z}{H}\right)$$
$$\theta(x, z, t) = \frac{R_c}{\pi R} \Delta T \left[ \sqrt{2} Y(t) \cos\left(\frac{\pi a x}{H}\right) \sin\left(\frac{\pi z}{H}\right) – Z(t) \sin\left(\frac{2\pi z}{H}\right) \right]$$
Substituting these into the fluid equations and projecting onto each orthogonal Fourier mode yields the celebrated dimensionless Lorenz Equations:
$$\dot{x} = \sigma (y – x)$$
$$\dot{y} = x(\rho – z) – y$$
$$\dot{z} = xy – \beta z$$
- $x(t)$: Rate of convective overturning circulation (clockwise vs counter-clockwise).
- $y(t)$: Horizontal temperature difference between ascending and descending currents.
- $z(t)$: Distortion of the vertical temperature profile from pure linearity.
- $\sigma = \nu / \kappa$: Prandtl number (momentum diffusivity vs thermal diffusivity).
- $\rho = Ra / Ra_c$: Normalized Rayleigh number (ratio of buoyant to viscous forces).
- $\beta = 4 / (1 + a^2)$: Geometric factor related to the convection roll aspect ratio.
3. Mathematical Proof: Zero Volume in Phase Space
Consider a parcel of initial conditions in 3D state space $\mathbb{R}^3$ occupying a volume $V(t)$. The rate of volume expansion or contraction is determined by the divergence of the flow field $\mathbf{F} = [\dot{x}, \dot{y}, \dot{z}]^T$:
$$\nabla \cdot \mathbf{F} = \frac{\partial \dot{x}}{\partial x} + \frac{\partial \dot{y}}{\partial y} + \frac{\partial \dot{z}}{\partial z}$$
$$\nabla \cdot \mathbf{F} = -\sigma – 1 – \beta = -(\sigma + 1 + \beta)$$
For canonical values ($\sigma = 10, \beta = 8/3, \rho = 28$):
$$\nabla \cdot \mathbf{F} = -10 – 1 – \frac{8}{3} = -\frac{41}{3} \approx -13.67 < 0$$
Applying Liouville’s theorem for dynamical systems:
$$\frac{dV}{dt} = \int_{V(t)} (\nabla \cdot \mathbf{F}) \, dV = -\left(\sigma + 1 + \beta\right) V(t)$$
$$V(t) = V(0) \exp\left[-\left(\sigma + 1 + \beta\right)t\right]$$
4. The 36-Particle Swarm Simulation
In our Manim 3D animation, we initialize 36 particles inside a microscopic sphere of radius $r = 0.04$ centered at $(1.0, 1.0, 20.0)$.
As the 3D orbital camera sweeps across the coordinate plane:
- $t \in [0, 4\text{s}]$: The particles travel as a unified luminous filament. The micro-perturbations remain visually imperceptible.
- $t \in [4, 10\text{s}]$: When the swarm approaches the saddle point at the origin $(0,0,0)$, the nonlinear stretching term $xy$ tears the swarm apart. Some particles swing into the left wing while others spiral into the right wing.
- $t > 10\text{s}$: Complete spatial divergence occurs. The ensemble illuminates the entire two-lobed manifold, visually proving the existence of the strange attractor.
5. Full Manim 3D Community Source Code
Below is the complete, self-contained Python script implementing the 3D camera rotation and particle swarm numerical integration:
Python 3
"""
3D Lorenz Strange Attractor & Fractal Particle Ensemble
Engine: Manim Community Edition (ThreeDScene)
Visual Standard: 3D Orbiting Camera, 60+ Particle Swarm with Velocity Gradient Trails
"""
import numpy as np
from manim import *
# Aesthetic Palette
COLOR_BG = "#08090C"
SIGMA = 10.0
RHO = 28.0
BETA = 8.0 / 3.0
def lorenz_deriv(pos):
x, y, z = pos
dx = SIGMA * (y - x)
dy = x * (RHO - z) - y
dz = x * y - BETA * z
return np.array([dx, dy, dz])
def rk4_step_3d(pos, dt):
k1 = lorenz_deriv(pos)
k2 = lorenz_deriv(pos + 0.5 * dt * k1)
k3 = lorenz_deriv(pos + 0.5 * dt * k2)
k4 = lorenz_deriv(pos + dt * k3)
return pos + (dt / 6.0) * (k1 + 2 * k2 + 2 * k3 + k4)
# Velocity to Color Interpolation
def get_velocity_color(speed):
# Normalized speed range approximately 5 to 60
t = np.clip((speed - 8.0) / 45.0, 0.0, 1.0)
# Color ramp: Deep Cyan -> Electric Violet -> Bright Magenta -> Golden Yellow
c1 = np.array([0x00, 0xF2, 0xFE]) / 255.0 # Cyan
c2 = np.array([0x79, 0x28, 0xCA]) / 255.0 # Violet
c3 = np.array([0xFF, 0x00, 0x7F]) / 255.0 # Pink
c4 = np.array([0xFF, 0xD1, 0x66]) / 255.0 # Gold
if t < 0.33:
alpha = t / 0.33
rgb = (1 - alpha) * c1 + alpha * c2
elif t < 0.66:
alpha = (t - 0.33) / 0.33
rgb = (1 - alpha) * c2 + alpha * c3
else:
alpha = (t - 0.66) / 0.34
rgb = (1 - alpha) * c3 + alpha * c4
return rgb_to_color(rgb)
class LorenzAttractor3D(ThreeDScene):
"""
3D Master Scene with dynamic orbital camera and luminous particle swarm.
"""
def construct(self):
self.camera.background_color = COLOR_BG
# 3D Axes (Subtle, minimalist)
axes = ThreeDAxes(
x_range=[-30, 30, 10],
y_range=[-30, 30, 10],
z_range=[0, 50, 10],
x_length=7,
y_length=7,
z_length=6,
axis_config={"stroke_color": "#22252e", "stroke_width": 1.5, "stroke_opacity": 0.5}
)
# Shift coordinate center to balance the attractor visually
axes.shift(IN * 1.5 + DOWN * 0.5)
# Scale and translation helper: transforms Lorenz (x,y,z) to scene coordinates
def to_scene_coords(pt):
# Center of the attractor is roughly (0, 0, 25)
scale = 0.14
return np.array([pt[0] * scale, pt[1] * scale, (pt[2] - 25.0) * scale])
# Cinematic Camera Initialization
self.set_camera_orientation(phi=65 * DEGREES, theta=-55 * DEGREES, zoom=1.15)
# Fixed 2D Overlay Title & Equations (Rendered in screen space)
self.begin_ambient_camera_rotation(rate=0.14)
title = Text("The Lorenz Strange Attractor", font="Sans", font_size=28, color="#F3F4F6")
subtitle = Text("Fractal Topology & Deterministic Chaos in ℝ³", font="Sans", font_size=16, color="#00F2FE")
title_group = VGroup(title, subtitle).arrange(DOWN, aligned_edge=LEFT, buff=0.15).to_corner(UL, buff=0.5)
eq1 = Text("dx/dt = σ(y - x)", font="Monospace", font_size=15, color="#9CA3AF")
eq2 = Text("dy/dt = x(ρ - z) - y", font="Monospace", font_size=15, color="#9CA3AF")
eq3 = Text("dz/dt = xy - βz", font="Monospace", font_size=15, color="#9CA3AF")
eq_group = VGroup(eq1, eq2, eq3).arrange(DOWN, aligned_edge=LEFT, buff=0.12).next_to(title_group, DOWN, buff=0.3)
self.add_fixed_in_frame_mobjects(title_group, eq_group)
self.play(FadeIn(title_group, shift=DOWN*0.2), FadeIn(eq_group, shift=DOWN*0.2), run_time=1.2)
# Ensemble of 35 particles initialized in a tight sphere of radius 0.05 around (1.0, 1.0, 20.0)
NUM_PARTICLES = 36
np.random.seed(42)
base_origin = np.array([1.0, 1.0, 20.0])
perturbations = np.random.normal(0, 0.04, size=(NUM_PARTICLES, 3))
# Color spectrum for initial swarm
colors = [
interpolate_color(ManimColor("#00F2FE"), ManimColor("#FF007F"), i / NUM_PARTICLES)
for i in range(NUM_PARTICLES)
]
# Create Particle Dots and Traced Trails
particles = []
trails = []
states = []
for i in range(NUM_PARTICLES):
init_pos = base_origin + perturbations[i]
states.append(init_pos)
scene_pos = to_scene_coords(init_pos)
# Particle dot
dot = Dot3D(point=scene_pos, radius=0.06, color=colors[i])
# High-fidelity glowing trail
trail = TracedPath(
dot.get_center,
stroke_color=colors[i],
stroke_width=2.2,
dissipating_time=4.0,
stroke_opacity=0.85
)
particles.append(dot)
trails.append(trail)
self.add(trail, dot)
# Numerical Integration Updater
dt = 0.015
sub_steps = 4
sub_dt = dt / sub_steps
def update_swarm(mob, *args, **kwargs):
nonlocal states
for i in range(NUM_PARTICLES):
cur = states[i]
for _ in range(sub_steps):
cur = rk4_step_3d(cur, sub_dt)
states[i] = cur
particles[i].move_to(to_scene_coords(cur))
# Attach updater to root object
axes.add_updater(update_swarm)
self.add(axes)
# Orbit and observe the fractal butterfly wings emerge
# Phase 1: Close clustering
self.wait(4.0)
# Phase 2: Divergence into both lobes
phase2_text = Text("Swarm Divergence Across Lobes", font="Sans", font_size=18, color="#FFD166")
phase2_text.to_edge(DOWN, buff=0.6)
self.add_fixed_in_frame_mobjects(phase2_text)
self.play(FadeIn(phase2_text), run_time=0.8)
self.wait(8.0)
# Phase 3: Dynamic camera tilt sweep
self.move_camera(phi=75 * DEGREES, theta=60 * DEGREES, zoom=1.25, run_time=6.0)
self.wait(6.0)
# Clean wrap up
axes.clear_updaters()
self.stop_ambient_camera_rotation()
self.wait(1.0)
class LorenzAttractorShorts(ThreeDScene):
"""
Vertical 9:16 Ultra-Cinematic Scene for YouTube Shorts & Reels.
Tightly framed, fast visual hook, vertical text layout.
"""
def construct(self):
self.camera.background_color = COLOR_BG
def to_scene_coords(pt):
scale = 0.13
return np.array([pt[0] * scale, pt[1] * scale, (pt[2] - 25.0) * scale])
self.set_camera_orientation(phi=70 * DEGREES, theta=-45 * DEGREES, zoom=1.0)
self.begin_ambient_camera_rotation(rate=0.18)
# Top Vertical Hook
hook = Text("THE BUTTERFLY OF CHAOS", font="Sans", font_size=28, weight=BOLD, color="#FFD166")
hook.to_edge(UP, buff=1.0)
sub = Text("35 Parallel Realities in ℝ³", font="Sans", font_size=16, color="#00F2FE")
sub.next_to(hook, DOWN, buff=0.18)
cta = Text("Math proof & Python code at:\nmath.praman.blog", font="Sans", font_size=18, color="#FFFFFF", line_spacing=1.3)
cta.to_edge(DOWN, buff=1.0)
self.add_fixed_in_frame_mobjects(hook, sub, cta)
self.play(FadeIn(hook, scale=1.1), FadeIn(sub), run_time=1.0)
NUM_PARTICLES = 36
np.random.seed(99)
base_origin = np.array([0.1, 1.0, 10.0])
perturbations = np.random.normal(0, 0.05, size=(NUM_PARTICLES, 3))
colors = [
interpolate_color(ManimColor("#00F2FE"), ManimColor("#FF007F"), i / NUM_PARTICLES)
for i in range(NUM_PARTICLES)
]
particles = []
trails = []
states = []
dummy_anchor = Dot(ORIGIN, radius=0).set_opacity(0)
self.add(dummy_anchor)
for i in range(NUM_PARTICLES):
init_pos = base_origin + perturbations[i]
states.append(init_pos)
scene_pos = to_scene_coords(init_pos)
dot = Dot3D(point=scene_pos, radius=0.07, color=colors[i])
trail = TracedPath(
dot.get_center,
stroke_color=colors[i],
stroke_width=2.6,
dissipating_time=4.5,
stroke_opacity=0.9
)
particles.append(dot)
trails.append(trail)
self.add(trail, dot)
dt = 0.016
sub_steps = 4
sub_dt = dt / sub_steps
def update_swarm(mob, *args, **kwargs):
nonlocal states
for i in range(NUM_PARTICLES):
cur = states[i]
for _ in range(sub_steps):
cur = rk4_step_3d(cur, sub_dt)
states[i] = cur
particles[i].move_to(to_scene_coords(cur))
dummy_anchor.add_updater(update_swarm)
self.wait(18.0)
dummy_anchor.clear_updaters()
self.stop_ambient_camera_rotation()
self.wait(0.5)
Download Assets & Reproduce
Direct high-definition assets for content creators, researchers, and educators:
