Skip to content

Camera and Projection

The camera decides how 3D points become cells. Its one setting you'll change most is how strong the perspective is: how much nearer things grow and farther things shrink.

The idea

The camera sits on the +z axis, looking back at the origin. To draw a point, it projects it: it finds where the line from the point to the camera crosses the screen.

  • With perspective, things farther away look smaller, and parallel lines running away from you converge. A point at depth d from the camera is scaled by distance / d, so the z = 0 plane keeps its size, nearer points grow and farther ones shrink.
  • An orthographic view drops the scaling. Everything keeps its size whatever its depth, like a technical drawing. It's flat, but it's also stable: nothing swells as it turns toward you.

Side view: with perspective, rays from an object converge on the camera, so its drawn size shrinks as it moves away; with an orthographic view the rays are parallel and the size stays the same

Perspective strength comes from the camera's distance. A camera close to the scene makes small changes in depth look large. A distant camera barely changes sizes at all.

The same near and far objects drawn with perspective 0, 1 and 2: the far object shrinks more as the camera comes closer

In TTE

The Lens example places three cubes, each 26 columns to the right of and 60 columns farther away than the one before. Save it as lens.py:

lens.py
"""Lens: three cubes running away from the viewer, seen with a chosen perspective.

An Effect Guide example for the Camera and Projection chapter.
"""

from __future__ import annotations

from dataclasses import dataclass

from terminaltexteffects import easing
from terminaltexteffects.engine.base_config import BaseConfig
from terminaltexteffects.engine.base_effect import BaseEffect, BaseEffectIterator
from terminaltexteffects.engine.space3d import Cube, Shaded, Space3D
from terminaltexteffects.utils import argutils


def get_effect_resources() -> tuple[str, type[BaseEffect], type[BaseConfig]]:
    """Return the command name, effect class, and configuration class TTE registers."""
    return "lens", Lens, LensConfig


@dataclass
class LensConfig(BaseConfig):
    """Configuration for the Lens effect."""

    parser_spec = argutils.ParserSpec(
        name="lens",
        help="Three cubes running away from the viewer.",
        description="lens | Three cubes running away from the viewer.",
        epilog="Example: tte --canvas-width 80 --canvas-height 24 --anchor-text c lens --perspective 2",
    )
    perspective: float = argutils.ArgSpec(
        name="--perspective",
        type=argutils.NonNegativeFloat.type_parser,
        default=1.0,
        metavar=argutils.NonNegativeFloat.METAVAR,
        help="1 is natural, larger values exaggerate depth, and 0 is a flat orthographic view.",
    )  # pyright: ignore[reportAssignmentType]
    "float : 1 is natural, larger values exaggerate depth, and 0 is a flat orthographic view."


class LensIterator(BaseEffectIterator[LensConfig]):
    """Produce the frames of the Lens effect."""

    def __init__(self, effect: Lens) -> None:
        """Place three cubes from near and left to far and right, and spin them."""
        super().__init__(effect)
        self.space = Space3D(self.terminal, perspective=self.config.perspective)
        # Each cube is 26 columns right of and 60 columns farther away than the one before it.
        for x, z in ((-26, 0), (0, -60), (26, -120)):
            cube = self.space.body(Cube(14), at=(x, 0, z)).draw(Shaded())
            cube.rotate("x", 20)
            cube.spin("y", turns=1, frames=240, ease=easing.in_out_sine)
        self.text_shown = False

    def __next__(self) -> str:
        """Spin the cubes, then swap them for the text."""
        if self.space.animating:
            self.space.step()
        elif not self.text_shown:
            for body in self.space.bodies:
                body.erase()
            self.space.step()  # Hides the surface characters once nothing is drawn.
            for character in self.terminal.get_characters():
                self.terminal.set_character_visibility(character, is_visible=True)
            self.text_shown = True
        else:
            raise StopIteration
        self.update()
        return self.frame


class Lens(BaseEffect[LensConfig]):
    """Three cubes running away from the viewer."""

    @property
    def _config_cls(self) -> type[LensConfig]:
        return LensConfig

    @property
    def _iterator_cls(self) -> type[LensIterator]:
        return LensIterator
tte --input-file concept_input.txt --canvas-width 80 --canvas-height 24 --anchor-text c lens --perspective 0

Three cubes the same size, evenly spaced

tte --input-file concept_input.txt --canvas-width 80 --canvas-height 24 --anchor-text c lens

The farther cubes are smaller and closer to the center

tte --input-file concept_input.txt --canvas-width 80 --canvas-height 24 --anchor-text c lens --perspective 2

The farthest cube is tiny and near the center

Space3D(terminal, perspective=...) sets the strength:

  • 1, the default, is a natural perspective. The camera is 120 columns from the origin.
  • Larger values move the camera closer. 2 puts it 60 columns away, which exaggerates depth.
  • 0 is orthographic.

Under the hood

geometry3d.Camera does the projection. Space3D makes one centered on the canvas, and you can read it as space.camera:

projected = self.space.camera.project(point)  # None if the point is behind the camera.
if projected is not None:
    cell = projected.coord
    depth = projected.depth  # Distance from the camera. Smaller is nearer.
  • distance is how far the camera is from the origin, and focal (the same as distance unless you set it) controls the scaling.
  • near clips points closer than that to the camera. Crossing faces and edges keep their visible portions; texture coordinates are interpolated at the clipped corners.

Side view: a box slides toward the camera; the part nearer than the near plane is clipped away and the rest is drawn - orthographic=True turns the scaling off. - view_direction(point) is the direction from a point toward the camera, which decides which faces turn toward it.

At cell resolution

Each projected point is drawn in the cell it falls in, so a point that moves smoothly in 3D moves on screen in whole-cell steps:

A point follows a smooth curve across a grid of cells, and the cell it is drawn in jumps from cell to cell, leaving a staircase

  • Strong perspective makes near faces large and far faces tiny. Tiny faces become a few cells of noise, so keep perspective at 1 or below for small shapes.
  • Perspective magnifies whatever comes toward you, including text on a surface. Letters on the near side of a large shape can double up (see Texture mapping). Gentler perspective, such as 0.5, reduces it.
  • Orthographic views are good for text: a text plane turning in an orthographic view never grows past the size of the input.
  • Nothing may come nearer than the camera's near distance. With perspective=2 the camera is only 60 columns away, so a body moved to z = 60 disappears.

Coordinates and mutable settings

World z increases toward the viewer. Camera depth is camera.distance - world_z, so smaller depth is nearer. cell_to_world(coord, z=...) accepts world z; Projected.depth and Mount.depth report camera depth. Both are measured in columns.

Camera settings are mutable. Construction checks positive distance, focal (when set), near and row_scale, and requires near < distance. Assignments do not revalidate: preserve those constraints when changing a field. With focal=None, focal length follows the current distance.