Skip to content

Depth and Hidden Surfaces

In a 3D scene, nearer things hide farther ones. The space has to decide, cell by cell, which surface wins. It uses two tools: a depth buffer for surfaces, and layers for characters.

The idea

Depth is a point's distance from the camera. Smaller is nearer.

A depth buffer keeps, for every cell, the nearest surface found there so far. Each face is filled cell by cell, and a cell only takes the new face if it's nearer than what the cell already holds. Draw every shape into the same buffer and overlaps sort themselves out, even where two shapes pass through each other.

Side view: a ray from the camera through each cell stops at the first surface it meets, so the near surface hides part of the far one

Back-face culling skips work before the buffer sees it. Each face of a closed shape has an outward normal, an arrow pointing straight out of it. A face whose normal points away from the camera is on the far side of the shape, hidden behind the near side, so it can be skipped without testing a single cell.

Seen from above, a turning prism: faces whose normals point toward the camera are drawn, the others are skipped

Mounted characters aren't drawn into the buffer. Each one is a single character on one cell, so the space orders them with layers instead: with layer_by_depth on, a nearer character gets a higher layer and draws on top.

In TTE

The Depth example passes a sphere through a cube, or shows a cube's far side through its text. Save it as depth.py:

depth.py
"""Depth: a sphere passes through a cube, or a cube shows its far side through its text.

An Effect Guide example for the Depth and Hidden Surfaces 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, Sphere, Textured
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 "depth", Depth, DepthConfig


@dataclass
class DepthConfig(BaseConfig):
    """Configuration for the Depth effect."""

    parser_spec = argutils.ParserSpec(
        name="depth",
        help="A sphere passes through a cube, or a cube shows its far side through its text.",
        description="depth | A sphere passes through a cube, or a cube shows its far side through its text.",
        epilog="Example: tte --canvas-width 80 --canvas-height 24 --anchor-text c depth --see-through",
    )
    see_through: bool = argutils.ArgSpec(
        name="--see-through",
        default=False,
        action="store_true",
        help="Draw one textured cube whose far side shows through the spaces in its text.",
    )  # pyright: ignore[reportAssignmentType]
    "bool : Draw one textured cube whose far side shows through the spaces in its text."


class DepthIterator(BaseEffectIterator[DepthConfig]):
    """Produce the frames of the Depth effect."""

    def __init__(self, effect: Depth) -> None:
        """Set up the bodies and queue their motion."""
        super().__init__(effect)
        self.space = Space3D(self.terminal)
        if self.config.see_through:
            cube = self.space.body(Cube(24)).draw(Textured("TTE 3D  ", back="dim"))
            cube.rotate("x", 20)
            cube.spin("y", turns=1, frames=360, ease=easing.in_out_sine)
        else:
            cube = self.space.body(Cube(20)).draw(Shaded())
            cube.set_rotation().rotate("y", 30).rotate("x", 20)
            # The sphere starts on the left, slightly in front, and passes through the cube to the right.
            sphere = self.space.body(Sphere(9), at=(-34, 0, 6)).draw(Shaded())
            sphere.move_to(34, 0, -6, frames=300, ease=easing.in_out_sine)
            cube.spin("y", turns=0.5, frames=300, ease=easing.in_out_sine)
        self.text_shown = False

    def __next__(self) -> str:
        """Play the motion, then swap the bodies 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 Depth(BaseEffect[DepthConfig]):
    """A sphere passes through a cube, or a cube shows its far side through its text."""

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

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

A sphere passes through a turning cube; each hides the other where it is nearer

tte --input-file concept_input.txt --canvas-width 80 --canvas-height 24 --anchor-text c depth --see-through

A cube covered in text, with its dim far side showing through the spaces

  • Every drawn body in a space shares one depth buffer, so bodies hide each other correctly with no extra work.
  • Textured(back="dim") draws a second pass of the faces turned away, in back_color, and lets them show through the spaces in the text. It's the one place the space draws back faces on purpose.

The Card example mounts the input text on a plane and turns it once, which shows what happens to characters as they turn edge-on. Save it as card.py:

