The Lorenz Strange Attractor: From Navier-Stokes Fluid Convection to 3D Fractal Chaos



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.

▶ 3D Lorenz Strange Attractor (Orbiting Camera Simulation)
Download 1080p MP4 ⬇

3D Lorenz Attractor Swarm Animation

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:

Incompressibility:
$$\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$$

Physical Meaning of the Variables:

  • $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]$$

The Profound Paradox: The volume of any initial set decays exponentially to zero as $t \to \infty$. All trajectories are sucked into a limiting subset of zero Euclidean volume. Yet, because the system is bounded and sensitive to initial conditions ($\lambda_1 > 0$), the trajectories can never intersect (by uniqueness of ODE solutions) nor repeat. Therefore, the attractor cannot be a point, a closed loop, or a 2D surface—it must be a fractal with fractional Hausdorff dimension $D \approx 2.06$!

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:

lorenz_chaos_3d.py — Manim Community 3D Engine
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:

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *