Skip to content

Texture Mapping

A Textured look wraps text over a shape's faces, so the surface itself is made of letters. To do that, every point on a face needs to know which character of the text belongs there. That lookup is called texture mapping.

The idea

Each face carries texture coordinates, usually called u and v: u runs across the face from 0 to 1, and v runs up it from 0 to 1. Any point on the face has a (u, v) position, however the face is turned or stretched on screen.

The shape turns (u, v) into a position in the text: a column and a row. TTE keeps the text at its natural size, about one character per column across and one per row down, and repeats it when the face is bigger than the text. Wrapped shapes, such as cylinders and spheres, run u all the way around, so the text continues across the seam.

The text unrolled as a strip from u = 0 to u = 1, and the same text wrapped around a turning cylinder, where the last column sits beside the first at the seam

A face with u across and v up, covered by rows of text, and a cylinder seen from above with text wrapped around its side

When a cell is filled, the space finds the (u, v) at the cell's center, looks up the character there, and draws it in a color from the light. So the text turns, tilts and foreshortens with the surface, while each cell still shows one upright character.

In TTE

The Texture example wraps a grid pattern, or the input text, over a cube and a sphere. Save it as texture.py:

texture.py
"""Texture: a grid pattern, or the input text, wrapped over a cube and a sphere.

An Effect Guide example for the Texture Mapping 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, Space3D, Sphere, Textured
from terminaltexteffects.utils import argutils

# Four columns and two rows repeat over each face: a cell with a + at one corner and lines along two edges.
GRID = "+---\n|   "


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


@dataclass
class TextureConfig(BaseConfig):
    """Configuration for the Texture effect."""

    parser_spec = argutils.ParserSpec(
        name="texture",
        help="A grid pattern, or the input text, wrapped over a cube and a sphere.",
        description="texture | A grid pattern, or the input text, wrapped over a cube and a sphere.",
        epilog="Example: tte --canvas-width 80 --canvas-height 24 --anchor-text c texture --text",
    )
    text: bool = argutils.ArgSpec(
        name="--text",
        default=False,
        action="store_true",
        help="Wrap the input text instead of the grid.",
    )  # pyright: ignore[reportAssignmentType]
    "bool : Wrap the input text instead of the grid."


class TextureIterator(BaseEffectIterator[TextureConfig]):
    """Produce the frames of the Texture effect."""

    def __init__(self, effect: Texture) -> None:
        """Wrap the texture over both shapes and spin them."""
        super().__init__(effect)
        self.space = Space3D(self.terminal)
        look = Textured() if self.config.text else Textured(GRID)  # Textured() wraps the input text.
        cube = self.space.body(Cube(22), at=(-20, 0, 0)).draw(look)
        sphere = self.space.body(Sphere(13, segments=32, rings=16), at=(20, 0, 0)).draw(look)
        for body in (cube, sphere):
            body.rotate("x", 20)
            body.spin("y", turns=1, frames=360, ease=easing.in_out_sine)
        self.text_shown = False

    def __next__(self) -> str:
        """Spin the shapes, 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 Texture(BaseEffect[TextureConfig]):
    """A grid pattern, or the input text, wrapped over a cube and a sphere."""

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

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

A grid of plus signs and lines wrapped over a turning cube and sphere

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

The input text wrapped over a turning cube and sphere

  • Textured() wraps the effect's input text. Textured("...") wraps any string, with lines separated by \n.
  • A grid shows the mapping clearly: watch the squares compress toward the sphere's edges, where the surface turns away.
  • Each face of a Box or Cube starts the text at its own top left corner. Prism and Pyramid run the text around their sides. Cylinder and Sphere wrap it all the way around, starting at the front.
  • Faces without texture coordinates, such as prism and cylinder caps, are drawn in back_color.
  • back="dim" shows the far side through the spaces in the text (see Depth).
  • Each cell shows one character, so wide characters such as CJK can't be split across cells and are left blank.

Mounted text or textured surface

Text can be part of a 3D scene in two ways. Mounting puts each character on an anchor, as in Animating in 3D: the character keeps its own symbol and moves as a whole. A Textured look draws the surface out of text: each cell samples the text where it lands. The Surfaces example shows the same words both ways.

surfaces.py
"""Surfaces: the same words mounted as characters on the left and drawn as a textured surface on the right.

An Effect Guide example for the Texture Mapping chapter.
"""

from __future__ import annotations

from dataclasses import dataclass

from terminaltexteffects import Color, ColorPair, EffectCharacter, easing
from terminaltexteffects.engine.base_config import BaseConfig
from terminaltexteffects.engine.base_effect import BaseEffect, BaseEffectIterator
from terminaltexteffects.engine.space3d import Box, Space3D, Textured
from terminaltexteffects.utils import argutils
from terminaltexteffects.utils.geometry import Coord


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


@dataclass
class SurfacesConfig(BaseConfig):
    """Configuration for the Surfaces effect."""

    parser_spec = argutils.ParserSpec(
        name="surfaces",
        help="The same words mounted as characters and drawn as a textured surface.",
        description="surfaces | The same words mounted as characters and drawn as a textured surface.",
        epilog="Example: tte --canvas-width 80 --canvas-height 24 --anchor-text c surfaces",
    )


WORDS = ("TERMINAL", "  TEXT  ", "EFFECTS ")
"""The words on both sides, one line each, padded to the same width so the texture repeats cleanly."""

LABEL = Color("#a0a0a0")


class SurfacesIterator(BaseEffectIterator[SurfacesConfig]):
    """Produce the frames of the Surfaces effect."""

    def __init__(self, effect: Surfaces) -> None:
        """Mount the words on one plane, texture them over a thin box, and turn both."""
        super().__init__(effect)
        self.space = Space3D(self.terminal)
        self.extras: list[EffectCharacter] = []
        center = self.terminal.canvas.center
        width, height = len(WORDS[0]), len(WORDS)

        # Left: one character per letter, mounted on a plane. Each keeps its own symbol and moves as a whole.
        self.sign = self.space.body(at=(-20, 0, 0))
        letters = []
        for row, line in enumerate(WORDS):
            for column, symbol in enumerate(line):
                if symbol == " ":
                    continue
                coord = Coord(center.column - 20 - width // 2 + column, center.row + height // 2 - row)
                letter = self.terminal.add_character(symbol, coord)
                letter.animation.set_appearance(symbol, ColorPair(fg=Color("#00D1FF")))
                letters.append(letter)
        self.sign.add_chars(letters, self.sign.text_plane(letters))
        for letter in letters:
            self.terminal.set_character_visibility(letter, is_visible=True)
            self.active_characters.add(letter)
        self.extras += letters

        # Right: a thin box with the same words as its texture. Each cell samples the text where it lands.
        self.panel = self.space.body(Box(width, height * 2, 1), at=(20, 0, 0)).draw(Textured("\n".join(WORDS)))

        for body in (self.sign, self.panel):
            body.rotate("x", 15)
            body.hold(30).spin("y", turns=1, frames=360, ease=easing.in_out_sine).hold(30)
        self.label("mounted", -20)
        self.label("textured", 20)
        self.text_shown = False

    def label(self, text: str, x: int) -> None:
        """Write `text` on the bottom row, under the body at `x`. Labels are plain characters, not part of the space."""
        start = self.terminal.canvas.center.column + x - len(text) // 2
        for offset, symbol in enumerate(text):
            character = self.terminal.add_character(symbol, Coord(start + offset, self.terminal.canvas.bottom))
            character.animation.set_appearance(symbol, ColorPair(fg=LABEL))
            self.terminal.set_character_visibility(character, is_visible=True)
            self.extras.append(character)

    def __next__(self) -> str:
        """Turn both, then swap them for the text."""
        if self.space.animating:
            self.space.step()
        elif not self.text_shown:
            self.sign.release_all()
            self.panel.erase()
            for character in self.extras:
                self.terminal.set_character_visibility(character, is_visible=False)
            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 Surfaces(BaseEffect[SurfacesConfig]):
    """The same words mounted as characters and drawn as a textured surface."""

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

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

The same three words turn side by side: mounted on the left, where each letter stays upright and the order reverses on the back, and textured on the right, where the letters are resampled and shaded by the light

  • Mounted characters keep their colors, scenes and symbols, and can be released to 2D paths afterward. Turned away, the letters still read upright, in reverse order.
  • A textured surface is lit and hides what's behind it, but it can't hand its letters back: they belong to the space.
  • Mount text that has to land somewhere. Texture text that decorates a shape.

Under the hood

A mesh face's uvs give a (u, v) for each corner. rasterize blends them across the face, correcting for perspective, and stores the result in each cell's Fragment.uv. The shape's texture_cell(fragment) turns that into a fractional (column, row) in the text:

column, row = body.shape.texture_cell(fragment)  # For example (12.4, 3.9): column 12, row 3.

A custom shape can override texture_cell to lay its text out differently. Return None for faces that shouldn't be textured.

At cell resolution

  • Text is sampled once per cell. Where a surface faces you squarely at its natural size, each character lands in its own cell. Where the surface is magnified, by perspective or by scaling up, neighboring cells sample the same character, and letters double up. Where it's shrunk or turned away, cells skip characters.
  • Doubling is worst on the near side of a large shape. A gentler perspective or a smaller shape helps. back="dim" doesn't: it only shows the far side through the near side's spaces.
  • Text needs space to read. A face 20 columns wide holds about 20 characters, and fewer as it turns.
  • Bold, dense text such as capitals reads better on a surface than lowercase or punctuation.

Scrolling, repetition and text size

Textured(offset=(column, row)) adds an offset in text cells before sampling. Positive columns scroll left; positive rows scroll up. repeat=True wraps the text in both directions. repeat=False clamps samples outside the text to the nearest edge cell. scale=2 doubles each character's footprint in both dimensions; scale must be finite and positive. Row density follows space.camera.row_scale, including when it differs from the usual tall-cell ratio.

Looks are immutable. Animate an offset by replacing the look each frame, without turning the body:

from dataclasses import replace
from terminaltexteffects.engine.space3d import Textured

look = Textured("A scrolling message")
# In the effect's frame loop, with elapsed_frames advanced by the effect:
body.draw(replace(look, offset=(elapsed_frames / 4, 0)))
space.step()

For custom shapes, texture_cell continues to return rows in the default terminal cell ratio; the renderer converts those rows to the camera's ratio before applying scale and offset.