Skip to content

Instantly share code, notes, and snippets.

@vurtun
Last active November 18, 2023 22:49
Show Gist options
  • Star 8 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save vurtun/a563c0136b9cd314ada25f10c5982f9e to your computer and use it in GitHub Desktop.
Save vurtun/a563c0136b9cd314ada25f10c5982f9e to your computer and use it in GitHub Desktop.

API Design: Builder APIs (October-2020)

Some time has past (three years!) since I last wrote about API specifically about coroutines style APIs so I thought why not write another one about a different API type I encounter relatively often. The builder API.

Now first let me take a step back and put this into 20,000 feet view on where builder APIs are located in the grant scheme. In general everything in computing is separated into input, processing and finally output. In its most basic form I am currently typing on my keyboard. All pressed keys are processed from the OS up to the browser I am writing this in and finally rendered and displayed on the screen as output. Of course this example is very user centric view but still applys to all functionallity in need of an API as well. Another maybe more appplicable example is a collision API that takes in a number of collision primitives and their transformations, finds collisions between these primitives and outputs these collisions with additional information.

My previous post about coroutine style APIs showed a specific practical example on how a processing API could be written with focus on granularity and resource management. Ranging from diagonal API that handles all system resource management like memory or file access to orthogonal coroutine APIs with full API user control of resources including control over if the the functionality should further process at all.

Builder APIs in contrast are placed on the data conversion/packing end of things. They take in a specific input format, process it and finally output the same data in a different format.

Lets look at a relativ well know example of an API taking in scaled vector drawing commands and convert it directly into vertex, element and draw commands for rendering inside a GPU. This kind of API is for example is used inside nuklear to convert an internal primitive buffer (rects, lines, text,...) into an graphics API accessable vertex format:

enum svg_vtx_lay_attr {
  SVG_VTX_POS,
  // ...
};
enum svg_vtx_lay_fmt {
  SVG_FMT_SCHAR,
  SVG_FMT_R8G8B8,
  // ...
};
enum svg_stroke {
  SVG_STROKE_OPEN,
  SVG_STROKE_CLOSED
};
struct svg_vtx_lay_elm {
  enum svg_vtx_lay_attr attr;
  enum svg_vtx_lay_fmt fmt;
  int off;
};
struct svg_cmd {
  unsigned int elem_cnt;
  float clip[4];
  handle tex;
};
struct svg_param {
  const struct svg_vtx_lay_elm *layout;
  int size;
  int align;
};
struct svg_info {
  int vtx_cnt;
  int elm_cnt;
  int cmd_cnt;
};
struct svg {
  int error;
  // ...
};

// init
void svg_begin(struct svg *b, const struct svg_param *param, struct storage *cmds, struct storage *vtx, struct storage *elm);
void svg_end(struct svg *b, struct svg_info *info);

// state
void svg_color(struct svg*, unsigned col);
void svg_rounding(struct svg*, float rounding);
void svg_line_thickness(struct svg*, float thickness);

// path
void svg_path_clear(struct svg*);
void svg_path_line_to(struct svg*, float x, float y);
void svg_path_arc_to(struct svg*, float x, float y, float radius, float a_min, float a_max, unsigned int num_seg);
void svg_path_rect_to(struct svg*, float ax, float ay, float bx, float by);
void svg_path_curve_to(struct svg*, const float *p2, const float *p3, const float *p4, unsigned int num_seg);
void svg_path_fill(struct svg*);
void svg_path_stroke(struct svg*, enum svg_stroke closed);

// stroke
void svg_stroke_line(struct svg*, float ax, float ay, float bx, float by);
void svg_stroke_rect(struct svg*, float x, float y, float w, float h, unsiged col, float rounding);
void svg_stroke_triangle(struct svg*, const float *a, const float *b, const float *c);
void svg_stroke_circle(struct svg*, float x, float y, float radius, unsigned int segs);
void svg_stroke_curve(struct svg*, const float *p0, const float *cp0, const float *cp1, const float *p1, unsigned int seg);
void svg_stroke_poly_line(struct svg*, const float *pnts, unsigned cnt, enum svg_draw_list_stroke);

// fill
void svg_fill_rect(struct svg*, float x, float y, float w, float h);
void svg_fill_triangle(struct svg*, const float *a, const float *b, const float *c);
void svg_fill_circle(struct svg*, float x, float y, float radius, unsigned segs);
void svg_fill_convex(struct svg*, const float *pnts, unsigned cnt);

Most functions and structs should be relativ straightforward to understand. A number of structs to specify a custom output vertex layout with elements like position, texture coordinates and color and their type. A number of functions for path drawing and finally a number of functions for drawing primitives. So lets look at the usage code:

// specify vertex layout
struct your_vertex {
  float pos[2];
  float uv[2];
  unsigned char color[4];
};
static const struct svg_vtx_lay_elm vtx_lay[] = {
  {SVG_VTX_POS, SVG_FMT_FLT, offsetof(struct your_vertex, pos)},
  {SVG_VTX_UV, SVG_FMT_FLT, offsetof(struct your_vertex, uv)},
  {SVG_VTX_COL, SVG_FMT_R8G8B8A8, offsetof(struct your_vertex, color)},
  {SVG_VTX_LAY_END}
};
struct svg_param cfg = {0};
cfg.size = sizeof(struct your_vertex);
cfg.align = alignof(struct your_vertex);
cfg.layout = vtx_lay;

// setup output memory
struct storage buf[3];
storage_init_fixed(&buf[0], calloc(2*1024*1024), 2*1024*1024);
storage_init_fixed(&buf[1], calloc(2*1024*1024), 2*1024*1024);
storage_init_fixed(&buf[2], calloc(2*1024*1024), 2*1024*1024);

// draw stuff
struct svg svg = {0};
svg_begin(&svg, &cfg, &buf[0], &buf[1], &buf[2]);
{
  // ...
  svg_color(&svg, svg_red);
  svg_fill_rect(&svg, 50, 50, 120, 30);
  // ...
}
svg_end(&svg, 0);

// draw to screen
int offset = 0;
svg_draw_foreach(cmd, buf[0]) {
  if (!cmd->elem_cnt) {
    continue;
  }
  glBindTexture(GL_TEXTURE_2D, (GLuint)cmd->texture.id);
  glScissor((GLint)(cmd->clip[0]),
    (GLint)((win_height - (GLint)(cmd->clip[1] + cmd->clip[3]))),
    (GLint)(cmd->clip[2]), (GLint)(cmd->clip[3]);
  glDrawElements(GL_TRIANGLES, (GLsizei)cmd->elem_count, GL_UNSIGNED_SHORT, offset);
  offset += cmd->elem_count;
}

First a vertex layout is specified and memory is reserved until finally we can specify primitives to be drawn. Finally the generated output is pushed to be rendered and output on the screen via graphics API. Specifically in this example I will skip resource handling for now and instead try to focus on the basic data flow for builder APIs. The reason for using a svg type renderer as the first example is that in this case no temporary format and resources are required instead primitives are directly converted into vertexes, elements and draw commands further simplifying the API.

Builders are the prototypical immediate mode APIs going so far as not keeping state between each run of svg_begin to svg_end and the having the caller only directly push new state and therefore never mutates any buffer state.

Most often builder have an initilizing functions call, svg_begin in this case and a final processing call at the end like svg_end. In between data is pushed by one or more function calls. Internally builders either directly convert the passed input data into the desired output format like in this case or stored into a temporary format that is later, often at the commiting end call, converted to the final output format.

Now lets move away from the introductory svg builder example and lets look at a piece of code I decided to extend from static compile time data tables to also be able to choose table columns in runtime and in general abstract over some internal implementation details:

static const char *tbl_titles[] = {"Name", "Path",       "Type",
                                   "Size", "Permission", "Date Modified"};
static const float tbl_panes[] = {-180.f, -6.0f, -225.0f, -6.0f, -50.0f, -6.0f,
                                  -60.0f, -6.0f, -80.0f,  -6.0f, 1.0f,   -6.0f};
static const struct gui_lay_con tbl_con[cntof(tbl_panes)] = {
    {.min = 100, .max = 400}, {.min = 6, .max = 6},
    {.min = 150, .max = 400}, {.min = 6, .max = 6},
    {.min = 50, .max = 400},  {.min = 6, .max = 6},
    {.min = 50, .max = 400},  {.min = 6, .max = 6},
    {.min = 50, .max = 400},  {.min = 6, .max = 6},
    {.min = 100, .max = 400}};
static int tbl_sep[cntof(tbl_panes)];
int tbl_cols[cntof(tbl_panes)];
gui_tbl_hdr(ctx, &tbl, tbl_titles, tbl_cols, tbl_panes, tbl_sep, tbl_con,
            cntof(tbl_panes));

The code snippet defines a graphical user interface table header columns seen here:

dark

The tbl_titles array describes the string title for each column while the size of each column is defined inside the tbl_panes array. It also includes some oddities. Not only column but also separator size is specified. Also pixel sizes are defined as negative while dynamic ratios are defined as positive float weights.

Final compile time constant is array tbl_con of bounding constraints for each column including each separator. The reason for the odd inclusion of separator sizes is that internally the table header uses a splitter which in term uses a general layouting functions. Layouting however requires the size of each element including the separator for both size and constraints. In other words implementation details spilling out into the API. In reality it is not that bad but still something to take care of in combination with compile time limitations.

Now we get to our only actual runtime state array tbl_sep. Since each table column can be resized we need to store the position of each separator that is converted each run into local state tbl_cols which stores the size of each column and separator.

Overall a lot of information not really interesting for the user of the API. From user perspective we just want to define each column with title, size and constraint and not bother with all these details.

So lets look at a potential builder API to construct our required internal table header column format from a more user centric input data subset:

enum gui_col_type {
  GUI_COL_FIX,
  GUI_COL_DYN,
};
struct gui_col {
  const char *title;
  int type;
  float size;
  struct gui_lay_con con;
};
struct gui_tbl_hdr_builder {
  // out
  int err;
  int out_size;
  int col_cnt;
  // internal
  struct gui_col *cols;
  int buf_cnt;
};
struct gui_tbl_hdr {
  // out
  int col_cnt;
  int *cols;
  // internal
  const char **titles;
  struct gui_lay_con *cons;
  float *panes;
  int *sep;
};
void gui_tbl_hdr_begin(struct gui_tbl_builder*, struct arena *a, int col_cnt);
void gui_tbl_hdr_begin_fix(struct gui_tbl_builder*, struct gui_col *buf, int cnt);
void gui_tbl_hdr_add_fix(struct gui_tbl_builder*, const char *title, int size, struct gui_lay_con con);
void gui_tbl_hdr_add_dyn(struct gui_tbl_builder*, const char *title, struct gui_lay_con con);
void gui_tbl_hdr_add(struct gui_tbl_builder*, const struct gui_col *cols, int cnt);
int gui_tbl_hdr_end_mem(struct gui_tbl_hdr **res, struct gui_tbl_builder*, void *memory);
int gui_tbl_hdr_end(struct gui_tbl_hdr **res, struct gui_tbl_builder*, struct arena *);

In this API struct gui_tbl_hdr is our output which is build up inside a struct gui_tbl_builder object. The API allows adding new table columns either by specifing all column data as parameter or directly as our builder internal temporary column format gui_tbl_col. The reason here specifically for this problem is that the number and existence of all table columns is almost always known at compile time and therefore it makes sense to directly just define them in compile time data.

For storing our temporary table header columns an linear allocator arena can be specified that only allows to allocate and frees everything, after the end call. As an alternativ based on the fact we often know the maximum number of column a static array can be passed as a buffer to be filled as well.

Finally since our resulting gui_tbl_hdr object contains a number of dynamic arrays the end call needs some dynamic memory. Lucky enough our builder object tracks the required memory in variable out_size. So it is possible to allocate the required amout of memory and pass it into gui_tbl_hdr_end_mem. Alternatively it is also possible to just call gui_tbl_hdr_end and pass a memory arena.

Now lets look at an example for how the API could used. By the way this code only needs to be called once at the beginning and once for each time the table columns change. So this probably be inside a functions with col_bitmask as input and a gui_tbl_hdr object as output:

enum gui_tbl_col_flags {
  GUI_TBL_COL_TYPE        = (1 << 0),
  GUI_TBL_COL_SIZE        = (1 << 1),
  GUI_TBL_COL_PERMISSION  = (1 << 2),
  GUI_TBL_COL_DATE        = (1 << 3),
  GUI_TBL_COL_ALL         = 0xffffffffu
};
static const struct gui_col default_columns[] = {
  { .title = "Name", .type =  GUI_TBL_HDR_COL_FIX, .size = 180, .con = { .min = 100, .max = 400 } },
  { .title = "Path", .type =  GUI_TBL_HDR_COL_DYN, .size = 1.0f, .con = { .min = 150, .max = 400 } }
};
static const struct gui_col optional_columns[] = {
  { .title = "Type",          .type =  GUI_TBL_HDR_COL_FIX, .size = 50,   .con = { .min = 50, .max = 400 } },
  { .title = "Size",          .type =  GUI_TBL_HDR_COL_FIX, .size = 60,   .con = { .min = 50, .max = 400 } }
  { .title = "Permission",    .type =  GUI_TBL_HDR_COL_FIX, .size = 80,   .con = { .min = 50, .max = 400 } }
  { .title = "Date Modified", .type =  GUI_TBL_HDR_COL_FIX, .size = 100,  .con = { .min = 150, .max = 400 } }
};
#define MAX_COLS 32
struct gui_col cols[MAX_COLS];
static unsigned col_bitmask = GUI_TBL_COL_ALL;

struct gui_tbl_hdr *hdr = 0;
struct gui_tbl_builder b = {0};
gui_tbl_hdr_begin_fix(&b, cols, MAX_COLS);
{
  gui_tbl_hdr_add(&b, default_columns, cntof(default_columns));
  for (int i = 0; i < cntof(optional_columns); ++i) {
    if (col_bitmask & (1 << i)) {
      gui_tbl_hdr_add(b, &optional_columns[i], 1);
    }
  }
}
gui_tbl_hdr_end(&hdr, &b, calloc(1, b.out_size));

I divided our previous columns into two separate camps. The first one are columns that cannot be hidden and most be shown and the second are optional columns. In addition I limit the number of total possible columns to 32 and just use a static array as internal buffer for the gui_tbl_builder. Our not optional columns are directly added while each optional column is added based on a bitset, by walking over each bit and only adding the column if the corrosponding bit is set.

Since the adding column based on bitset is quite useful we now know that it could be useful in general and can add another API function:

void gui_tbl_hdr_add_mask(struct gui_tbl_builder *b, const struct gui_col *cols, const unsigned *mask, int cnt)

Which simplifies our code further by moving the loop and the bit check inside an API code:

struct gui_tbl_hdr *hdr = 0;
struct gui_tbl_builder b = {0};
gui_tbl_hdr_begin_fix(&b, cols, MAX_COLS);
{
  gui_tbl_hdr_add(&b, default_columns, cntof(default_columns));
  gui_tbl_hdr_add_mask(&b, optional_columns, col_bitmask, cntof(optional_columns));
}
gui_tbl_hdr_end(&hdr, &b, calloc(1, b.out_size));

Now as soon as our gui_tbl_hdr object was created it can be called each frame by passing it directly further simplifying the code:

// ...
gui_tbl_hdr(ctx, &tbl, hdr);
// ...

Now what I left out for this example is column resorting which would be another array filled with the column sorting order. I will leave it out for now since it brings no further depth for the builder API explanation.

Now a last concept I want to address even if it doesn't directly make a lot of sense for this particular example is the fact that it is also possible to serialize the final output data into compile time tables:

void gui_tbl_hdr_print(FILE *fp, const struct gui_tbl_hdr *hdr, const char *prefix);

which is called by:

gui_tbl_hdr_print(stdout, hdr, "tbl");

and produces output:

static const char *tbl_titles[] = {"Name", "Path",       "Type",
                                   "Size", "Permission", "Date Modified"};
static const float tbl_panes[] = {-180.f, -6.0f, -225.0f, -6.0f, -50.0f, -6.0f,
                                  -60.0f, -6.0f, -80.0f,  -6.0f, 1.0f,   -6.0f};
static const struct gui_lay_con tbl_con[cntof(tbl_panes)] = {
    {.min = 100, .max = 400}, {.min = 6, .max = 6},
    {.min = 150, .max = 400}, {.min = 6, .max = 6},
    {.min = 50, .max = 400},  {.min = 6, .max = 6},
    {.min = 50, .max = 400},  {.min = 6, .max = 6},
    {.min = 50, .max = 400},  {.min = 6, .max = 6},
    {.min = 100, .max = 400}};
static int tbl_sep[cntof(tbl_panes)];
int tbl_cols[cntof(tbl_panes)];

which brings us full circle again. Like I said not overly useful in this example but I had a case where it proved to be a vital addition.

The final example I want to show is a skeleton builder useful for building a animation skeleton:

struct skeleton;
struct skel_builder;

struct skel_builder* skel_begin(struct arena *tmp, int bone_cnt);
int skel_get_size(const struct skel_builder * b);
void skel_add_bone(struct skel_builder *b, int parent_idx, 
                  float *local_pos3, float *local_orient4, 
                  float *world_pos3, float *world_orient4);
void skel_set_parent(struct skel_builder *b, int bone_idx, int parent_idx);
struct skeleton* skel_end(struct skel_builder *b, struct arena *a);
void skel_end_mem(struct skel_builder *b, void *memory);

We have our usual begin and end call including a functions to compute the final skeleton output memory size, temporary allocators and a functions to add a bone into the skeleton. The actually functionality is not so important here. I rather want to focus on the fact the the skel_builder can be kept private as well. While I personally prefer having my struct public as much as possible in some circumstances it is useful to abstract away the implementation details.

Hopefully I could provide a small overview over immediate mode builder APIs. They are probably the most useful data conversion tool I use and are in my experience very intuitive both in implementation and usage. They work especially well starting any solution by setting up your data in a format the perfectly fits the algorithm to solve your problem which often is not necessarily the data format you get. It almost always makes sense to transform your input data into your prefered processing data format instead of trying to fit your solution around a non-fitting data format.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment