Dark Mode

picotron_gfx_pipeline.txt
author:  zep
updated: 2024-07-06 (WIP!)
picotron.net

▨ Draw State

Picotron API functions for drawing sprites (spr, map, tline) and shapes (circ, rect, line) all observe a draw state that determines how each pixel is rendered to the draw target:

Colour Tables:    Four 64x64 lookup tables indexed by the draw colour and target colour
Fill Pattern:     A 64-bit 8x8 repeating pattern
Read Mask:        An 8-bit mask applied to the raw draw colour
Write Mask:       An 8-bit mask that determine which bits in the output pixel will be altered
Target Masks:     Applied to the target pixel value during colour table lookup
Camera:           An offset applied to all graphics operations
Clipping Rect:    A rectangle outside of which graphics operations have no effect
Draw Target:      The bitmap being drawn to. Change with set_draw_target(my_bitmap)

▨ Bitmap Formats

Bitmaps in Picotron are 2d 8-bit blocks of userdata.

b = userdata("u8", 32, 32) -- width, height
set(b, 3,4,8)  -- set a pixel 3,4 to red
spr(b, 220,20) -- draw the sprite

The low 6 bits (0x3f) form the colour index (0..63), and the high 2 bits (0xc0) are used to select colour tables.

The 6-bit colour index is a usual index into the display palette. 8 means red etc.

The 2-bit colour table index can have various meanings depending on how the colour tables are set up. They are used to implement more advanced effects like colour blending, shadows and stencils. In most cases only the first colour table (0x8000~0x8fff) is needed and these 2 bits can be ignored.

Picotron's video output format is 6-bit colour index per pixel. When a frame has been sent to video out, it is displayed using an rgb "display palette" defined at 0x5000. There are 4 64-colour display palettes that can be selected per-scanline using bits at 0x5400 (2 bits per scanline, LSB first).

▨ Colour Tables

A colour table is conceptually a 64x64 lookup table that is indexed both by the draw colour, and the colour of the target pixel that is to be written.

For example, drawing red (colour 8) over a blue pixel might produce a different result than drawing over a green pixel, depending on the corresponding entry in the colour table.

Unlike PICO-8, there is no draw palette or per-colour transparency bit. Instead, all colour mapping, transparency, and new effects like colour blending, are implemented using colour tables.

It is often sufficient to use only pal() and palt() to manage colour tables, but the lower-level details are given here to explain the implementation, and for situations where more customisation is needed.

The default colour table is set up so that colour 0 is transparent (any target colour maps to itself so that it is not altered), and colours 1~63 are opaque (any target colour is overwritten by the same colour index).

The 4096 (64x64) bytes of colour table 0 look like this (disregarding high bits -- see below):

0,1,2,3, ... 63,   -- colour 0 maps to whatever the target colour value is
1,1,1,1, ... 1,    -- colour 1 overwrites any target value with 1
2,2,2,2, ... 2,    -- colour 2 overwrites any target value with 2
..
63,63,63 ... 63

Functions pal() and palt() only alter the colour table values, and can be used to efficiently make a colour opaque (and mapped to another colour) or transparent (mapping each target value to itself).

[deleteme: For opaque colour mappings set by pal(), both high bits (0xc0) are always set. This means that the write_mask can be used to control if / which of those bits are set in the target pixel when drawing. // 2024-07 update: no longer true; can just manually set those bits in the colour (or sprite data) if want to do this.]

Transparent colour mappings set by palt() set the high bits to map to the same colour table they appear in. This is similar to mapping colour indexes to themselves -- the result is that when such a colour value is drawn it will leave the target pixel's high bits unchanged.

▨ Masks

Each of the four masks (@0x5508..@0x550a) are used to control which bits in the colour index (0x3f) are read/written, and how colour tables are used (0xc0).

The default mask values are:

0x5508  READ Mask:     0x3f
0x5509  WRITE Mask:    0x3f
0x550a  TARGET Mask:   0x3f  // for sprites
0x550b  TARGET Mask:   0x00  // for shapes

For example, setting the write mask (0x5509) to 0x3 means that only the lowest two bits can be altered in the target bitmap for any given gfx operation. cls() is an exception, and performs a raw memset.

Graphics functions for drawing shapes (rectfill, circfill..) have their own separate target mask (0x550b) that defaults to 0. This means that with the default mask values, drawing a rectangle with colour 0 will be solid rather than transparent, which is the same behaviour as PICO-8.

▨ Colour Table Selection

// can normally be ignored unless setting up custom colour table mappings.

There are 4 colour tables defined at 0x8000. Which colour table is applied for each pixel depends on 3 things:

  1. The high bits (0xc0) in the draw colour
  2. The high bits (0xc0) in the target colour
  3. The fill pattern (used only for sprites)

The colour table index (0..3) is derived from:

  ( ((draw_col & read_mask) | (target_col & target_mask)) >> 6 ) | fillp_bit
   
  // fillp_bit is 0x0 or 0x2, and only used when drawing sprites / textures

By default, the read mask (@0x5508) is 0x3f and the fill pattern is 0, so only the first colour table (at 0x8000) is used unless there are high bits set in the target pixels.

The default colour table at 0x9000 is an identity colour table (has no effect), so setting bit 0x40 in the pixels of the target bitmap will function as a stencil; once set, no gfx operations will alter that pixel value (assuming the default mask values).

To disable colour table selection, unset bits 0xc0 in both the read mask (@0x5508) and the target mask (@0x550a)

▨ Fill Patterns

The global fill pattern is a 8x8 1-bit pattern defined with 8 bytes starting at 0x5500.

It is used differently by shape (circfill, line..) and sprite (spr, map..) functions:

For shape functions, the draw colour (normally passed as the last parameter) is taken to be 2 separate colours. The second colour is stored in the high 8 bits (0xff00) and is used when the fill pattern bit is set for that pixel.

For sprite functions, the draw colour (taken from the sprite's pixel) is taken to be a single colour. Instead, the fill pattern bit is used to control which colour table is used. When the fill pattern bit is set, colour table 2 or 3 is used.

▨ Display Palettes

There are two types of display palettes that apply when each frame is rendered to video out. The first is an indexed display palette (64 bytes from 0x5480) that maps each colour value to another. This defaults to an identity palette (0, 1, 2..) that has no effect. Indexed display palette entries can be set with pal(col0, col1, 1) as in PICO-8.

The second type is the RGB colour values that each 6-bit output value is rendered with. These live at 0x5000 and can be set with pal(col0, argb, 2), where argb is a 32-bit value 0xaarrggbb (alpha is currently ignored).

▨ Drawing a Pixel: Step by Step

This section summarises what happens internally for each pixel drawn; to make it clear ~ code snippets shown here are for explanitory purposes and are not part of a picotron program.

1. x,y is translated by -camera_x, -camera_y

2. Discard when x, y is outside the clipping rectangle

3. A fill pattern bit is calculated based on x,y and the current fill pattern peek8(0x5500)

4. For shape operations, draw_col is shifted right 8 bits when the fill pattern bit is set. (Two possible 8-bit draw colours are provided as a single 16-bit parameter)

5. draw_col is masked by READ_MASK (@0x5508)

6. target_col is read from the draw target at modified x, y

7. target_col is masked by TARGET_MASK (@0x550a for sprites and @0x550b for shapes)

8. A colour table (coltab 0..3) is selected according to 0xc0 in draw_col and target_col

coltab = (draw_col | target_col) >> 6

9. For sprite operations, the colour table is also selected according to the fill pattern bit.

The fill pattern bit value is 0x2, which means that it can be used to switch between colour tables 0 and 2, or between 1 and 3.

coltab |= fill_pattern_bit

10. The colour table selection bits (0xc0) of draw colour and target colour are discarded to leave only colour indexes.

draw_col   &= 0x3f
target_col &= 0x3f

11. Look up colour table (coltab 0..3) using target_col (0..63) and draw_col (0..63) to find the pixel value to be written (0..255)

out_col = peek(0x8000+coltab*0x1000 + draw_col*64 + target_col)
 
// 2024-07: fixed; was "+ target_col*64 + draw_col"
// storing by draw_col first allows pal(a,b) to be a 64-byte memset

12. The result is written into the output, masked by WRITE_MASK (@0x5509)

output_val  = get(draw_target, x, y) & ~WRITE_MASK  --  clear writeable bits
output_val |= (out_col & WRITE_MASK)                --  set writeable bits
set(draw_target, x, y, output_val)

13. At the end of the frame, the output value is transformed by the indexed display palette

output_val = @(0x5480 + (output_val & 0x3f))

14. One of the four rgb palettes is decided based on per-scanline selection at 0x5400:

rgb_pal = (peek(0x5400 + (y >> 2)) >> (y & 0x3)) & 0x3

15. Finally, the indexed output value is used to look up the rgb entry to display.

output_rgb = $(0x5000 + rgb_pal * 0x100 + (output_val & 0x3f) * 4)

▨ tline3d

Graphics functions are similar to PICO-8, with the exception of tline3d:

tline3d(src, x0, y0, x1, y1, u0, v0, u1, v1, [w0, w1])

src can be either a bitmap or a map x,y are screen pixels (ints) u,v are texture coordinates in pixels w is 1/z, useful for perspective-correct texture mapping u,v should be given as u/z and v/z when w0 and w1 are both 1 (the default), tline3d is linear

▨ sspr

sspr is also a little different from PICO-8; the first parameter is the source sprite:

sspr(src, x0,y0,w0,h0, x1,y1,[w1,h1])

▨ CPU costs

// Provisional goals -- current costs for gfx operations:

A full screen sprite or rectfill (480x270) can be drawn 6 times a frame at 60fps
A full screen worth of tline3d calls can be called 2.5 times a frame at 60fps
 
Special fast path for runs of solid colour:

When peek(0x550b) == 0, the target pixel colour is ignored by shape drawing functions that use horizontal spans(circfill, rectfill). In this case, more than one pixel can be processed at once even with fill patterns enabled. Such spans only cost half as much cpu, and will likely cost less in future versions.

[A slightly faster path for sprite functions (spr, sspr, tline, map) is also being considered for the default mask values and fill pattern]

▨ Memory Map

0x5000  rgb display palettes (1k)
0x5400  per-scanline rgb display palette selection (120 bytes)
0x5480  indexed display palette (64 bytes)
0x54c0  reserved (64 bytes)
0x5500  picotron draw state (64 bytes)
  0x5500  fill pattern (8 bytes)
  0x5508  read mask
  0x5509  write mask
  0x550a  target mask (for sprite functions)
  0x550b  target mask (for shapes functions)
  [..]
0x5540  reserved (64 bytes)
0x5580  controller input state (128 bytes)
0x5600  P8SCII custom font (2k)
0X5e00  persistent cart data (256 bytes)
0x5f00  legacy draw state (reserved)
0x5f80  legacy gpio state (reserved)
0x6000  legacy video memory (8k)
0x8000  colour tables (16k)
0xc000  sprite flags (16k)
0x10000 video memory (128k)