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:
- Performance: Graphv is about 10% slower than the pure C implementation. It also matches the web performance when comparing against a WASM version of the C library.
- Advanced Shapes: Support for the even-odd rule and bezier curves allows complicated 2D shapes with holes to be rendered in a performant manner.
- Advanced Painting: Linear, radial, and box gradients are supported as well as image patterns. This allows shapes to be rendered with complicated patterns and subtle effects.
- OpenGL interoperation: Raw OpenGL calls can be used in addition to Graphv. Graphv uses minimal OpenGL state and can be composed with existing drawing.
- Minimal dependencies: Graphv does not depend on anything other than the Stdlib that ships with OCaml. Full implementations depend on minimal libraries for their platform. For example GLES2 native depends only on
conf-gles2
and the web onjs_of_ocaml
.
Modules
Ready to use modules:
- Graphv_gles2_native: Native GLES2 implementation.
- Graphv_webgl: WebGL implementation.
Modules for creating a new backend:
- Graphv_core: Functor for making a new Graphv library.
- Graphv_gles2: Functor for creating a new OpenGL ES 2 based library.
- Graphv_font.Fontstash: Functor for creating a new Graphv font backend.
Getting Started
Installing with opam
Pick a platform implementation:
opam install graphv_gles2_native
for a native GLES2 implementation.
opam install graphv_webgl
for a WebGL implementation, for use with Js_of_ocaml.
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:
- Simple to implement
- Very fast to render
- One time cost (per glyph+size+font)
- Fairly space efficient
- Minimal CPU<->GPU data
Cons:
- Static, needs a new entry for every glyph/size/font combination
- Can only support a limited number of fonts
- Can only support a limited size of fonts
- Not actually "vectorized"
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:
use_program
enable/disable Gl.cull_face_enum
cull_face
front_face
enable/disable Gl.blend
enable/disable Gl.depth_test
enable/disable Gl.scissor_test
enable/disable Gl.stencil_test
color_mask
stencil_mask
stencil_op
stencil_func
active_texture
bind_texture
bind_buffer
enable_vertex_attrib_array
vertex_attrib_pointer
stencil_op_separate
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.