card.py
"""Card: the input text turns like a card, showing how mounted characters thin out edge-on.

An Effect Guide example for the Depth and Hidden Surfaces 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 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 "card", Card, CardConfig


@dataclass
class CardConfig(BaseConfig):
    """Configuration for the Card effect."""

    parser_spec = argutils.ParserSpec(
        name="card",
        help="The input text turns like a card.",
        description="card | The input text turns like a card.",
        epilog="Example: tte --canvas-width 80 --canvas-height 24 --anchor-text c card --hide-back",
    )
    hide_back: bool = argutils.ArgSpec(
        name="--hide-back",
        default=False,
        action="store_true",
        help="Hide characters while the back of the card faces the viewer.",
    )  # pyright: ignore[reportAssignmentType]
    "bool : Hide characters while the back of the card faces the viewer."


class CardIterator(BaseEffectIterator[CardConfig]):
    """Produce the frames of the Card effect."""

    def __init__(self, effect: Card) -> None:
        """Mount the text on a plane and queue one slow turn."""
        super().__init__(effect)
        self.space = Space3D(self.terminal)
        self.card = self.space.body()
        characters = [c for c in self.terminal.get_characters() if c.input_symbol != " "]
        self.card.add_chars(
            characters,
            self.card.text_plane(characters),
            hide_when_facing_away=self.config.hide_back,
        )
        for character in characters:
            self.terminal.set_character_visibility(character, is_visible=True)
            self.active_characters.add(character)
        self.card.hold(20).spin("y", turns=1, frames=360, ease=easing.in_out_sine).hold(20)
        self.card.on_complete(lambda card: card.release_all())

    def __next__(self) -> str:
        """Turn the card until it is back at rest."""
        if self.space.animating or self.active_characters:
            self.space.step()
            self.update()
            return self.frame
        raise StopIteration


class Card(BaseEffect[CardConfig]):
    """The input text turns like a card."""

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

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

The text turns like a card, thinning to a sliver edge-on and showing mirrored on the back

tte --input-file concept_input.txt --canvas-width 80 --canvas-height 24 --anchor-text c card --hide-back

The same turn, with characters hidden while the back faces the viewer

As the card turns edge-on, its columns crowd into fewer cells and only one character can show in each. Past edge-on you see the back of the card, so the text reads mirrored. hide_when_facing_away=True hides each character while its side faces away, as a solid card would.

Hidden wireframe lines

By default, Wireframe calls an edge front-facing when an adjacent face points toward the camera. This is a fast approximation for convex solids; concave and custom meshes can have front-facing edges behind another part of the mesh. Use Wireframe(depth_test=True, hidden_color=None) for hidden-line removal against the same mesh's filled depth buffer. A hidden color instead draws those occluded lines in that color. The low-level geometry3d.wireframe accepts the same depth_test=True option. It adds a fill pass within the viewport and tests depth at terminal cell centers, so cell rounding can affect silhouettes.

Under the hood

geometry3d.rasterize fills a mesh into a buffer and returns it:

from terminaltexteffects.utils import geometry3d as g3

buffer: dict = {}
g3.rasterize(cube_mesh, camera, rotation=turn, buffer=buffer)
g3.rasterize(sphere_mesh, camera, position=g3.Vec3(10, 0, 0), buffer=buffer)
for cell, fragment in buffer.items():
    ...  # fragment.depth, fragment.normal, fragment.facing, fragment.tag, fragment.uv
  • cull="back", the default, skips faces turned away. "front" keeps only those, and "none" keeps both.
  • Each Fragment holds the nearest surface's depth, normal, face tag and texture coordinates.

Characters are ordered by layer, then by creation order. A mount with layer_by_depth sets the character's layer to its layer when mounted plus how much nearer than the origin it is, in whole columns, and restores it on release. The rest of a column orders characters within the layer, so the nearer of two characters always draws on top, however small the difference. Surface cells are ordered the same way and count a hair farther than they are, so text mounted on a drawn body from layer 0 or above reads on top.

At cell resolution

  • Text edge-on loses characters: a plane turned 60 degrees shows only half of its columns. Keep text near facing the viewer when it must be read, and move through edge-on quickly.
  • Where two characters land in the same cell, the higher layer wins. With layer_by_depth off, the last-created character wins instead, which looks random on a turning body.
  • Only cell centers are tested, so a part of a shape thinner than a cell, such as a narrow rim seen edge-on, can fall between centers and vanish for a frame.

Mounted anchors at the near plane

mount.projected reports whether the anchor can be projected at its current pose. While the anchor is closer than camera.near (including behind the camera), its character is automatically hidden on its tick. The mount retains its last valid coordinate, depth, facing, and light readings; check projected before using them. It restores visibility when the anchor returns, unless backface hiding still applies. A character already hidden by the effect stays hidden. Releasing a mount restores visibility only if the mount hid it.