Santa Hat Documentation
A complete guide to understanding and customizing the ASCII 3D Santa Hat renderer. Learn the mathematics, implementation details, and how to create your own 3D ASCII art.
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.
This documentation covers 3D rotation matrices, perspective projection, depth buffering, parametric surface generation, and ASCII-based rendering techniques.
Key Features
- Three distinct parametric surfaces (cone, torus, sphere)
- Real-time 3D rotation on two axes
- Perspective projection using 1/z depth
- Z-buffer for proper depth occlusion
- Dot product-based lighting and shading
- ANSI color terminal support
- Scrolling text billboard overlay
Installation
Requirements
- Python 3.6 or higher
- Terminal with ANSI color support
- No external dependencies required
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
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
- Geometry Generation: Create 3D points using parametric equations
- Rotation: Apply X and Y axis rotation matrices
- Translation: Move object in front of camera (z += 6.0)
- Projection: Convert 3D coordinates to 2D screen space
- Z-Buffer Test: Compare depth values to determine visibility
- Lighting: Calculate luminance using dot product
- ASCII Mapping: Select character based on brightness
- Output: Print frame to terminal
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:
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:
Pom-Pom (Sphere)
The pom-pom at the tip is a sphere using latitude-longitude parameterization:
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:
This "one over z" value serves two purposes:
- Projection: Larger 1/z values mean closer points
- Depth comparison: Used directly in the z-buffer
Screen Coordinates
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-Axis Rotation (Spin)
Controlled by angle B, this rotates points in the XZ plane:
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:
- Larger ooz = smaller z = closer to camera
- Simple comparison: if ooz > zbuffer[idx]
- No need to invert z back for comparison
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 |
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
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:
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 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:
- Model Space: Original geometry coordinates
- World/Camera Space: After rotation (x₃, y₃, z₃)
- 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:
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.
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:
- Increase sampling step sizes (reduce point count)
- Reduce WIDTH and HEIGHT
- Use a GPU-accelerated terminal emulator
- Increase frame delay: time.sleep(0.05)
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:
- Add new shapes and models
- Improve performance
- Create new lighting models
- Enhance the documentation
- Report bugs and suggest features
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.