03 TextMode – Views and Grid

14 min read

The cell grid every TextMode feature writes into, and the two view components that put it on screen, whether that is a Canvas overlay or a monitor the player can walk up to.

Summary

Everything in TextMode ends up as cells in a CellGrid. Your own drawing code, the terminal emulator running an escape sequence, and an effect animating a reveal all end up writing into the same array, and a view component uploads whatever changed to the GPU once a frame. Understanding that one shared destination is most of what you need to know about how the module fits together.

A Cell is a 32-byte struct, sized deliberately so it maps straight onto the structured buffer the shader reads without any repacking. It carries a Unicode codepoint, a packed foreground and background color, a bitfield of style attributes, and the glyph’s coordinates in the font atlas. That last part is the piece people trip over: a cell knows where its glyph lives in the atlas, and setting the codepoint alone leaves a cell the renderer will draw as blank. Writing through the console or the grid’s stamping method handles it for you.

The view is the component that owns a grid and draws it. There are two, and which one you want depends on where the text lives. UITextModeView is a Canvas graphic, so it clips to masks, honors CanvasGroup alpha, and sorts with the rest of your interface. TextModeView sits in world space, which is what you need when the display is a physical object in the scene: a terminal on a wall, a monitor in a cockpit, a screen the camera can approach and angle past. Both expose the same grid, the same glyph cache, and the same effects player, so code that draws into one works unchanged against the other.

Neither view asks you to size the grid by hand unless you want to. Give it a font size and some space and it computes the columns and rows that fit, up to the module’s ceiling of 512 by 512 and 131,072 cells in total.

Use views and the grid directly for:

  • Reading and writing cells when you need more control than the drawing API gives you.
  • Sizing a display to an exact grid rather than letting it fit the available space.
  • Hit-testing a screen position back to a cell, for click handling and hover highlights.
  • Swapping a whole prepared grid onto a view in one call, for double buffering or a screen cache.
  • Scrolling, inserting and deleting lines and characters, which is what the terminal emulator drives.

Features

  • 32-byte cells. Cell is laid out with LayoutKind.Sequential and asserts its own size at startup, so the C# struct and the shader’s structured buffer never drift apart. Colors are packed into a uint each, and the glyph’s atlas rect and metrics are packed alongside.
  • Dirty range tracking. The grid records the lowest and highest cell index touched since the last upload. Writing one character marks one row, and the view uploads that slice rather than the whole buffer.
  • Terminal-shaped grid operations. Scroll up and down within a region, insert and delete lines, insert and delete characters, erase parts of a line or the display. These exist because a VT emulator needs them, but they are just as useful for a message log or a ticker.
  • Two view components. A Canvas graphic and a world-space renderer over identical APIs.
  • Automatic layout. Columns and rows are computed from the font metrics and the available space, with configurable line height and character spacing multipliers.
  • Cell border. UITextModeView can frame its content with whole border cells on each side. The border cells show the default background and never hold content, so the grid, and a terminal’s wrapping and cursor with it, stays inside the frame. The full grid is centered in the view, with the space whole cells cannot fill split evenly around it.
  • Glyph atlas cache. A GlyphAtlasCache reads a TextMeshPro font asset once and keeps per-glyph atlas coordinates and metrics ready for stamping, so writing a cell never touches TextMeshPro at runtime.
  • Screen-to-cell hit testing. ScreenPointToCell converts a screen position and camera into a cell coordinate on either view.

The cell

A cell holds everything the shader needs to draw one character.

FieldHoldsNotes
CodepointThe Unicode codepointZero or a space renders as background only. The codepoint alone is not enough to draw: the atlas fields below have to match it.
FGColor, BGColorPacked RGBA32 colorsUse Cell.PackColor and Cell.UnpackColor to convert to and from Color32. Packing keeps the struct at 32 bytes.
AttributesStyle bitsBold, italic, underline, strikethrough, inverse, dim, blink, hidden and double-width, plus internal bits for cursor, selection and the atlas index. See Colors and Attributes.
AtlasUVPacked, AtlasRectPackedWhere the glyph sits in the atlasOrigin and size in texels, packed as two 16-bit values each. Written by the glyph cache, not by hand.
MetricsPacked, MetricsWHPackedThe glyph’s bearings and logical sizeDrives how the glyph is positioned and stretched inside its cell so the result matches how TextMeshPro would lay it out.

The practical consequence of that table is one rule: set a cell’s character through something that stamps the atlas data. CellGrid.SetCellStamped and everything built on it (the console, the terminal, the effects) do this. Copying a cell, changing only its Codepoint, and writing it back gives you a cell whose atlas rect still describes the old glyph, or is zeroed, and the renderer draws nothing at all.

Recipes

Put a grid on screen at an exact size

Problem. You’re building a roguelike and the map logic assumes an 80 by 25 playfield. Letting the view fit the window means the grid changes size when someone resizes it, and your map code has to cope with a moving target. You want the grid pinned.

Solution. Set the columns and rows explicitly and let the cell size follow.

/* Resize takes the grid dimensions you want. The view recomputes cell size from the space it
   has, so the characters get bigger or smaller rather than the playfield changing shape. */
_view.Resize(80, 25);

/* The grid is now exactly the size the map code expects, whatever the window does. */
var map = new DungeonMap(_view.Columns, _view.Rows);

Read and write cells directly

Problem. You’re writing a fog-of-war pass over an already-drawn dungeon. You don’t want to redraw the map; you want to walk the cells that are out of the player’s sight and dim whatever is already in them, whatever that happens to be.

Solution. Read the cell, change the part you care about, write it back. Because you’re keeping the existing codepoint and its atlas data, a plain SetCell is safe here.

for (var row = 0; row < _view.Rows; row++)
{
    for (var column = 0; column < _view.Columns; column++)
    {
        if (visibility.IsVisible(column, row))
        {
            continue;
        }

        /* GetCell returns a copy. Nothing is written until you put it back. */
        var cell = _view.Grid.GetCell(row, column);

        /* Dim the foreground toward the background. The glyph and its atlas data ride along
           untouched, which is exactly what you want when recoloring existing content. */
        var dimmed = Color32.Lerp(_view.DefaultBackground, Cell.UnpackColor(cell.FGColor), 0.35f);
        cell.FGColor = Cell.PackColor(dimmed);

        _view.Grid.SetCell(row, column, cell);
    }
}

Scroll a message log

Problem. Your roguelike has a combat log at the bottom of the screen. When it fills, the oldest line should slide off the top rather than the whole panel being rebuilt every turn.

Solution. Scroll the region and write into the line the scroll just freed.

/* Scroll a region up by exactly one line. Both bounds are inclusive rows of the region, so
   this moves the log without touching the map above it or the status bar below. The bottom
   row is cleared to the grid's default colors, ready to be written into. */
var evicted = _view.Grid.ScrollUp(scrollTop: LOG_TOP, scrollBottom: LOG_BOTTOM);

/* ScrollUp hands back a copy of the row that fell off the top, which is what you push into a
   scrollback buffer if the player can page back through the log. An empty array comes back
   when the region was too small to scroll. */
if (evicted.Length > 0)
{
    _scrollback.Append(evicted);
}

/* Now write the new entry into the line the scroll just freed. */
_console.Print(1, LOG_BOTTOM, "The kobold hits you for 4.", ANSIColor.BrightRed, ANSIColor.Black);

Hit-test a click back to a cell

Problem. You’ve drawn an inventory grid and you want a click to select the item under the cursor. You need the cell the player actually clicked, not a pixel position.

Solution. ScreenPointToCell does the conversion and tells you whether the point landed on the grid at all.

/* Both view types expose the same call. Pass the camera the click came through: for a Canvas
   in Screen Space Overlay that is null, for anything else it is the event camera. */
if (_view.ScreenPointToCell(Input.mousePosition, _eventCamera, out var cell))
{
    /* cell.Row and cell.Column are grid coordinates, so they index straight into your model. */
    var slot = _inventory.SlotAt(cell.Column, cell.Row);
    slot?.Select();
}

