CindyJS

CindyGL Tutorial - Rendering 3D Scenes

In this tutorial, we want to render a three-dimensional scene with colorplot in CindyJS. We will demonstrate the basic indigents that can be used to build complex three-dimensional scenes or to investigate various rendering approaches.

We will build an applet 3d.html, which renders a 3D scene that can be rotated by dragging the mouse:

Prerequisites

We assume that you already have seen some CindyScript and the core functionality of the colorplot-command, for example in the CindyGL live coding tutorial. For the last part, it is good to know how to create your CindyJS-applets or having some experience with the CindyJS editor.

Basic concepts of Raytracing

Let us assume that a camera is located at a particular position, for instance at origin = [0,1,-3] and we are looking along the $z$-axis. For the beginning, let us try to render the plane $y=0$.

How can this be archived through a colorplot on the GPU?

The key idea is that with each pixel a direction dir is associated, which varies for each pixel. The pixel in the middle of the rendered screen should "look" along the z-axis, hence for it, we have dir = [0,0,1]. For pixels that are slightly right of the pixel in the middle, this variable could take the value dir = [0,0.1,1], meaning that the associated direction points slightly more to the right.

More general, we can write dir = [#.x, #.y, 1], where # is the coordinate of the current pixel.

We will say that behind this pixel lies a ray

ray(t) := origin + t*dir

This ray will eventually intersect the scene for some $t>0$. Now, if we want to render the intersection of each ray with the plane $y=0$. We can solve $(\mathrm{origin} + t \cdot \mathrm{dir}).y = 0$ for $t$ and obtain

tfloor = -origin.y/dir.y;

If the computed tfloor>0, then the ray behind the current pixel eventually will intersect the plane $y=0$, otherwise it wont intersect it (and we will display the color of a sky, for instance). Let us put this together:

origin = [0,1,-3];
colorplot(
  dir = [#.x,#.y,1]; //ray(t):=origin+t*dir
  //intersection with floor:
  //ray(floort).y=0 <=> origin.y+floort*dir.y=0
  floort = -origin.y/dir.y;
  if(floort>0,
    [1,1,1]*exp(-|floort|*.2), //ray hits floor
    [.8,.8,1]*exp(-.3*dir.y) //sky
  );
);

Rendering a sphere

Now, instead of the floor, let us render a sphere. For simplicity, let us display the unit sphere located at (0,0,0).

When does ray(t):=origin+t*dir intersect this sphere? This can be reformulated into the solution a quadratic polynomial:

$$ \begin{eqnarray} || \mathrm{ray}(t) || = 1 &\Leftrightarrow& || \mathrm{ray}(t) ||^2 = 1 \Leftrightarrow \langle \mathrm{ray}(t), \mathrm{ray}(t) \rangle = 1 \\ &\Leftrightarrow & \langle \mathrm{origin}, \mathrm{origin} \rangle + 2 t \langle \mathrm{origin}, \mathrm{dir} \rangle + t^2 \langle \mathrm{dir}, \mathrm{dir} \rangle= 1 \\ &\Leftrightarrow& a t^2 + b t + c = 0 \text{ for $a=\langle \mathrm{dir}, \mathrm{dir} \rangle$, $b=2 \langle \mathrm{origin}, \mathrm{dir} \rangle$ and $c=\langle \mathrm{origin}, \mathrm{origin} \rangle$}\ \end{eqnarray} $$ Hence, whenever the discriminant $D=b^2-4 a c$ of the polynomial $a t^2 + b t + c$ is non-negative, the ray intersects the sphere. Scalar products can be computed in CindyScript via *. The two intersection points can be computed as $t_{1,2} = \frac{-b \pm \sqrt{b^2-4 a c}}{2 a}$. This can be used for the following program that "renders" a sphere:

origin = [0,1,-3];
colorplot(
  dir = [#.x,#.y,1];
  a = (dir*dir);
  b = 2*(origin*dir);
  c = origin*origin-1;
  D = b^2-4*a*c; //discriminant
  if(D>0, //there is some intersection
     [1,.3,.3], //some reddish color
     [.8,.8,1]*exp(-.3*dir.y) //sky
    );
);

How can we colorize the sphere, which now shown as a reddish circle only, properly? One trick is that the coordinate on the unit sphere itself is already the normal of the sphere at this position. The normal can be used for approximating some diffuse reflection.

ray(t):= origin+t*dir;
origin = [0,1,-3];
colorplot(
  dir = [#.x,#.y,1];
  a = (dir*dir);
  b = 2*(origin*dir);
  c = origin*origin-1;
  D = b^2-4*a*c; //discriminant
  if(D>0, //there is some intersection
     normal = ray((-b-re(sqrt(D)))/(2*a));
     (normal*[1,1,-1])*[1,.3,.3],
     [.8,.8,1]*exp(-.3*dir.y) //sky
    );
);

Combining multiple elements in a scene

Now, let us combine multiple elements within one scene. For each object $i$ we compute an intersection value $t_i$, i.e. the smallest value such that $\mathrm{ray}(t_i)$ intersects the object. For the corresponding pixel, we display the object $i$ which has the smallest non-negative value $t_i$. We might also say that each object has some different color and also the normal of each object should be taken into consideration. This could be programmed in CindyScript with the following helper function that is executed for each element:

updatehit(t, normal, color) := if(t>0 & t < hitt,
     hitt = t;
     hitpos = ray(t);
     hitnormal = normal;
     hitcolor = color;
);

If updatehit(t, normal, color) is called, it will update the variables hitt, hitpos, hitnormal and hitcolor whenever the current parameters correspond to the so far closest object. The variable hitt has to be initialized to $\infty$, within colorplot. Since there is no Infinity in CindyScript, we just choose a large float-value such as 1e8=$1\cdot 10^8$. Alltogether we can use the following code to render a scene consisting of a floor and a wall:

light = [cos(seconds()),2, sin(seconds())-1];
origin = [0,1,-3];
colorplot(
  dir = [#.x,#.y,1];
  //default values (if no hit)
  hitt = 1e8; hitpos = hitnormal = hitcolor = [0,0,0];
  //intersect with floor
  updatehit((-origin.y)/dir.y, [0,1,0], [1,1,1]);
  //intersect with wall at z=4:
  updatehit((4-origin.z)/dir.z, [0,0,-1], [1,1,0.6]);
  lightdir = (light-hitpos)/|light-hitpos|;
  max(0,lightdir*hitnormal)*hitcolor;
);

Furthermore, we introduced a vector light, which indicates the (moving) source of the light. Click "Enter Fullscreen" if the code covers your entire screen.

We can use the same scheme to futher add the unit-sphere with center $(0,0,0)$ as follows:

light = [cos(seconds()),2, sin(seconds())-1];
origin = [0,1,-3];
colorplot(
  dir = [#.x,#.y,1];
  //default values (if no hit)
  hitt = 1e8; hitpos = hitnormal = hitcolor = [0,0,0];
  updatehit((-origin.y)/dir.y, [0,1,0], [1,1,1]);
  updatehit((4-origin.z)/dir.z, [0,0,-1], [1,1,0.6]);
  //polynomial for |ray(t)|^2=1: sphere
  a = (dir*dir); b = 2*(origin*dir); c = origin*origin-1;
  D = b^2-4*a*c; //discriminant of polynomial a t^2 + b t + c
  if(D>0,
     spheret = (-b-re(sqrt(D)))/(2*a);
     updatehit(spheret, ray(spheret), [1,0,0]);
    );
  lightdir = (light-hitpos)/|light-hitpos|;
  max(0,lightdir*hitnormal)*hitcolor;
);

Moving the camera

In order to avoid a lot of distracting code, we have encapsulated the code above into a helper-function hitray() that is defined as follows and returns the color based on the set variables dir and origin

hitray() := (
  hitt = 1e8; hitpos = hitnormal = hitcolor = [0,0,0];

  //floor
  updatehit((-origin.y)/dir.y, [0,1,0], [1,1,1]);
  //wall
  updatehit((4-origin.z)/dir.z, [0,0,-1], [1,1,0.6]);
  //sphere; polynomial for |ray(t)|^2=1
  a = (dir*dir); b = 2*(origin*dir); c = origin*origin-1;
  D = b^2-4*a*c; //discriminant of polynomial a t^2 + b t + c
  if(D>0,
     spheret = (-b-re(sqrt(D)))/(2*a);
     updatehit(spheret, ray(spheret), [1,0,0]);
    );
  lightdir = (light-hitpos)/|light-hitpos|;
  max(0,lightdir*hitnormal)*hitcolor;
);

and the entire scene can be now rendered as

origin = [0,1,-3];
colorplot(
  dir = [#.x,#.y,1];
  hitray() //computes the color of the intersection of ray(t):=origin+t*dir with the scene
);

Let us move the camera! A simple way of moving the is to change the variable origin. In the examples above, you could replace origin = [0,1,-3]; with origin = [0,2+sin(seconds()),-3] for instance:

origin = [0,2+sin(seconds()),-3];
colorplot(
  dir = [#.x,#.y,1];
  hitray() //computes the color of the intersection of ray(t):=origin+t*dir with the scene
);

Pointing the camera to a target

How can point the camera to a certain target coordinate? Let us introduce a second variable target = [0,0,0]; and demand that dir for the pixel in the middle of the screen should be the vector pointing from origin to target. Let us compute this vector as mdir = (target-origin)/|(target-origin)|;. Further, we can compute a orthogonal basis (v,w,mdir) and and set dir = mdir + #.x*v + #.y*w;. In order to build such a orthogonal basis, we have a freedom in rotating v and w along mdir.

In this context, a good orthogonal basis has the vector w pointing upwards such that the vertical lines on the screen align with the vertical lines in the image (no "Dutch angle"). Such a orthogonal basis can be computed with cross-products as follows:

target = [0,0,0];
origin = [cos(seconds())*2, sin(seconds()/3)+2, sin(seconds())*2];
mdir = (target-origin)/|(target-origin)|;
v = cross([0,1,0], mdir);
w = cross(mdir, v);
v = v/|v|;
w = w/|w|;
colorplot(
  dir = mdir + #.x*v + #.y*w;
  hitray();
);

Once this code is executed, the target at $(0,0,0)$ remains at the center of the screen.

Interaction with the mouse

For many visualizations, it is good to let the user how to watch something. Suppose, we want to study a particular object at the target = [0,0,0] and modify origin with the mouse (by dragging). How can we do this?

The aim is to build an applet such as 3d.html, which renders a 3D scene that can be rotated by dragging the mouse.

In order to build this applet we set origin to be a point on a sphere specified by the coordinates (lambda, phi):

target = [0,0,0];
origin = 2*[cos(lambda)*cos(phi),
          sin(phi),
          sin(lambda)*cos(phi)
        ];
mdir = (target-origin)/|(target-origin)|;
v = cross([0,1,0], mdir);
w = cross(mdir, v);
v = v/|v|;
w = w/|w|;
colorplot(
  dir = mdir + #.x*v + #.y*w;
  hitray();
);

The values of lambda and phi are controlled essentially via the mousedrag-script. In the live-editor of the tutorial you cannot modify these special scripts. For this purpose, you can use the CindyJS online editor available at https://cindyjs.org/editor/. Alternatively, if you are building your own applet based on a file such as boilerplate.html, you can either add some HTML-code

<script id="csmousedrag" type="text/x-cindyscript">
your code
</script>

The mousedrag script is always executed if the browser notices that the mouse is moved while the button is pressed.

In this example we have set the mousedrag-script to the following:

d = mouse()-lastmouse;
lambda = lambda-d.x;
phi = phi-d.y;
lastmouse = mouse();

Which means that whenever the mouse is dragged, the distance traveled by the mouse is also subtracted to the coordinates (lambda, phi). The script should always be well defined. So, in the mousedown-script we should also set

lastmouse = mouse();

and in the init-script we should assign some starting values to lambda and phi.