Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions docs/source/architecture/palette.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
Palette System
==============

OpenOMF uses paletted rendering. Every pixel in every sprite is stored as an 8-bit
palette index (0-255). At render time, these indices are resolved to RGB colors via a global
palette. The palette itself can be manipulated per-frame by stacking **palette transforms** to
produce effects like tinting, darkening, and cross-fading.

The internal palette has been extended from the original VGA 256-color limit to **1024 colors**
to support features like expanded HAR color gradients. Game data files store colors as 6-bit
per channel (0-63); these are converted to 8-bit at load time.

Palette Layout
--------------

The 256-color base palette is divided into the following zones:

============== ===============================================
Index Range Purpose
============== ===============================================
0x00 Always black (transparency / background)
0x01 - 0x2F Player 1 HAR colors (3 shades x 16 hues)
0x30 Always black (transparency / background)
0x31 - 0x5F Player 2 HAR colors (3 shades x 16 hues)
0x60 - 0x9F Background / arena colors (64 colors)
0xA0 - 0xF9 Shared object colors (projectiles, effects, hazards)
0xFA - 0xFF Menu UI colors (6 colors)
============== ===============================================

On scene load, the entire palette is set from the BK file, then menu colors are written to
indices 250-255, index 0 is forced to black, and HAR colors overwrite 0x01-0x5F at arena start.

The 64-color background block (0x60-0x9F) can be swapped from BK palette variants to change
the arena's look (e.g. the desert arena cycles palettes between rounds to simulate time of day).

Index 255 pulses through a green gradient on a 16-tick cycle, producing the animated
selected-menu-item glow.

HAR Colors
----------

Each player gets a 48-index block divided into 3 **shades** of 16 **hues**::

Player 1: [0x00 .. 0x0F] shade 0 (index 0 is always black, so effectively 0x01-0x0F)
[0x10 .. 0x1F] shade 1
[0x20 .. 0x2F] shade 2

Player 2: [0x30 .. 0x3F] shade 0 (index 0x30 is always black, so effectively 0x31-0x3F)
[0x40 .. 0x4F] shade 1
[0x50 .. 0x5F] shade 2

HAR sprite data always uses indices in the 0-47 range. Each object has ``pal_offset`` and
``pal_limit`` fields; the fragment shader shifts any index within the limit by the offset.
Player 1 uses offset 0, player 2 uses offset 48. Projectiles and scrap pieces inherit the
parent HAR's offset/limit.

.. graphviz::

digraph pal_offset {
rankdir=LR;
node [shape=box, style=filled, fontname="sans-serif", fontsize=10];
edge [fontname="sans-serif", fontsize=9];

sprite [label="Sprite index\n0x05", fillcolor="#b3d9ff"];
add [label="+ pal_offset", shape=ellipse, fillcolor="#e8e8e8"];
p1 [label="Player 1: 0x05\n(offset 0)", fillcolor="#b3ffb3"];
p2 [label="Player 2: 0x35\n(offset 48)", fillcolor="#ffcccc"];

sprite -> add;
add -> p1;
add -> p2;
}

Each pilot has three color choices (primary, secondary, tertiary), with values 0-16. Values
0-15 select a 16-color block from ``ALTPALS.DAT`` palette 0; value 16 uses the pilot's custom
palette from ``PLAYERS.PIC``. The three choices map to the three shade blocks. For cutscenes,
the 3x16 color blocks are expanded to 3x32 by interpolation to provide smoother gradients.

Palette Effects
---------------

.. graphviz::

digraph palette_pipeline {
rankdir=LR;
node [shape=box, style=filled, fillcolor="#e8e8e8", fontname="sans-serif", fontsize=10];
edge [fontname="sans-serif", fontsize=9];

base [label="Base\npalette", fillcolor="#b3d9ff"];
copy [label="Copy to\ncurrent", shape=ellipse];
obj [label="Object\ntransforms\n(HAR effects,\nBK tag effects)"];
scene_cf [label="Scene\ncross-fade"];
scene_t [label="Scene\ntransforms\n(arena crossfade)"];
gpu [label="Upload dirty\nranges to GPU", fillcolor="#b3ffb3"];

base -> copy -> obj -> scene_cf -> scene_t -> gpu;
}

Up to 8 transforms can be stacked per frame. Each transform marks which palette indices it
modified via a damage tracker, enabling partial GPU uploads. The available operations are:

- **Tint** (``vga_palette_tint_range``): Brightness-weighted blend toward a reference color.
- **Mix** (``vga_palette_mix_range``): Linear interpolation toward a reference color.
- **Darken** (``vga_palette_darken``): Scale all colors toward black.
- **Light range** (``vga_palette_light_range``): Brightness-weighted blend toward a gray level.
- **Multiply** (``vga_state_mul_base_palette``): Scale a range by a float, applied directly to
the base palette rather than as a stacked transform.

These are driven by animation script tags. HAR animations use tint/mix with a three-phase
envelope (fade in, sustain, fade out) for hit flash effects. Background animations drive
scene-wide palette shifts over time. On the Stadium arena, positional lighting blends each
HAR's palette toward a gray value based on distance from center.

Remap Tables
------------

The game uses **19 remap tables**, each a 256-entry lookup that maps one palette index to
another: ``new_index = table[old_index]``. At load time each table is expanded to 1024 entries
and stored as a GPU texture (entries beyond 255 map to themselves).

A remap table can do anything expressible as an index-to-index mapping. For example, a table
could shift all HAR colors toward darker hues (for a shadow effect), swap one color range for
another (for a glow), or collapse multiple indices onto the same target (for a silhouette).
The tables are authored per-arena in the BK files, so each arena can define its own visual
style for these effects.

Remaps are applied in the fragment shader after the palette index is determined. The effect
selects which table to use and how many **rounds** to apply. With one round, the index is
looked up once. With multiple rounds, the result is fed back through the same table repeatedly,
pushing the color further along:

.. graphviz::

digraph remap {
rankdir=LR;
node [shape=box, style=filled, fontname="sans-serif", fontsize=10];
edge [fontname="sans-serif", fontsize=9];

input [label="Pixel index", fillcolor="#b3d9ff"];
table [label="Remap\ntable[index]", shape=ellipse, fillcolor="#e8e8e8"];
output [label="Final index", fillcolor="#b3ffb3"];

input -> table;
table -> table [label="repeat N rounds"];
table -> output;
}

133 changes: 133 additions & 0 deletions docs/source/architecture/renderer.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
Rendering Pipeline
==================

OpenOMF renders all graphics using **paletted indexing** through an OpenGL 3.3 pipeline. Every
sprite is an 8-bit indexed surface -- its pixels are palette indices, not colors. The renderer
collects all draw calls during a frame, batches them by blend mode, and resolves the final RGB
output through three passes: indexed rendering, palette resolve, and screen scaling.

For palette layout, color zones, and palette transforms, see :doc:`palette`.

Architecture
------------

The renderer uses a plugin-style interface (``renderer.h``) with function pointers for
initialization, drawing, and frame management. The primary backend is **OpenGL3**
(``gl3_renderer.c``); a no-op **Null** backend exists as a fallback. Game code uses the
``video.h`` public API, which delegates to whichever backend is active.

The game's native resolution is **320x200**. Internal framebuffers are scaled by a configurable
``fb_scale`` multiplier. The final output is scaled to the window with aspect ratio correction
(4:3 or stretch) and any active screen shake offset.

Texture Atlas
-------------

All sprites are packed into a single 2048x2048 texture. Each surface gets a unique ID at
creation; on first draw it is bin-packed into the atlas and cached in a hash map (see
:doc:`sprite_packer` for the packing algorithm). The atlas is cleared on scene changes.

Draw Calls and Batching
-----------------------

Game code submits sprites through the ``video_draw*`` family. Each call looks up the sprite in
the atlas and appends a quad to the vertex buffer. No GL draw calls happen yet -- everything is
deferred to ``render_finish()``.

The vertex buffer holds up to **2048 quads** per frame. Each quad carries per-vertex attributes
for position, atlas coordinates, transparency index, remap table/rounds, palette offset/limit,
decimation value, and effect flags. Sprites are tagged with a blend mode and consecutive
same-mode sprites are batched into single ``glMultiDrawArrays`` calls.

Three-Pass Pipeline
-------------------

Pass 1: Indexed Rendering (palette.frag)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

All sprites are drawn into a framebuffer with four 16-bit channels (RGBA16). This is not a
regular color buffer -- each channel encodes a different piece of information for the resolve
pass.

Effects like tints, remaps, and shadows are drawn as separate quads layered on top of the base
sprite. Without protection, these overlay draws would overwrite the base palette index already
stored in the R channel. ``glColorMask`` prevents this: each blend mode masks off the channels
it does not own, so an overlay draw can only touch its designated channel(s) while leaving the
rest intact.

====================== ==== ==== ==== ==== ======================================
Mode R G B A What it writes
====================== ==== ==== ==== ==== ======================================
``MODE_SET`` yes yes yes yes Base sprite index
``MODE_DARK_TINT`` -- yes yes yes Tint overlay
``MODE_REMAP`` -- yes -- -- Remap info overlay
``MODE_SPRITE_SHADOW`` -- yes -- -- Shadow coverage (max-blended)
``MODE_ADD`` -- -- -- yes Additive index (credits)
====================== ==== ==== ==== ==== ======================================

The fragment shader processes each sprite through: pixel decimation (dithered transparency),
shadow mask sampling, transparency discard, palette offset/limit adjustment, remap table
lookup, and channel encoding.

Each sprite carries a ``palette_offset`` and ``palette_limit``. Any index within the limit is
shifted by the offset and clamped. This is how both players share the same HAR sprite data --
player 1 uses offset 0, player 2 uses offset 48 to shift indices into its color zone. See
:doc:`palette` for the full color zone layout.

Pass 2: Palette Resolve (rgba.frag)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

A fullscreen quad reads the paletted framebuffer and reassembles each pixel from the encoded
channels. The R channel provides the base palette index, G encodes a remap table selector and
round count, B carries a dark tint index, and A holds any additive contribution.

The shader first combines the base index with any additive offset. It then checks for dark
tint: if a tint index is present, it replaces or blends with the base index using a
brightness lookup through a remap table. Finally, the remap rounds are applied -- the index
is fed through the selected remap table the requested number of times (see :doc:`palette` for
how remap tables work). The resulting index is looked up in the palette texture to produce the
final RGB color.

Pass 3: Screen Scaling
~~~~~~~~~~~~~~~~~~~~~~~

The resolved RGBA framebuffer is still at internal resolution (320x200 times ``fb_scale``).
A final fullscreen quad scales it to the window. The viewport is letterboxed to 4:3 aspect
ratio (or stretched, depending on settings), and any active screen shake is applied as a
viewport offset. The user can choose between three scaling filters:

============== ===========================================================================
Mode Shader
============== ===========================================================================
0 (None) ``scalers/none.frag`` -- nearest-neighbor passthrough
1 (Bilinear) ``scalers/bilinear.frag`` -- bilinear interpolation of 4 neighboring texels
2 (CRT) ``scalers/crt.frag`` -- scanline darkening and color bleed simulation
============== ===========================================================================

The CRT filter simulates a cathode-ray display by darkening alternating scanlines and blending
each pixel with its left and top neighbors to approximate color bleed.

Sprite Flags
------------

Per-sprite bit flags control which rendering path a sprite takes:

====================== =========================================================
Flag Effect
====================== =========================================================
``SPRITE_REMAP`` Look up the index in a remap table during pass 1
``SPRITE_SHADOW`` Render as a shadow mask (4-sample coverage at 1/4 height)
``SPRITE_INDEX_ADD`` Write to the additive channel (credits)
``SPRITE_HAR_QUIRKS`` Skip remap for indices > 0x30 (Electra/Pyros)
``SPRITE_DARK_TINT`` Use the multi-channel dark tint / stasis path
====================== =========================================================

``object_render()`` translates game-level effects (glow, trail, dark tint, stasis, additive)
into combinations of these flags and remap parameters.

Offscreen Rendering
-------------------

The renderer can also draw to a CPU-accessible ``screen_surface`` instead of the screen. This
runs pass 1 only and reads back the R channel. The result uses 16-bit palette indices
(supporting the full 1024-color palette) and can be converted to a standard 8-bit surface.
2 changes: 2 additions & 0 deletions docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ fighting game One Must Fall 2097.
:caption: Contents:

architecture/gui
architecture/palette
architecture/renderer
architecture/text
architecture/sprite_packer

Expand Down
Loading