diff --git a/docs/source/architecture/palette.rst b/docs/source/architecture/palette.rst new file mode 100644 index 000000000..1bab1d7a6 --- /dev/null +++ b/docs/source/architecture/palette.rst @@ -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; + } + diff --git a/docs/source/architecture/renderer.rst b/docs/source/architecture/renderer.rst new file mode 100644 index 000000000..d14d0ff63 --- /dev/null +++ b/docs/source/architecture/renderer.rst @@ -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. diff --git a/docs/source/index.rst b/docs/source/index.rst index 3a530196c..91a31fc25 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -9,6 +9,8 @@ fighting game One Must Fall 2097. :caption: Contents: architecture/gui + architecture/palette + architecture/renderer architecture/text architecture/sprite_packer