Prepare a screen off-view and swap it in

Problem. Your game switches between a map screen and a character sheet. Redrawing the character sheet every time the player toggles to it wastes work, and drawing it a piece at a time in front of them looks like tearing.

Solution. Build each screen into its own grid and hand the finished one to the view.

/* A grid is an ordinary object. Build one at the view's dimensions and draw into it whenever
   the data changes rather than when the player looks at it. */
_sheetGrid = new CellGrid(_view.Rows, _view.Columns, _view.DefaultForeground, _view.DefaultBackground);
var sheetConsole = new TextModeConsole(_sheetGrid, _view.GlyphCache);
DrawCharacterSheet(sheetConsole);

/* Swapping is a reference assignment plus a full upload, so the screen changes in one frame. */
_view.SetGrid(_sheetGrid);

API Sketch

The grid and the cell are plain C# and usable without a view; the views are MonoBehaviours that own one.

/* --- The cell --- */
struct Cell {
    uint Codepoint, FGColor, BGColor, Attributes;
    uint AtlasUVPacked, AtlasRectPacked, MetricsPacked, MetricsWHPacked;
    const int SIZE_IN_BYTES = 32;
    static Cell CreateDefault(Color32 fg, Color32 bg);
    static uint PackColor(Color32 c);
    static Color32 UnpackColor(uint packed);
}

/* --- The grid --- */
class CellGrid {
    int Rows, Columns, CellCount;
    Cell[] Cells;
    Color32 DefaultFG, DefaultBG;
    bool IsDirty; int DirtyRangeMin, DirtyRangeMax;

    Cell GetCell(int row, int col);
    void SetCell(int row, int col, Cell cell);
    void SetCellStamped(...);            /* writes codepoint plus atlas data */
    void SetDefaultColors(Color32 fg, Color32 bg);
    void Fill(...); void Clear(); void ClearRegion(...);
    void Resize(int rows, int cols);

    /* Terminal-shaped operations */
    Cell[] ScrollUp(...); void ScrollDown(...);
    void InsertLines(...); void DeleteLines(...);
    void InsertChars(...); void DeleteChars(...);
    void EraseLine(...); void EraseDisplay(...);

    /* Damage tracking */
    void MarkRowDirty(int row); void MarkAllDirty();
    bool IsRowDirty(int row); void ClearDirty();
}

/* --- The glyph atlas --- */
class GlyphAtlasCache {
    void Initialize(TMP_FontAsset font);
    bool HasGlyph(uint codepoint);
    PackedGlyphData GetGlyph(uint codepoint);
    void StampCell(ref Cell cell, uint codepoint);
    int CachedGlyphCount, AtlasPadding;
    float PointSize, AdvanceWidth, LineHeight, AscentLine, DescentLine, InkTop, InkBottom;
    Texture2D AtlasTexture;
}

/* --- The views (shared surface) --- */
class UITextModeView / TextModeView {
    CellGrid Grid; GlyphAtlasCache GlyphCache; TextModeEffectPlayer Effects;
    int Columns, Rows; Vector2 CellSize; bool IsInitialized;
    float FontSize, LineHeight, CharSpacing;
    Color32 DefaultForeground, DefaultBackground;
    Color CursorColor, SelectionColor, SelectionTextColor; CursorStyle CursorStyle;
    bool FollowColorTheme;
    void Resize(int columns, int rows);
    void SetGrid(CellGrid grid);
    void SetCell(int row, int col, Cell cell);
    void ApplyTheme(in TextModeColorScheme scheme);
    bool ScreenPointToCell(Vector2 screenPoint, Camera eventCamera, out CellPosition cell);
}

/* TextModeView adds, for world space: */
float PixelsPerUnit, ViewWidth, ViewHeight;
void SetViewSize(float width, float height);

See the API reference for full signatures, remarks, and exception contracts on every member.

View settings

Both views share most of their Inspector surface. These are the ones that change what you see.

SettingDefaultEffect
Font AssetDefault monospace SDFThe TextMeshPro font glyphs are sampled from. Left empty, the view loads the module’s bundled monospace font, so a fresh view renders without setup. A proportional font will render, but cells are uniform width, so it will not look right.
Font Size16Drives cell size, and through it the computed column and row counts. The single most useful dial: halve it and you get roughly four times the cells.
Line Height1.0Multiplier on the natural line height. Below about 0.82 the cell becomes shorter than the glyphs are tall, at which point the renderer draws the overhang from neighboring rows so descenders are not clipped.
Char Spacing1.0Multiplier on the advance width. Above 1 loosens the grid horizontally; the glyph itself does not stretch, it just occupies less of a wider cell.
Cell Border0 on every sideWhole cells of empty frame around the content, per side (UI view only). Columns, Rows and Grid cover the content inside it. When the view is too small, the border shrinks so at least one column and row of content remains.
Border Follows ThemeOnThe border cells show the active color theme’s background. Off, they are transparent, so whatever lies behind the view shows through.
Default ForegroundLight grayThe color a cleared cell takes, and what drawing calls fall back to. Replaced wholesale when a color theme is applied.
Default BackgroundBlackAs above, for the background. A transparent value lets whatever is behind the view show through, which is how the debug console renders over a game.
Cursor StyleBlockBlock, Bar, Underscore, Vintage, EmptyBox or DoubleUnderscore. Drawn by the shader on the cell carrying the cursor attribute.
Cursor Blink Rate1.0Cycles per second for the cursor. Blinking is done in the shader, so it costs nothing per frame.
Text Blink Rate1.0Cycles per second for cells carrying the blink attribute, kept separate from the cursor so the two do not have to agree.
SDF Smoothing1.0Edge sharpness. One is an anti-aliasing ramp exactly one screen pixel wide, which is as crisp as an edge gets without aliasing. Lower softens it. Independent of the font’s atlas padding, so the same value looks the same on every font.
sRGB CorrectionOn in linear projectsConverts cell colors from sRGB to linear so they display as authored. Leave it on unless your project renders in gamma space.

Best Practices

  • Write through the console or the terminal, not by hand. TextModeConsole stamps atlas data for you. Hand-built cells are for recoloring and attribute changes on content that is already there.
  • Do not batch your writes. Dirty-range tracking means redrawing a screen every frame costs one buffer update of the touched span. Building your own change detection on top is usually slower than just drawing.
  • Redraw whole screens rather than diffing. A full 80 by 25 redraw is 2,000 cell writes, which is nothing. Diffing logic is a source of bugs that the dirty range already solves for you.
  • Keep one console per grid. A TextModeConsole is a thin writer with no state worth pooling, but making a new one per call is noise. Build it once next to the view.
  • Pin the grid size when your game logic depends on it. A roguelike whose map is authored at 80 by 25 should call Resize, not hope the window cooperates.
  • Set default colors on the grid, not per call, for anything that clears. Clear and the erase operations use the grid’s defaults, so a mismatch between those and what you print with shows up the first time something is cleared.
  • Prefer UITextModeView unless the display is a physical object. Canvas integration gives you masking, sorting and alpha for free, and those are usually what an interface needs.
  • Use ScreenPointToCell rather than computing cells from pixels. It accounts for the view’s transform, the canvas scale factor and the camera, all of which are easy to get subtly wrong by hand.
  • Watch the glyph coverage of your font. A dynamic TextMeshPro atlas builds itself lazily, so it holds only the characters something has already asked for. Add box-drawing and block elements to a static atlas up front if your interface leans on them.
  • Treat Cells as read-mostly. The array is exposed for bulk reads and for code that knows what it is doing. Writing through it directly bypasses dirty tracking, so anything you change that way needs a matching MarkRowDirty or it will not reach the GPU.
  • Let the view own its grid unless you have a reason not to. SetGrid is for double buffering and prepared screens; using it casually makes it harder to reason about which grid an effect or a terminal is attached to.

Pitfalls