Triadica

Triadica is a thin wrapper built with Calcit-js for interactive WebGL Toys.

Explain Video(voice in Chinese)

A demo of a Triadica component:

object $ {} (:draw-mode :triangles)
  :vertex-shader $ inline-shader "\"stitch-bg.vert"
  :fragment-shader $ inline-shader "\"stitch-bg.frag"
  :points $ %{} %nested-attribute (:augment 3) (:length nil)
    :data $ map-indexed chars
      fn (idx c)
        ->
          [] ([] 0 0 0) ([] 1 0 0) ([] 1 -1 0) ([] 0 0 0) ([] 1 -1 0) ([] 0 -1 0)
          map $ fn (x)
            &v+
              &v+ (v-scale x size) position
              v-scale
                [] (+ size gap) 0 0
                , idx
  :attributes $ {}

you specify shaders, you provide points, then it renders. Internally it uses twgl.js for calling WebGL APIs.

Also notice that a lot of magic happens in shaders(GLSL), the code in Calcit-js is mostly scaffolding.

Parts

There are several things that Triadica provides in the code base:

  • scaffolding that collect components(attributes and shaders), and call WebGL with HMR support,
  • 3D perspetive projection, mostly in shaders, also with some code for handling mouse events,
  • Touch Control components that you can use to fly in 3D screen,

Again, I would like to invide you to try demos https://r.tiye.me/Triadica/triadica-space/ , try to click or even maybe drag.

Calcit-js

Calcit has great support for HMR, and decent interop for JavaScript. Take it as a simplified version of ClojureScript if you feel strange.

If you want to use TypeScript, try https://github.com/Triadica/triadica.ts , which implemented a relatively older feature set of Triadica.

Guide

I recommend https://github.com/Triadica/triadica-workflow for trying Triadica. Too much boilerplate code.

An example of a Tradica component looks like this:

defn tiny-cube-object (v)
  let
      geo $ [] ([] -0.5 -0.5 0) ([] -0.5 0.5 0) ([] 0.5 0.5 0) ([] 0.5 -0.5 0) ([] -0.5 -0.5 -1) ([] -0.5 0.5 -1) ([] 0.5 0.5 -1) ([] 0.5 -0.5 -1)
      indices $ [] 0 1 1 2 2 3 3 0 0 4 1 5 2 6 3 7 4 5 5 6 6 7 7 4
      position $ []
        + 400 $ * v 10
        , 400 -1200
    object $ {} (:draw-mode :lines)
      :vertex-shader $ inline-shader "\"lines.vert"
      :fragment-shader $ inline-shader "\"lines.frag"
      :points $ map geo
        fn (p)
          -> p
            map $ fn (i) (* i 40)
            &v+ position
      :indices indices

object define a component that can be passed to WebGL APIs to paint, where:

  • :draw-mode, WebGL draw mode, could be :lines, :triangles, :line-strip, :line-loop,
  • :vertex-shader, string for vertex shader code, which provides positions and attirbutes for each vertex,
  • :fragment-shader, string for fragment shader, which provides coloring algorithm,
  • :points and :indices, delcares information that generates a_position for vertex code, which is exactly position,

For flexibilities and performance, Triadica provides a %nested-attribute record class for declaring type:

object $ {} (:draw-mode :triangles)
  :vertex-shader $ inline-shader "\"stitch-bg.vert"
  :fragment-shader $ inline-shader "\"stitch-bg.frag"
  :points $ %{} %nested-attribute (:augment 3) (:length nil)
    :data $ map-indexed chars
      fn (idx c)
        ->
          [] ([] 0 0 0) ([] 1 0 0) ([] 1 -1 0) ([] 0 0 0) ([] 1 -1 0) ([] 0 -1 0)
          map $ fn (x)
            &v+
              &v+ (v-scale x size) position
              v-scale
                [] (+ size gap) 0 0
                , idx
  • :augment, specifies how many values used for a single vertex, a vec3 point, it's 3,
  • :length, length of points, normally it's total-size-of-array / augment, it could be calculated while nil is passed,
  • :data, lists of floats. float numbers are collected recursively by Tradica, so no need to use mapcat or concat.

Attributes

In WebGL:

"attributes" are inputs to a vertex shader that get their data from buffers.

to each vertex, an attribute might be a point of vec3, or a float, or other vectors.

Triadica tried some ways of reprensenting that, while trying not to be slow.

Simplest way of passing attributes is create a list and pass points directly:

object $ {}
  :draw-mode :triangles
  :points $ []
    [] 1 2 3
    [] 4 5 6
    [] 4 5 6

notice that :points and :indices are used by twgl.js for creating a_position attributes.

internally it's turning into AugmentedTypedArray that consumes by twgl.js .

object $ {}
  :draw-mode :triangles
  :attributes $ {}
    :positions $ []
      [] 1 2 3
      [] 4 5 6
      [] 7 8 9

To make it easier to support complicated logics, there are other ways of passing attributes.

