Pixels2GenAI
Path ii Continuum
M 08 · 8.3.1 · hands-on

8.3.1 Star Wars Titles

Build the iconic *Star Wars* opening crawl: yellow text on a starfield, projected into a receding trapezoid. Inverse perspective sampling, no shader required.

Duration25–30 min
Levelintermediate
Load
Prereqs8.1.1 (image operators), 8.1.3 (interpolation), basic trigonometry

Overview

The opening crawl of Star Wars: A New Hope (1977) is one of the most recognisable shots in cinema. Yellow capitalised text floats up into deep space, receding into a vanishing point so the lines at the top of the screen are smaller and closer together than the lines at the bottom. The technical achievement is not the typeface — it’s the perspective projection. The original 1977 sequence was shot on an animation stand with a paper crawl card pulled physically across a glass plate; modern recreations are a few lines of NumPy.

The trick is to invert the perspective transformation: for every pixel (x, y) on the output screen, compute the corresponding pixel (x0, y0) in the input text bitmap. Run the computation once at startup; reuse the index arrays every frame; scroll the text by shifting y0. The math comes from the optics of a tilted plane viewed from a fixed observer — geometry the medieval Italian painters worked out by hand in the 15th century [1].

Learning objectives

  1. Derive the perspective transformation: a tilted plane projected through a single-point pinhole.
  2. Implement inverse perspective sampling: for each output pixel, look up its source pixel in a tall unprojected bitmap.
  3. Precompute index arrays so the per-frame work is just fancy-indexing — no trigonometry in the hot loop.
  4. Add a starfield background and composite the projected text over it to recreate the iconic look.

Quick start — perspective scroll

python · quick_start.py
import numpy as np
from PIL import Image, ImageDraw, ImageFont

XSIZE, YSIZE = 720, 480
PERSPECTIVE_C = 220

def prepare_perspective(c=PERSPECTIVE_C):
    indices = {}
    xx = np.arange(XSIZE)
    for yy in range(1, YSIZE):
        y = 1 - yy / YSIZE                           # 0 at bottom, 1 at top
        y0 = int(-y * c / (y - 1))                   # source row offset
        x_off = xx - XSIZE // 2
        x_src = (x_off - x_off * y / (y - 1)).astype(int) + XSIZE // 2
        valid = (x_src >= 0) & (x_src < XSIZE)
        indices[yy] = (y0, x_src[valid], xx[valid])
    return indices
An animated GIF showing the Star Wars opening crawl: yellow text on a black starfield receding into the distance. The text scrolls upward and shrinks toward a vanishing point near the top of the screen, while stationary stars dot the background
Fig. 1 120-frame perspective scroll. Yellow Zen-of-Python text on a 720×480 canvas, projected through a single-point pinhole.

Core concepts

Concept 1 — The perspective transformation

A tilted plane viewed through a pinhole produces lines that converge toward a vanishing point. The mathematics: for a vertical screen coordinate $y$ in $[0, 1]$ (with $0$ at the vanishing point at the top of the screen and $1$ at the bottom), the source row $y_0$ on the unprojected text is

$y_0 = -y \cdot c / (y - 1)$

where $c$ is a constant controlling the “depth” of the scene. Larger $c$ makes the trapezoid taller; smaller $c$ makes it flatter. Picking $c \approx 200$ gives the classic Star Wars trapezoid aspect ratio.

The horizontal source coordinate scales similarly: pixels at the edge of the screen pull toward the centre as we move up the trapezoid. The full formula:

$x_0 = x_c - x_c \cdot y / (y - 1)$, where $x_c = x - W/2$ is the centred horizontal pixel coordinate.

Concept 2 — Inverse sampling beats forward projection

Two approaches to mapping pixels through a perspective:

  • Forward: for each source pixel (x0, y0), compute (x, y) on screen. Sets up gaps and overlapping pixels.
  • Inverse: for each output pixel (x, y), compute the source (x0, y0). Always paints every visible pixel exactly once.

Inverse is the standard graphics-pipeline approach for any non-trivial coordinate transform — used in every texture sampler in every shader. Forward sampling is faster for sparse transforms (line drawing, particle render) but produces gaps for any zoom or perspective.

python · inverse_sample.py
# For each screen row yy in 1..YSIZE:
#   compute y0 = source row index
#   compute x0_array = source columns for each screen column
#   frame[yy, valid_x] = msg[y0, x0_array[valid]]

Concept 3 — Precomputing the index arrays

For every output row yy, the source row index y0 is the same every frame — only the scroll offset changes. So precompute a dictionary mapping yy → (y0, source_x_indices, screen_x_indices). The frame loop becomes:

python · frame_loop.py
indices = prepare_perspective()
for f in range(N_FRAMES):
    frame = starfield.copy()
    ofs = f * scroll_per_frame
    for yy, (y0, src_x, dst_x) in indices.items():
        src_y = ofs - y0 + text_h - YSIZE
        if 0 <= src_y < text_h:
            frame[yy, dst_x] = msg[src_y, src_x]
    frames.append(Image.fromarray(frame))

This loop runs at hundreds of FPS on a modern machine: every operation is a NumPy fancy-index. No cos/sin/atan per frame — the geometry was solved once during setup.

Exercises

Three exercises in Execute → Modify → Create order: run the scroll, sweep parameters, then add a starfield with twinkling.

EXECUTE I.

Render the perspective crawl

Run starwars.py. The script generates the yellow text bitmap, computes the perspective indices, and renders 120 frames.

starwars.py — perspective crawl message.txt — Zen of Python text

Reflection questions

  • Why is the perspective constant c = 220? What does increasing c to 400 or decreasing to 100 do?
  • The line y = 1 - yy / YSIZE flips the y-axis. Why?
  • The text bitmap is 2400 px tall but the screen is 480 px. Why so much taller?
MODIFY II.

Sweep the perspective parameters

Two variants of the crawl.

Goals

  1. Steeper trapezoid. Reduce c from 220 to 80. The trapezoid will be sharper.
  2. Different text. Replace message.txt with your own paragraph. Try a quote or song lyric. Re-render.
CREATE III.

Add a twinkling starfield

The starfield is static. Add per-frame brightness variation so individual stars twinkle — a tiny detail that adds enormous atmosphere.

python · exercise3_starter.py
import numpy as np
from PIL import Image

def make_twinkling_starfield(seed, frame, n_stars=180, xsize=720, ysize=480):
    rng = np.random.default_rng(seed)
    field = np.zeros((ysize, xsize, 3), dtype=np.uint8)

    # TODO 1: generate star positions (same for all frames)
    # ys, xs, base_b = ...

    # TODO 2: per-frame brightness offset
    # twinkle = sin(frame * 2 * pi / 60 + phase_per_star) * 30
    # actual_b = clip(base_b + twinkle, 100, 255)

    # TODO 3: write stars at (ys, xs) with brightness actual_b
    return field

Make it your own

  • Scroll easing. Apply an ease-out-cubic to the scroll position so the crawl decelerates near the end. Useful for “and that’s the end of the story” pacing.
  • Camera tilt. Tilt the trapezoid left or right by skewing x_src. Cinematic “looking through the cockpit” feel.
  • Sound effect. Play a low brass sting or fanfare alongside the GIF — the audio context dramatically amplifies the perception of recession.

Downloads

starwars.py — perspective crawl renderer message.txt — Zen of Python text

Summary

Common pitfalls to avoid

  • Forward projection. Trying to project source onto screen produces gaps and overlaps. Always go inverse.
  • Per-frame index computation. Recomputing the perspective indices every frame is 100× slower than necessary. Compute once, reuse.
  • Too-small text bitmap. If the bitmap doesn’t have enough vertical headroom, the scroll runs out of content. Always size it generously (2-3× screen height + total scroll distance).
  • Wrong y-axis convention. Mixing screen-y (top-down) with mathematical-y (bottom-up) is the most common bug in any perspective code. Pick one and stick with it.

References

  1. [1] Kemp, M. (1990). The Science of Art: Optical Themes in Western Art from Brunelleschi to Seurat. Yale University Press. ISBN 978-0-300-04337-8.
  2. [2] Rinzler, J. W. (2007). The Making of Star Wars: The Definitive Story Behind the Original Film. Del Rey. ISBN 978-0-345-49476-4.
  3. [3] Foley, J. D., van Dam, A., Feiner, S. K., & Hughes, J. F. (1990). Computer Graphics: Principles and Practice (2nd ed.). Addison-Wesley. ISBN 978-0-201-12110-0.
  4. [4] Akenine-Möller, T., Haines, E., Hoffman, N., et al. (2018). Real-Time Rendering (4th ed.). CRC Press. ISBN 978-1-138-62700-0.
  5. [5] Hartley, R., & Zisserman, A. (2003). Multiple View Geometry in Computer Vision (2nd ed.). Cambridge University Press. ISBN 978-0-521-54051-3.
  6. [6] Pearson, M. (2011). Generative Art: A Practical Guide Using Processing. Manning. ISBN 978-1-935182-62-5.
  7. [7] Gonzalez, R. C., & Woods, R. E. (2018). Digital Image Processing (4th ed.). Pearson. ISBN 978-0-13-335672-4.