← Back to Main Site

Introduction

The ASCII Santa Hat renderer is a terminal-based 3D graphics engine that demonstrates fundamental computer graphics concepts using nothing but text characters. Built on the same principles as the famous donut.c, this project extends those ideas to create a more complex, multi-part 3D model.

💡 What You'll Learn

This documentation covers 3D rotation matrices, perspective projection, depth buffering, parametric surface generation, and ASCII-based rendering techniques.

Key Features

Installation

Requirements

Setup

# Clone the repository git clone https://github.com/yourusername/ascii-santa-hat.git # Navigate to directory cd ascii-santa-hat # Run the renderer python main.py
⚠️ Windows Users

On Windows, the script automatically enables ANSI color support. For best results, use Windows Terminal or a modern terminal emulator.

Quick Start

Getting the Santa Hat spinning on your terminal is as simple as running the main script. The renderer will clear your terminal and begin the animation immediately.

python main.py

To stop the animation, press Ctrl+C. The cursor will be restored automatically.

What You'll See

                  @@@@
                @@@@@@@
              @@@@@@@@@
            @@@@@@@@@@@
          @@@@@@@@@@@@@@@
        @@@@@@@@@@@@@@@@@@@
=============================================
*** MERRY CHRISTMAS *** HO HO HO ***
=============================================

Architecture

The renderer follows a classic 3D graphics pipeline adapted for ASCII output. Understanding this flow is essential to customizing or extending the system.

Pipeline Overview

  1. Geometry Generation: Create 3D points using parametric equations
  2. Rotation: Apply X and Y axis rotation matrices
  3. Translation: Move object in front of camera (z += 6.0)
  4. Projection: Convert 3D coordinates to 2D screen space
  5. Z-Buffer Test: Compare depth values to determine visibility
  6. Lighting: Calculate luminance using dot product
  7. ASCII Mapping: Select character based on brightness
  8. Output: Print frame to terminal
🎯 Core Principle

Every pixel is rendered independently. The z-buffer ensures that closer points always overwrite farther ones, creating proper 3D occlusion without complex sorting algorithms.

Rendering Pipeline

The rendering pipeline is executed for every point on the surface of our 3D model. Let's break down each stage in detail.

Stage 1: Point Generation

Each surface is parameterized using two variables (typically t and θ) that sweep across the surface:

# Hat body example for t_step in range(0, 100, 2): t = t_step / 100.0 rad = 1.8 * (1 - t) spine_y = -1.5 + 3.5 * t - 1.0 * (t**3) spine_x = 1.8 * (t**2) for theta_step in range(0, 628, 8): theta = theta_step / 100.0 x = spine_x + rad * math.cos(theta) y = spine_y z = rad * math.sin(theta)

Stage 2: Transformation

The plot_pixel function handles all transformation, projection, and rendering logic:

def plot_pixel(x, y, z, color, luminance_val): # Rotate around X-axis y2 = y * cosA - z * sinA z2 = y * sinA + z * cosA # Rotate around Y-axis x3 = x * cosB - z2 * sinB y3 = y2 z3 = x * sinB + z2 * cosB # Move in front of camera z3 += 6.0

Geometry System

The Santa Hat is composed of three distinct parametric surfaces. Each surface is defined mathematically and sampled at discrete intervals.

Hat Body (Curved Cone)

The main body uses a lofted surface technique where a circle of varying radius follows a curved spine:

rad(t) = 1.8 × (1 - t) spineₓ(t) = 1.8 × t² spineᵧ(t) = -1.5 + 3.5t - t³ x = spineₓ(t) + rad(t)·cos(θ) y = spineᵧ(t) z = rad(t)·sin(θ) where t ∈ [0, 1], θ ∈ [0, 2π]

The radius shrinks linearly from base to tip, while the spine curves to create the characteristic bent Santa hat shape.

Rim (Torus)

The fluffy white rim is a standard torus positioned at the base of the hat:

R = 1.7 (major radius) r = 0.4 (minor radius) x = (R + r·cos(φ))·cos(θ) y = -1.5 + r·sin(φ) z = (R + r·cos(φ))·sin(θ) where θ, φ ∈ [0, 2π]

Pom-Pom (Sphere)

The pom-pom at the tip is a sphere using latitude-longitude parameterization:

Tip position: (1.8, 1.0, 0) Radius: 0.45 x = tipₓ + r·cos(lat)·cos(lon) y = tipᵧ + r·sin(lat) - 0.1 z = r·cos(lat)·sin(lon) where lat ∈ [-π/2, π/2], lon ∈ [0, 2π]

Projection Math

The renderer uses perspective projection to create depth. This is the same technique used in the classic donut.c demo.

The 1/z Trick

After rotation, each point has coordinates (x₃, y₃, z₃) in camera space. We compute:

ooz = 1 / z₃

This "one over z" value serves two purposes:

  1. Projection: Larger 1/z values mean closer points
  2. Depth comparison: Used directly in the z-buffer

Screen Coordinates

xₛcᵣₑₑₙ = WIDTH/2 + Sₓ × (ooz × x₃) yₛcᵣₑₑₙ = HEIGHT/2 + 2 - Sᵧ × (ooz × y₃) where: Sₓ = 55 (horizontal scale) Sᵧ = 30 (vertical scale) +2 = vertical offset to prevent clipping
📐 Why This Works

This projection mimics how our eyes work: distant objects appear smaller. The multiplication by ooz makes farther points (larger z₃, smaller ooz) compress toward the center.

Rotation Matrices

The renderer applies two successive rotations to achieve 3D spinning. These are standard 3D rotation matrices from linear algebra.

X-Axis Rotation (Tilt)

Controlled by angle A, this rotates points in the YZ plane:

y₂ = y·cos(A) - z·sin(A) z₂ = y·sin(A) + z·cos(A) x remains unchanged

Y-Axis Rotation (Spin)

Controlled by angle B, this rotates points in the XZ plane:

x₃ = x·cos(B) - z₂·sin(B) z₃ = x·sin(B) + z₂·cos(B) y₃ = y₂ (unchanged)

Animation

The spinning effect is achieved by incrementing B each frame:

B += 0.15 # Radians per frame time.sleep(0.03) # ~33 fps
Parameter Value Description
A 0.5 Fixed tilt angle (radians)
B 0 → ∞ Continuously increasing spin angle
ΔB 0.15 Rotation speed per frame

Z-Buffer

The z-buffer is a critical component that enables proper depth occlusion without sorting geometry.

How It Works

For each pixel position on the screen, we store the depth (ooz) of the closest point rendered so far:

zbuffer = [0] * (WIDTH * HEIGHT) output = [" "] * (WIDTH * HEIGHT) # For each point: idx = xp + yp * WIDTH if ooz > zbuffer[idx]: zbuffer[idx] = ooz output[idx] = color + char + RESET

Why 1/z?

Using the inverse depth (ooz = 1/z) has a clever property:

🎯 Key Insight

The z-buffer test happens after projection. This means we never need to sort our geometry or worry about draw order—the z-buffer handles everything automatically.

Lighting & Shading

The renderer uses a simple but effective diffuse lighting model based on the dot product of surface normals with a light direction vector.

Normal Calculation

For each surface, we approximate the normal vector. For the hat body:

nx = math.cos(theta) ny = 0.5 nz = math.sin(theta) # Light direction: (0.5, 0.5, -0.5) L = nx * 0.5 + ny * 0.5 - nz * 0.5

ASCII Character Mapping

The luminance value determines which ASCII character to use:

chars = ".,-~:;=!*#$@" lum_idx = int(L * 8) lum_idx = max(0, min(len(chars)-1, lum_idx)) char = chars[lum_idx]
Luminance Character Visual Density
0.0 - 0.1 . Very dim
0.1 - 0.3 ,-~ Dim
0.3 - 0.6 :;= Medium
0.6 - 0.9 !*# Bright
0.9 - 1.0 $@ Very bright

Animation Loop

The main render loop handles frame generation, terminal control, and timing.

Frame Structure

Frame Structure

while True: # 1. Reset buffers zbuffer = [0] * (WIDTH * HEIGHT) output = [" "] * (WIDTH * HEIGHT) # 2. Compute trig values once per frame sinA, cosA = math.sin(A), math.cos(A) sinB, cosB = math.sin(B), math.cos(B) # 3. Render all geometry render_hat_body() render_rim() render_pompom() # 4. Overlay billboard text render_billboard() # 5. Print to terminal sys.stdout.write("\x1b[H") # Move cursor to top-left sys.stdout.write("".join(output)) sys.stdout.flush() # 6. Update animation state B += 0.15 text_offset += 1 time.sleep(0.03) # ~33 fps

Terminal Control Sequences

Sequence Purpose
\x1b[H Move cursor to home position (0,0)
\x1b[?25l Hide cursor
\x1b[?25h Show cursor (on exit)
\x1b[31m Red text color
\x1b[0m Reset all formatting
⚡ Performance Tip

Computing sin and cos values once per frame (rather than per-pixel) significantly improves performance. With thousands of pixels, this optimization is crucial for maintaining smooth framerates.

Configuration

The renderer can be customized by modifying constants at the top of the file.

Display Settings

Parameter Default Description
WIDTH 80 Terminal width in characters
HEIGHT 30 Terminal height in characters
Scale X 55 Horizontal projection scale
Scale Y 30 Vertical projection scale

Animation Settings

Parameter Default Description
A (tilt) 0.5 Fixed X-axis rotation angle
B increment 0.15 Spin speed (radians/frame)
Frame delay 0.03 Seconds between frames (~33 fps)
Text scroll 1 Characters to scroll per frame

Geometry Parameters

# Hat body sampling t_step_size = 2 # Vertical resolution theta_step_size = 8 # Horizontal resolution # Rim sampling rim_theta_step = 12 rim_phi_step = 15 # Pom-pom sampling lat_step = 30 lon_step = 30
⚠️ Performance Trade-offs

Smaller step sizes produce smoother surfaces but require more computation. Increase step sizes if you experience lag on slower systems.

Customization

Learn how to modify the renderer to create your own 3D ASCII art or adjust the Santa Hat's appearance.

Changing Colors

# ANSI color codes RED_TXT = "\x1b[31m" GREEN_TXT = "\x1b[32m" YELLOW_TXT = "\x1b[33m" BLUE_TXT = "\x1b[34m" MAGENTA_TXT = "\x1b[35m" CYAN_TXT = "\x1b[36m" WHITE_TXT = "\x1b[37m" # Use in plot_pixel calls plot_pixel(x, y, z, BLUE_TXT, L)

Creating New Shapes

To add a new parametric surface, follow this pattern:

# Example: Cylinder def render_cylinder(): radius = 1.0 height = 3.0 for h_step in range(0, 100, 5): h = (h_step / 100.0) * height - height/2 for theta_step in range(0, 628, 10): theta = theta_step / 100.0 x = radius * math.cos(theta) y = h z = radius * math.sin(theta) # Normal points outward from cylinder axis nx = math.cos(theta) ny = 0 nz = math.sin(theta) L = nx * 0.5 + ny * 0.5 - nz * 0.5 if L > 0: plot_pixel(x, y, z, GREEN_TXT, L)

Adjusting the Hat Shape

Modify the spine curves to change the hat's profile:

# Original curved hat spine_y = -1.5 + 3.5 * t - 1.0 * (t**3) spine_x = 1.8 * (t**2) # Straight cone spine_y = -1.5 + 4.0 * t spine_x = 0 # Exaggerated curve spine_y = -1.5 + 3.5 * t - 2.0 * (t**3) spine_x = 2.5 * (t**2)

Custom ASCII Character Sets

# Default chars = ".,-~:;=!*#$@" # High contrast chars = " .:-=+*#%@" # Block style chars = " ░▒▓█" # Dots only chars = ".·∙•●"

Performance

Understanding performance characteristics helps you optimize the renderer for your system.

Complexity Analysis

The rendering complexity is O(n) where n is the total number of points sampled:

Total points = (hat_points) + (rim_points) + (pompom_points) Hat: (100/2) × (628/8) ≈ 3,925 points Rim: (628/12) × (628/15) ≈ 2,190 points Pom: (300/30) × (628/30) ≈ 210 points Total ≈ 6,325 points per frame

Optimization Techniques

1. Reduce Sampling Resolution

# Lower resolution (faster) for t_step in range(0, 100, 4): # Was 2 for theta_step in range(0, 628, 16): # Was 8

2. Precompute Constants

# Compute once per frame, not per pixel sinA, cosA = math.sin(A), math.cos(A) sinB, cosB = math.sin(B), math.cos(B) # Precompute projection constants half_width = WIDTH / 2 half_height = HEIGHT / 2

3. Skip Invisible Faces

# Only render if facing camera L = nx * 0.5 + ny * 0.5 - nz * 0.5 if L > 0: # Backface culling plot_pixel(x, y, z, color, L)

Typical Performance

System FPS Notes
Modern Desktop 30-60 Smooth animation
Laptop 20-30 Good performance
Raspberry Pi 10-20 Playable, reduce resolution if needed
💡 Terminal Performance

Terminal emulator choice significantly affects performance. Modern GPU-accelerated terminals (Alacritty, Kitty, Windows Terminal) perform much better than legacy terminals.

Mathematical Foundations

A deeper dive into the mathematics that make this renderer work.

Coordinate Systems

The renderer uses three coordinate systems:

  1. Model Space: Original geometry coordinates
  2. World/Camera Space: After rotation (x₃, y₃, z₃)
  3. Screen Space: 2D terminal coordinates (xₚ, yₚ)

Perspective Projection Derivation

Consider a point P at (x, y, z) viewed from the origin. The camera has a projection plane at distance K₂ from the viewer:

Similar triangles give us: x' / K₁ = x / (K₂ + z) Therefore: x' = (K₁ × x) / (K₂ + z) y' = (K₁ × y) / (K₂ + z) Rewrite as: x' = K₁ × x × (1 / (K₂ + z)) y' = K₁ × y × (1 / (K₂ + z)) In our implementation: ooz = 1 / z₃ (we already moved object by K₂=6.0) xₚ = center_x + K₁ × x₃ × ooz yₚ = center_y - K₁ × y₃ × ooz

Why Two Rotations?

A single rotation around one axis only allows 1 degree of freedom. By combining X and Y rotations, we achieve full 3D orientation control. This is the basis of Euler angles.

📚 Further Reading

For a deeper understanding of 3D graphics mathematics, see Fundamentals of Computer Graphics by Shirley & Marschner, or explore the original donut.c explanation by Andy Sloane.

Troubleshooting

Common Issues

Colors Not Showing

Symptom: ASCII characters render but no colors appear.

Solution: Ensure your terminal supports ANSI colors. On Windows, use Windows Terminal or enable ANSI support with os.system('color').

Hat Appears Clipped

Symptom: Bottom of hat is cut off.

Solution: Adjust the vertical offset in projection:

yp = int((HEIGHT / 2 + 2) - 30 * ooz * y3) # ↑ Increase this value

Slow Performance

Symptom: Animation is choppy or laggy.

Solutions:

Division by Zero Error

Symptom: Crash with ZeroDivisionError.

Solution: Add protection in plot_pixel:

if z3 == 0: z3 = 0.001 ooz = 1 / z3

Examples & Extensions

Example 1: Adding a Star

def render_star(): # Five-pointed star at top of hat star_x, star_y = 1.8, 1.5 for i in range(5): angle = (i * 2 * math.pi / 5) - math.pi / 2 for r_step in range(0, 30, 5): r = r_step / 100.0 x = star_x + r * math.cos(angle) y = star_y z = r * math.sin(angle) plot_pixel(x, y, z, YELLOW_TXT, 1.0)

Example 2: Multiple Hats

def render_hat_at_position(offset_x, offset_z): for t_step in range(0, 100, 2): # ... hat geometry code ... x = spine_x + rad * math.cos(theta) + offset_x z = rad * math.sin(theta) + offset_z plot_pixel(x, y, z, RED_TXT, L) # Render three hats render_hat_at_position(-3, 0) render_hat_at_position(0, 0) render_hat_at_position(3, 0)

Example 3: Interactive Controls

import sys, tty, termios, select def get_key(): if select.select([sys.stdin], [], [], 0)[0]: return sys.stdin.read(1) return None # In render loop: key = get_key() if key == 'w': A += 0.1 if key == 's': A -= 0.1 if key == 'a': B -= 0.2 if key == 'd': B += 0.2 if key == 'q': break

Credits & License

Inspiration

This project is heavily inspired by Andy Sloane's (a1k0n) legendary donut.c. The mathematical techniques and rendering approach are directly adapted from that work.

Author

Created with ❤️ for the holiday season and the joy of understanding computer graphics from first principles.

License

MIT License Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

Contributing

Contributions are welcome! Feel free to:

🎄 Share Your Creations

If you create something cool with this renderer, we'd love to see it! Tag your terminal recordings with #ASCIISantaHat

Happy Holidays! 🎅

May your terminals be merry and your renders be smooth.

Back to Top ↑