:packed-attrs

To make it easier, we can collect attributes of a single vertex in a map, and nest them in lists:

object $ {}
  :draw-mode :triangles
  :packed-attrs $ []
    []
      {}
        :position $ [] 1 2 3
        :color_type 0
      {}
        :position $ [] 4 5 6
        :color_type 1
      {}
        :position $ [] 7 8 9
        :color_type 2

This could make building some shapes that requires multiple attributes easier by aligning the length of arrays, with some extra performance penalties.

Shaders

In Triadica, you need to pass shader by string:

object $ {}
  :fragment-shader (inline ...)
  :segment-shader (inline ...)

{{triadica_perspective}} (in vertex shader)

Source https://github.com/Triadica/triadica-space/blob/0.0.7/shaders/triadica-perspective.glsl .

provides several uniforms and the function transform_perspective that calculates position on screen:

PointResult result = transform_perspective(p);

vec3 pos_next = result.point; // vec3(x, y, depth)
v_s = result.s;
v_r = result.r;

{{triadica_colors}} (in fragment shader)

Source https://github.com/Triadica/triadica-space/blob/0.0.7/shaders/triadica-colors.glsl .

Provides a function for making colors,

  • hsl3rgb(h, s, l), with all arguments in [0,1].

{{triadica_noises}} (in both)

Source https://github.com/Triadica/triadica-space/blob/0.0.7/shaders/triadica-noises.glsl .

Provides functions for noises:

  • float rand(xy)
  • float snoise(xy) for Simplex 2D noise
  • float pNoise(xy, res) Poisson noise(not sure)

{{triadica_rotation}} (in vertex shader)

Source https://github.com/Triadica/triadica-space/blob/0.0.10/shaders/triadica-rotation.glsl .

Provides a simple function for 3D rotation(around axis that passes origin point):

  • vec3 rotation_around(float p, float axis, float angle)

Mouse Events

Triadica has quite simple events for hanlding. However, it treats every object as an sphere in click detection, its shape is ignored.

For example:

object $ {} (:draw-mode :lines)
  :vertex-shader $ inline-shader "\"lines.vert"
  :fragment-shader $ inline-shader "\"lines.frag"
  :points $ map geo
    fn (p)
      -> p
        map $ fn (i) (* i 40)
        &v+ position
  :indices indices
  :hit-region $ {} (:position position) (:radius 20)
    :on-hit $ fn (e d!) (d! :cube-right 0)
    :on-mousedown $ fn (e d!) (js/console.log "\"mouse down" e)
      reset! *prev-mouse-x $ .-clientX e
    :on-mousemove $ fn (e d!) (js/console.log "\"mouse move" e)
      let
          x $ .-clientX e
        d! :city-spin $ - x @*prev-mouse-x
        reset! *prev-mouse-x x
    :on-mouseup $ fn (e d!) (js/console.log "\"mouseup" e)

:hit-region contains information about how mouse interactions are handled:

  • :position tell vec3 position of the center point,
  • :radius of clickable area. some tolerance area is added for touch screens,
  • :on-hit is called when click event happened in the area,
  • :on-mousedown is called when mousedown event triggered,
  • :on-mouseup is called when mouseup event triggered, or mouse left page,
  • :on-mousemove is called during moving, before :on-mouseup triggered. notice it has impact on performance.

Controls

Besides the builtin control panel for flying, there are several components provided for connecting interactions. All of them are under triadica.comp.drag-point.

comp-drag-point

A control point in 3D that you can drag, within a 2D screen that you are currently in. You have to move your camera in order to move in the 3rd dimension.

comp-drag-point
  {} (:ignore-moving? false)
    :color $ [] 1.0 1.0 1.0
    :size 20
    :position p0
  fn (p1 d!) $ println p1

comp-slider

A control point that returns [] dx dy values that can be used to change you own float value:

comp-slider
  {} (:size 20)
    :color $ [] 1.0 1.0 1.0
    :position v0
  fn (xy d!) $ println xy

comp-button

A point for responding to clicks:

comp-button
  {} (:size 20)
    :color $ [] 1.0 1.0 1.0
    :position $ :p1 store
  fn (e d!) $ println |clicked

3D rotation

Core idea of handling rotation is using a variant of Rodrigues' rotation formula . Explained in a Chinese video:

As a helper function, Triadica provide:

triadica.math/rotate-3d-fn origin axis-direction radian

which creates a rotation function that could rotate:

let
    rotation $ rotate-3d-fn ([] 0 100 0) ([] -1 1 1) 0.04
  rotation p

Lines

Normally you can use :line-strip or :lines for lines:

object $ {}
  :draw-mode :line-strip

However in WebGL, lines has a constent width of 1, which does not meet many scenarios.

There are two components provided under triadica.comp.line:

Segments

comp-segments draws many lines into width 2 rectangles. Brushing direction is calculated from forward direction:

comp-segments $ {} (; :draw-mode :line-strip)
  :segments $ []
    {}
      :from $ [] 0 0 0
      :to $ [] 0 100 0
    {}
      :from $ [] 400 50 -20
      :to $ [] -10 300 40
  :width 2

for curves, try:

comp-segments-curves $ {}
  :curves $ []
    -> (range 400)
      map $ fn (idx)
        let
            angle $ * idx 0.08
            h $ * 0.1 idx
            r 40
          {} $ :position
            []
              + 100 $ * r (cos angle)
              , h $ * r (sin angle)

Tube

comp-tube draws a curve into a tube by generating triangles. Some drawbacks is you have to pass a :normal0 argument to help it decide how to start to cross product for tube surfaces. normal0 is a vec3 vector that is not supposed to be parallel with any 2 points, default value is [] 0 0 1. For smooth curves, it's not hard to pick:

comp-tube $ {} (:draw-mode :line-loop)
  :curve $ -> (range 200)
    map $ fn (idx)
      let
          angle $ * 0.04 idx
          r 200
        {}
          :position $ []
            * r $ cos angle
            * r $ sin angle
            * idx 0.6
  :normal0 $ [] 0 0 1

Brush

comp-brush offer a brush for quickly adding triangles perpendicular to eye sight casted from camera. It's a brush(or 2, 3 brushes) painting extra colors. Default brush is [] 8 0. It may not look good from specific angles, but it's a lot cheaper:

comp-brush $ {} (; :draw-mode :line-strip)
  :curve $ -> (range 200)
    map $ fn (idx)
      let
          angle $ * 0.06 idx
          r 40
        {}
          :position $ []
            * r $ cos angle
            * r $ sin angle
            * idx 0.6
  :brush $ [] 8 0
  :brush1 $ [] 4 4
  :brush2 $ [] 6 3

For tube and brush, more details is explained in the video(Chinese).

Strip light

comp-strip-light draws lines with discrete hexagons, making it looks like strip lights. A gravity option is supported to defined how the strip light is curved.

comp-strip-light
  {} (; :draw-mode :line-strip)
    :lines $ []
      {}
        :from $ [] 0 0 0
        :to $ [] 100 100 0
    :dot-radius 4
    :step 6
    :offset 12
    :gravity $ [] 0 -0.0008 0
    :color $ [] 0.1 0.9 0.5

color field uses HSL color.

Projection

I explained how I did that in a video(voice in Chinese):

The major part of the work calculating in GLSL, vertex shader, if you want to read:

https://github.com/Triadica/triadica-space/blob/0.0.7/shaders/triadica-perspective.glsl

Performance

The process of rendering can be divided into several passes:

  • declare tree of objects in Calcit-js
  • collect points and attributes into typed arrays(via twgl.js)
  • build WebGL program from shaders
  • call WebGL APIs to paint(via twgl.js)
  • optionally, extra framebuffers are used for bloom effect(via twgl.js)

Not all passes need to be re-computed when new frames are painted. In WebGL, we may put parameters that controls how object change in "uniforms", reuse shader programs and "attibutes". That means, when only last 2 steps need to be re-computed for new frames. When you are controlling the camera and the canvas being redrew, normally attributes do not need to be re-computed.

For imperative or OO programs, we may cache arrays for attributes directly. However in tree-shape DSLs, we need to cache them with some tricks. Caclit uses memoizations like in Clojure with the library memof.

; storing 1 item of caches for function
memof.once/memof1-call add3 1 2 3

; storing items of caches of a function by a given key, pass nil to skip
memof.once/memof1-call-by |a-unique-key add3 1 2 3

and each Triadica component is currently a function.

Uniforms

An extra field of injecting uniforms is provided called get-uniforms:

object $ {} (:draw-mode :triangles)
  :vertex-shader $ inline-shader "\"spin-city.vert"
  :fragment-shader $ inline-shader "\"spin-city.frag"
  :attributes attributes
  :get-uniforms $ fn ()
    js-object
      :citySpin $ :spin-city @*dirty-uniforms

in the function, dirty tricks can be used to access mutable states.

About

Prior to Triadica, I was using a tiny framework Quatrefoil for building shapes, which a declarative wrapper on Three.js. Three.js is obviously a lot easier and more powerful. But I figure out I need to learn shaders for very crazy color pattern, so I tried WebGL and twgl.js . Plus, I also want to try if I can get better performance out of WebGL APIs over three.js .

Maybe I should say thanks to beam since it convinced me that it's too hard to call WebGL APIs. WebGL is always a monster to beginners.

Both Quatrefoil and Triadica is based on Calcit-js, which is very similar to ClojureScript. Because I want DSLs, persistent data, functional style APIs, and HMT(hot module replacement). Plus, I'm author of Calcit-js I could fix feature in Calcit to meet my needs in Triadica quickly.

Demonstrations

Initial purposes that drove me to Triadica is to rendering nice shapes, with more flexible vertexes and vivid colors. Some of some are recorded: