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 generatesa_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, avec3
point, it's3
,:length
, length of points, normally it'stotal-size-of-array / augment
, it could be calculated whilenil
is passed,:data
, lists of floats. float numbers are collected recursively by Tradica, so no need to usemapcat
orconcat
.
Attributes
"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 noisefloat 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
tellvec3
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: