Graphv

Graphv is a real time 2D rendering library for OCaml, supporting both native and web targets. It is based on the NanoVG C library. A live demo can be viewed here. If the fonts don't load, try refreshing the page.

Overview

Graphv is a performant pure OCaml 2D vector graphics renderer.

Provides:

Modules

Ready to use modules:

Modules for creating a new backend:

Getting Started

Installing with opam

Pick a platform implementation:

This library does not provide context creation or GUI library support. That will be application depedent. For native contexts glfw is recommended and can be installed with: opam install glfw-ocaml

For the web Js_of_ocaml should be installed with: opam install js_of_ocaml

NOTE

Graphv requires a stencil buffer for the OpenGL (and WebGL) backends. Make sure a stencil buffer is present when creating the OpenGL context.

Creating your first application

The boilerplate needed depends on the platform you are developing for. In the source code repository there are two examples, one for native and one for web that show sample implementations. These can be found here.

Native

A minimal native application can be made using two extra libraries, tgles2 and glfw-ocaml. Install these through opam.

dune
(executable
  (name main)
  (libraries
      graphv_gles2_native
      glfw-ocaml
      tgls.tgles2
  )
)
main.ml
open Tgles2

module NVG = Graphv_gles2_native

let _ =
    GLFW.init();
    at_exit GLFW.terminate;
    GLFW.windowHint ~hint:GLFW.ClientApi ~value:GLFW.OpenGLESApi;
    GLFW.windowHint ~hint:GLFW.ContextVersionMajor ~value:2;
    GLFW.windowHint ~hint:GLFW.ContextVersionMinor ~value:0;

    let window = 
        GLFW.createWindow ~width:400 ~height:400 ~title:"window" () 
    in

    GLFW.makeContextCurrent ~window:(Some window);
    GLFW.swapInterval ~interval:1;

    Gl.clear_color 0.3 0.3 0.32 1.;

    let vg = NVG.create
        ~flags:NVG.CreateFlags.(antialias lor stencil_strokes) 
        () 
    in

    while not GLFW.(windowShouldClose ~window) do
        let win_w, win_h = GLFW.getWindowSize ~window in
        Gl.viewport 0 0 win_w win_h;
        Gl.clear (
            Gl.color_buffer_bit
            lor Gl.depth_buffer_bit
            lor Gl.stencil_buffer_bit
        );

        NVG.begin_frame vg
            ~width:(float win_w)
            ~height:(float win_h)
            ~device_ratio:1.
            ;

        NVG.Path.begin_ vg;
        NVG.Path.rect vg ~x:40. ~y:40. ~w:320. ~h:320.;
        NVG.set_fill_color vg 
            ~color:NVG.Color.(rgba ~r:154 ~g:203 ~b:255 ~a:200);
        NVG.fill vg;

        NVG.end_frame vg;

        GLFW.swapBuffers ~window;
        GLFW.pollEvents();
    done;
;;

Web

Once compiled the web demo should look like this.

dune
(executable
  (name main) 
  (modes byte js)
  (preprocess (pps js_of_ocaml-ppx))
  (libraries
      graphv_webgl
      js_of_ocaml
  )
)
main.ml
open Js_of_ocaml
module NVG = Graphv_webgl

(* This scales the canvas to match the DPI of the window,
   it prevents blurriness when rendering to the canvas *)
let scale_canvas (canvas : Dom_html.canvasElement Js.t) =
    let dpr = Dom_html.window##.devicePixelRatio in
    let rect = canvas##getBoundingClientRect in
    let width = rect##.right -. rect##.left in
    let height = rect##.bottom -. rect##.top in
    canvas##.width := width *. dpr |> int_of_float;
    canvas##.height := height *. dpr |> int_of_float;
    let width = Printf.sprintf "%dpx" (int_of_float width) |> Js.string in
    let height = Printf.sprintf "%dpx" (int_of_float height) |> Js.string in
    canvas##.style##.width := width;
    canvas##.style##.height := height;
;;

let _ =
    let canvas = Js.Unsafe.coerce (Dom_html.getElementById_exn "canvas") in
    scale_canvas canvas;

    let webgl_ctx = 
        (* Graphv requires a stencil buffer to work properly *)
        let attrs = WebGL.defaultContextAttributes in
        attrs##.stencil := Js._true;
        match WebGL.getContextWithAttributes canvas attrs 
              |> Js.Opt.to_option 
        with
        | None -> 
            print_endline "Sorry your browser does not support WebGL";
            raise Exit
        | Some ctx -> ctx
    in

    let open NVG in

    let vg = create 
        ~flags:CreateFlags.(antialias lor stencil_strokes) 
        webgl_ctx 
    in 

    (* File in this case is actually the CSS font name *)
    Text.create vg ~name:"sans" ~file:"sans" |> ignore;

    webgl_ctx##clearColor 0.3 0.3 0.32 1.;

    let rec render (time : float) =
        webgl_ctx##clear (
            webgl_ctx##._COLOR_BUFFER_BIT_ 
            lor webgl_ctx##._DEPTH_BUFFER_BIT_ 
            lor webgl_ctx##._STENCIL_BUFFER_BIT_
        );

        let device_ratio = Dom_html.window##.devicePixelRatio in
        begin_frame vg 
            ~width:(canvas##.width) 
            ~height:(canvas##.height) 
            ~device_ratio
            ;
        Transform.scale vg ~x:device_ratio ~y:device_ratio;

        Path.begin_ vg;
        Path.rect vg ~x:40. ~y:40. ~w:320. ~h:320.;
        set_fill_color vg ~color:Color.(rgba ~r:154 ~g:203 ~b:255 ~a:200);
        fill vg;

        Transform.translate vg ~x:200. ~y:200.;
        Transform.rotate vg ~angle:(time *. 0.0005);

        Text.set_font_face vg ~name:"sans";
        Text.set_size vg ~size:48.;
        Text.set_align vg ~align:Align.(center lor middle);
        set_fill_color vg ~color:Color.white;
        Text.text vg ~x:0. ~y:0. "Hello World!";

        NVG.end_frame vg;

        Dom_html.window##requestAnimationFrame (Js.wrap_callback render) 
        |> ignore;
    in

    Dom_html.window##requestAnimationFrame (Js.wrap_callback render) 
    |> ignore;
;;
index.html

Don't forget to change the script path to match wherever you are building this project from.

<!DOCTYPE>
<html>
  <head>
    <style>
html, body {
    width: 100%;
    height: 100%;
    overflow: hidden;
    margin: 0;
    padding: 0;
}

div {
    display: flex;
    align-items: center;
    justify-content: center;
}

canvas {
    width: 400px;
    height: 400px;
}
    </style>
  </head>
  <body>
      <div>
          <canvas id='canvas'></canvas>
      </div>
  </body>
  <script 
    type='text/javascript' 
    defer 
    src='../../_build/default/examples/web_doc/main.bc.js'>
  </script>
</html>

Implementation

This section will contain relevant information about how different features are implemented. It will also discuss some of the limitations that might apply when using different features.

Fonts

Fonts are implemented using a texture atlas. This comes with some pros and cons.

Pros:

Cons:

Usage

Loading a font is very simple, specify a name and a file (or css font-family on the web):

(*1*) let _ = Graphv.Text.create vg ~name:"id" ~file:"filename" in
(*2*) Graphv.Text.add_fallback vg ~name:"id" ~fallback:"another_id";

Step 2 is optional and specifies a secondary font to use when a glyph cannot be found in the first font. Multiple fallbacks can be added for a single font. The most common use case is to pair a regular text font with an emoji or icon font. That way only one font needs to be referenced when drawing to get both text and icons.

To draw using a font specify the size, font face, alignment, blur, and color. You only need to specify these if they have changed since the last text you have drawn, as Graphv is stateful.

let open Graphv in
Text.set_size vg ~size:15.;
Text.set_font_face vg ~name:"sans";
Text.set_blur vg ~blur:2.;
set_fill_color vg ~color:Color.white;
Text.text vg ~x:0. ~y:0. "Hello World!";
Native

Font files must be in the TTF format. If using the default Graphv_gles2_native library, the font must be loadable by the STB True Type library as that is the backend used.

Web

Fonts on the web use the CSS fonts loaded by the browser. This means custom fonts must be loaded using the browsers custom font methods. There are various resources on the web such as this for loading in custom fonts. The example demo uses the CSS method:

@font-face {
    font-family: 'Roboto';
    font-style: normal;
    font-weight: 100;
    font-display: swap;
    src: url('../assets/Roboto-Regular.ttf') format('opentype');
}

and then loads the font with the CSS name:

let _ = NVG.Text.create vg ~name:"sans" ~file:"Roboto" in

Where the `file` is the CSS `font-family` name.

Limitations

Stroke does not work with fonts. Font drawing can only fill the glyph areas.

The biggest limitation is the texture size. Once the texture atlas is full no more glyphs can be added to it unless the atlas is reset. Which will incur the costs of generating all the glyphs again.

To avoid this scenario you should try to limit the combinations of font/size/glyph in your programs. Similarly, the larger the font is, the more space it will take up in the texture atlas. This can cause the texture atlas to fill up with only a few characters instead of a couple thousand. In your program it may be useful to have a number of "preferred" sizes and snap every text string to one of them. In the future some extra font APIs may be added to make managing these scenarios easier.

Another avenue will be exploring alternative font rendering methods like mutli-signed distance fields which have fewer limitations than the standard bitmap texture atlas.

Transforms

Affine transforms work as expected. The important thing to know is when the global transformation is applied to the shape data. When a path object is called, like Graphv.Path.rect, the current global transform is applied to the shape vertices. It is not applied during the fill or stroke operations.

This means you should setup all transforms before creating paths. Take the two examples below:

let open Graphv in
Path.begin_ vg;
(* Transform before *)
Transform.rotate vg ~angle:(Float.pi *. 0.25);
Path.rect vg ~x:0. ~y:0. ~w:100. ~h:100;
fill vg;
let open Graphv in
Path.begin_ vg;
Path.rect vg ~x:0. ~y:0. ~w:100. ~h:100;
(* Transform after *)
Transform.rotate vg ~angle:(Float.pi *. 0.25);
fill vg;

The first example will rotate the rectangle by 45 degrees, the second example will not.

Fonts

Transforms also affect the current font size. So you must be careful about the transform scale otherwise you may use too large a font size and use up the entire font texture atlas.

OpenGL Interop

When working with Graphv and custom OpenGL you need to know when Graphv uses OpenGL state. The two times Graphv modifies OpenGL state are during context creation create, and end_frame.

create

During the context creation Graphv will create some shader programs and textures. This will involve calling certain texture state functions like pixel_storei, bind_texture.

end_frame

End frames modifies a lot of OpenGL state. This call will flush all the pending geometry built up during the frame and send it to the GPU. This will involve shader changes, buffer changes, stencil changes, etc. The full list is below:

You do not need to reset any state that has been modified during custom OpenGL rendering. Graphv will reset all settings to be appropriate values during the next end_frame call.