Skip to content

Camera and Projection

A camera answers two questions: where are we looking from? and how does that view become terminal cells? Its position and orientation answer the first. Projection and focal length answer the second. Start here for framing and perspective, then read Moving the camera for travel, turning, aiming and orbiting.

The default view

Every world has one camera, terminal.world.camera. It starts with its eye at (0, 0, 120), looking along world -z, with +y up. The canvas center is the center of the image. This is the view used by the earlier chapters; moving the camera changes the view while the world axes and body positions stay fixed.

Perspective uses converging rays, while orthographic projection uses parallel rays; moving an object changes its size only in perspective

  • Perspective makes farther things smaller. A point's projected size is multiplied by focal / depth, where depth is distance along the camera's forward axis. Initially focal length is 120 too, so the world z=0 plane keeps its size and the input text meets its original cells.
  • Orthographic projection skips that multiplication. Depth still exists for clipping and occlusion; it just does not change apparent size. The eye has a finite position and a near plane even though its rays are parallel.

Once the camera moves or turns, the world z=0 plane is no longer necessarily face-on, centered or at its initial size. Camera coordinates and depth explains the extra transform.

Setting up perspective

camera.set_perspective(strength) chooses the projection, now:

Strength Eye z Focal length Result
0 120 Ignored for size Orthographic, with no depth magnification
1 120 120 Default perspective
2 60 60 Closer eye and stronger differences between near and far objects

The same near and far objects under orthographic projection, default perspective and a closer initial camera

The Lens example keeps the bodies still and changes only that initial setup:

self.world = self.terminal.world
self.world.camera.set_perspective(self.config.perspective)
for x, z in ((-26, 0), (0, -60), (26, -120)):
    self.world.body(Cube(14), at=(x, 0, z)).draw(Shaded())
lens.py

Save this module in your community effects directory as 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 Shaded, easing
from terminaltexteffects.engine.base_config import BaseConfig
from terminaltexteffects.engine.base_effect import BaseEffect, BaseEffectIterator
from terminaltexteffects.engine.shapes import Cube
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.world = self.terminal.world
        self.world.camera.set_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.world.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 not self.world.animating:
            if self.text_shown:
                raise StopIteration
            for body in self.world.bodies:
                body.erase()  # The world hides its cells when it next paints.
            for character in self.terminal.get_characters():
                self.terminal.set_character_visibility(character, is_visible=True)
            self.text_shown = True
        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 keep the same size at different depths

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

Farther cubes appear smaller and nearer the center of the image

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

The closer camera exaggerates the size difference between the cubes

set_perspective sets both the eye's reference distance, 120 / strength, and the focal length, so the z = 0 plane keeps its size at any strength. It becomes the camera's starting setup: camera.reset() keeps it. Call it once, in the iterator's __init__. The controls below let an effect change position and focal length independently afterward.

Moving the camera

Moving the camera teaches eye position, camera-local travel, pan/tilt/roll, aiming and orbiting. Camera shots and timing combines those controls into queued shots. Start with the comparison below when deciding between changing position and changing focal length.

Dolly or zoom?

A dolly moves the eye forward or backward. The foreground grows faster than the background as you approach, and their relative positions can change: that is parallax. A zoom changes focal length while the eye stays still. It magnifies the whole perspective image, keeping its perspective proportions.

Dolly and zoom reach the same foreground scale of 1.333, but the background scales to 0.800 with dolly and 0.889 with zoom

The camera lab starts with eye z=120 and focal=120. Cyan F has its front face at world z=0; purple B has its front face at z=-60. Both are the same physical size. These two moves give F the same final framing:

Move Final eye z Final focal F's front-face scale B's front-face scale
dolly_by(30) 90 120 120 / 90 = 1.333 120 / 150 = 0.800
zoom_to(160) 120 160 160 / 120 = 1.333 160 / 180 = 0.889
camera.dolly_by(30, frames=120, ease=easing.in_out_sine)
tte --input-file concept_input.txt --canvas-width 80 --canvas-height 32 --anchor-text c cameralab --lesson dolly

The camera approaches two stationary cubes; foreground F grows faster than background B

camera.zoom_to(160, frames=120, ease=easing.in_out_sine)
tte --input-file concept_input.txt --canvas-width 80 --canvas-height 32 --anchor-text c cameralab --lesson zoom

Zoom magnifies both cubes while the eye stays still; B ends larger than in the dolly recording

dolly(amount) and zoom(factor) are immediate. dolly_by(amount, frames=...) is relative travel; zoom_to(focal, frames=...) targets an absolute positive focal length. For example, zoom(2) doubles the current focal length, while zoom_to(2) targets a focal length of two columns. Choose dolly to travel through a scene and zoom to reframe the image from the same viewpoint.

Orthographic camera movement

Orthographic travel and turns still work. Truck and pedestal move the image; pan, tilt and roll change which way you see the scene. Dolly changes depth and can cross the near plane, but does not magnify. Focal length is ignored, so zoom does not magnify either. Watch the readout change during the first two phases of this recording:

tte --input-file concept_input.txt --canvas-width 80 --canvas-height 32 --anchor-text c cameralab --lesson orthographic

An orthographic camera dollies and zooms without changing apparent size, then trucks and pans to change the view

The standalone camera lab and its lesson snippets are introduced in Moving the camera.

Camera coordinates and depth

Body coordinates describe points on a body. A body's transform places them in world space. The camera then subtracts its eye position and undoes its orientation to express those world points in view space. In that space, forward is always -z, right is +x and up is +y. Projection turns view x/y into canvas columns/rows.

The body-to-world-to-view-to-canvas pipeline, followed by a side-view example with eye at world x=30 and view-axis depth 25

For a world point point, the geometry is equivalent to:

view = camera.rotation.inverse().apply(point - camera.position)
depth = -view.z

camera.depth(point), Projected.depth and character.view.depth all use this view-axis depth. It is not Euclidean range to the eye. With the default orientation and position it equals camera.distance - point.z; after camera movement, use depth(point) instead of assuming that formula still describes the view.

Projection returns None before the near plane, including behind the eye:

projected = self.world.camera.project(point)
if projected is not None:
    cell = projected.coord
    depth = projected.depth

The near plane is perpendicular to the current forward axis and moves with the eye. Faces and wireframe edges crossing it retain their visible portions. Depth and hidden surfaces shows this with a stationary body and a moving side-view camera.

Cells, rays and the text layout

camera.cell_to_world(coord, z=...) intersects the active viewing ray with a plane of constant world z. For example, at the default view, z=0 lifts a cell onto the input-text plane. A moved or rolled camera uses a different ray, so the resulting world x/y may change. If that ray is parallel to the plane, there is no unique intersection and the method raises ValueError.

For an arbitrary camera view, a ray is often a better building block:

origin, direction = camera.ray(coord)
point_at_depth_30 = origin + direction * 30

Perspective rays begin at the eye; orthographic rays begin at the cell's position on the eye plane. The direction is scaled so advancing by 30 gives a point at camera depth 30. It is not normalized to Euclidean length.

The text plane does not follow the camera. Its surface frame maps each canvas cell to the world point that cell shows in the starting view, so the input text lands in its input cells only while the text plane is at its starting pose and the camera is at its starting view. To bring the text home after moving the camera, put the camera back with a tween or camera.reset(). Returning the view demonstrates it.

Settings and troubleshooting

Symptom or task Check
Everything disappears after moving the eye Is the camera aimed at the scene? Movement alone does not turn it. Use look_at and check view-axis depth against near.
Objects enlarge when you wanted a flat view Use camera.set_perspective(0); a long focal length alone is still perspective.
Text doubles or loses letters as the camera approaches Projected characters are colliding or the textured surface is magnified. Use gentler framing or more canvas space.
Zoom appears to do nothing Is the view orthographic? Its size ignores focal length.
Input text does not land home Restore both the camera and the text plane; resetting only one does not restore the other.
You need a new optical center center_column and center_row are canvas coordinates for the image center, not world positions.

A smoothly moving 3D point lands in discrete cells, producing whole-cell steps

distance is the eye's reference distance: the eye follows it until an explicit set_position or travel call. Afterward, changing it does not move the eye. With focal=None, it still supplies the focal length. zoom makes focal length explicit. camera.reset() cancels the camera's tweens and restores its starting setup, including the implicit position/focal relationship. position and rotation are read-only properties; change them through the control methods.

set_perspective checks that the eye stays beyond the near plane. Direct field assignments, such as camera.near = 2, are not checked. Keep projection values positive; a moved camera clips using its current view depth rather than its distance from the world origin. row_scale remains the terminal's tall-cell correction; see Tall cells.