3D with Dither
One of the most wonderful features of Dither is that you can write shaders (mini-programs for GPU's) and regular code in the same Dither language, in the same file. Your CPU and GPU code can share functions and constant variables — the compiler automatically figures out which pieces are needed for what. You can even debug shaders by running them on the CPU, where you have the power of print!
Let's make some fun 3D stuff so you can see for yourself!
Overview
Dither comes with a standard library std/g3d for making 3D graphics. g3d's current backends include OpenGL and WebGL, with more on the roadmap. It is supplemented by std/frag, a standard library for working with fragment shaders. frag can be used to shade g3d scenes, as well as on its own to create shadertoy-style renderings, without the need to (manually) set up a 3D environment.
g3d and
frag provide somewhere between a low- and a high-level abstraction. On one hand, it saves you all the tedious setting-up from zero to seeing something on the screen, and hides the nasty bits behind a nice friendly API; On the other hand it doesn't assume much about what you're doing: you'll be writing shaders to render triangles, instead of putting materials on a gameobject.
Getting Started
Let's start with the simplest 3D program: an empty window filled with a single color:
include "std/g3d"
include "std/win"
context := win.init(200,200,win.CONTEXT_3D);
g3d.init(context);
while (1){
g3d.background(0.3,0.6,0.9);
win.poll();
}
First, we include the win library (for window management) and the g3d library. Next we create a 3D rendering context of 200x200 pixels. We initialize g3d by passing the context to it.
In the infinite while loop, we fill the screen with a background color (each RGB component ranges from 0.0 to 1.0). Finally, don't forget to win.poll() to listen for events and refresh the screen (otherwise we'll get stuck in a tight loop!)
Now, let's draw the “hello world” of 3D graphics, a triangle:
include "std/g3d"
include "std/win"
g3d.init(win.init(200,200,win.CONTEXT_3D));
mesh := g3d.Mesh{vertices: list[vec[f32,3]]{
{0,0.5,0},{-0.5,-0.5,0},{0.5,-0.5,0}
}};
cam := g3d.Camera{}
while (1){
g3d.background(0.9,0.6,0.3);
cam.begin();
mesh.draw(g3d.mat.id);
cam.end();
win.poll();
}
We generated a mesh by creating a g3d.Mesh object and filling its vertex list. We set up a camera cam but did not configure it — so everything will be drawn with default coordinate system (X and Y axes from -1 to 1).
In the main loop, we call the draw method of the mesh, surrounded by cam.begin() and cam.end(). The draw method takes a single argument, which is a transformation matrix (called a model matrix) specifying exactly where and at what angle and scale to draw the mesh. Here we pass in a constant identity matrix to mean no transformation need to be applied. (More on transformation matrices later.)
By filling the color list of the mesh, we can draw a colorful triangle:
include "std/g3d"
include "std/win"
g3d.init(win.init(200,200,win.CONTEXT_3D));
mesh := g3d.Mesh{
vertices: list[vec[f32,3]]{
{0,0.5,0},{-0.5,-0.5,0},{0.5,-0.5,0}
},
colors : list[vec[f32,4]]{
{1.,0,0,1},{0.,1,0,1},{0.,0,1,1}
}
};
cam := g3d.Camera{}
while (1){
g3d.background(0.);
cam.begin();
mesh.draw(g3d.mat.id);
cam.end();
win.poll();
}
We only specify the color at each vertex, and the in-between colors are interpolated. For more complicated shading, we can make use of the frag library. But before we start mixing the two libraries, let's try out frag by itself.
Fragment Shader
We introduce frag (and shader-writing in Dither) by rendering a gradient:
include "std/frag"
include "std/win"
frag.init(win.init(200,200,win.CONTEXT_3D));
func gradient(@varying uv:vec[f32,2]):vec[f32,4]{
return {uv.x,uv.y,1.0,1.0};
}
shader:=frag.program(embed gradient as "fragment")
while (1){
frag.begin(shader)
frag.render();
frag.end();
win.poll();
}
We define a gradient function that takes a single argument uv — but notice the @varying in front of it — it is an annotation telling the shader compiler that uv is a special argument passed down from a previous point in the pipeline — when the code is run normally on CPU, such annotations are safely ignored. Anyways, uv refers to UV-coordinates, also known as texcoords, which specifies the mapping between each vertex and a 2D location on a texture.
Here we simply copy the X and Y components (each from 0.0 to 1.0) of the UV coordinate to the red and green components of the output color. The shader is executed per pixel in the GPU, and that's why we see a gradient when all the output pixels are put together.
Next comes the key step: the embed statement takes a function as entry point and passes it to a plugin — in this case one named "fragment", the fragment shader translator-compiler. The plugin generates a string as output (e.g. GLSL code, depending on selected 3D backend), which gets passed into frag.program. frag.program compiles the shader program for later use.
In the main loop, we enable the shader with frag.begin, and call frag.render, which is a shorthand that simply draws a fullscreen rectangle with the shader.
In addition to @varying, we have @uniform, for passing custom variables to the shader.
include "std/frag"
include "std/win"
include "std/time"
include "std/math"
frag.init(win.init(200,200,win.CONTEXT_3D));
func gradient(
@varying uv : vec[f32,2],
@uniform t : f32,
):vec[f32,4]{
return {
(uv.x*3.+t*0.001)%1.,
(uv.y*3.+t*0.001)%1.,
0.5,1.0
};
}
shader:=frag.program(embed gradient as "fragment")
while (1){
frag.begin(shader);
frag.uniform("t", time.millis() as f32)
frag.render();
frag.end();
win.poll();
}
In this example, we import std/time library to access the number of milliseconds passed since the start of the program, and pass it into a @uniform argument with the frag.uniform method.
We'll learn more about the @varyings, @uniforms (and later @builtins) that can be used in fragment shader later.
A Cube
Now let's draw something that actually looks 3D! A cube.
include "std/g3d"
include "std/win"
W := 200
H := 200
g3d.init(win.init(W,H,win.CONTEXT_3D));
mesh := g3d.Mesh{
vertices: list[vec[f32,3]]{
{ 1.,-1,-1},{ 1., 1,-1},{ 1., 1, 1},{ 1.,-1, 1},
{-1.,-1, 1},{-1., 1, 1},{-1., 1,-1},{-1.,-1,-1},
{-1., 1,-1},{-1., 1, 1},{ 1., 1, 1},{ 1., 1,-1},
{-1.,-1, 1},{-1.,-1,-1},{ 1.,-1,-1},{ 1.,-1, 1},
{ 1.,-1, 1},{ 1., 1, 1},{-1., 1, 1},{-1.,-1, 1},
{-1.,-1,-1},{-1., 1,-1},{ 1., 1,-1},{ 1.,-1,-1}
},indices: list[i32]{
0, 1, 2, 0, 2, 3, 4, 5, 6, 4, 6, 7,
8, 9, 10, 8, 10, 11, 12, 13, 14, 12, 14, 15,
16, 17, 18, 16, 18, 19, 20, 21, 22, 20, 22, 23
}};
cam := g3d.Camera{};
cam.perspective(45,W/(H as f32),0.1,100.0);
cam.look_at({3,3,3},{0,0,0},g3d.AXIS_Y);
while (1){
g3d.background(0.4,0.5,0.6);
cam.begin();
mesh.draw(g3d.mat.id);
cam.end();
win.poll();
}
We all know that a cube has 8 vertices. However here we specify 4x6=24 of them. This is because we don't want to share vertices between faces. In 3D graphics, normals directions are specified per-vertex. Therefore, if faces share vertices, they'll look smooth and blended when shaded, which is great if we have a sphere or something actually smooth, but for cubes, this is probably not what we want.
In addition to vertices, we also specify an indices list. This is telling the graphics backend how we want to connect these vertices. Each of the index refers to a vertex by, well, its index, and by default, we'll be drawing triangle by triangle. So the indices go like: first vertex of first triangle, second vertex of first triangle, third vertex of first triangle, first vertex of second triangle, and so on. For a cube, each face consists of two triangles, so we have 12 triangles resulting in 36 indices.
You might be wondering why there isn't a built-in primitive in g3d called cube (and sphere and cone and so on). This is because we figured there're so many ways you might want a primitive, and that g3d should focus on what's definite and what's essential. That said, the official Dither examples include a file prim.dh, an implementation for all the common primitives, that you can reuse.
We configured the camera by giving it a perspective projection (closer things look bigger), with field of view of 45 degrees, and aspect ratio of W/H. The closest thing and furthest thing that can be rendered is 0.1 and 100 respectively. We placed the camera at {3,3,3}, pointing toward {0,0,0}. The third argument of cam.look_at is the “up vector”, which we set to the positive Y direction.
In g3d, positive Y points upward, positive X points toward your right, and positive Z points out of your screen.
The above cube requires some imagination to look 3D. This is because we haven't shaded it yet, which we'll do next.
include "std/g3d"
include "std/win"
include "std/frag"
W := 200
H := 200
context := win.init(W,H,win.CONTEXT_3D)
g3d.init(context);
frag.init(context);
mesh := g3d.Mesh{
vertices: list[vec[f32,3]]{
{ 1.,-1,-1},{ 1., 1,-1},{ 1., 1, 1},{ 1.,-1, 1},
{-1.,-1, 1},{-1., 1, 1},{-1., 1,-1},{-1.,-1,-1},
{-1., 1,-1},{-1., 1, 1},{ 1., 1, 1},{ 1., 1,-1},
{-1.,-1, 1},{-1.,-1,-1},{ 1.,-1,-1},{ 1.,-1, 1},
{ 1.,-1, 1},{ 1., 1, 1},{-1., 1, 1},{-1.,-1, 1},
{-1.,-1,-1},{-1., 1,-1},{ 1., 1,-1},{ 1.,-1,-1}
},indices: list[i32]{
0, 1, 2, 0, 2, 3, 4, 5, 6, 4, 6, 7,
8, 9, 10, 8, 10, 11, 12, 13, 14, 12, 14, 15,
16, 17, 18, 16, 18, 19, 20, 21, 22, 20, 22, 23
},normals: list[vec[f32,3]]{
{ 1., 0,0},{ 1., 0,0},{ 1., 0,0},{ 1., 0,0},
{-1., 0,0},{-1., 0,0},{-1., 0,0},{-1., 0,0},
{ 0., 1,0},{ 0., 1,0},{ 0., 1,0},{ 0., 1,0},
{ 0.,-1,0},{ 0.,-1,0},{ 0.,-1,0},{ 0.,-1,0},
{ 0.,0, 1},{ 0.,0, 1},{ 0.,0, 1},{ 0.,0, 1},
{ 0.,0,-1},{ 0.,0,-1},{ 0.,0,-1},{ 0.,0,-1},
}};
func normal_mat(
@varying normal : vec[f32,3],
):vec[f32,4]{
c := normal*0.5+0.5;
return {c.x,c.y,c.z,1.0};
}
shader := frag.program(
embed normal_mat as "fragment")
cam := g3d.Camera{};
cam.perspective(45,W/(H as f32),0.1,100.0);
cam.look_at({3,3,3},{0,0,0},g3d.AXIS_Y);
while (1){
g3d.background(0);
frag.begin(shader)
cam.begin();
mesh.draw(g3d.mat.id);
cam.end();
frag.end();
win.poll();
}
Notice how we included the frag library, and had it share the same win context with g3d. In addition to vertices and indices, we have a normals list, which specifies the direction each vertex is pointing toward. For each of the faces, the 4 vertices points in the same direction.
We also created a shader with entry point function normal_mat. This shader simply normalizes the normals passed down, and copies the components to the RGB components of the output color.
In the main loop, we surround cam.begin/cam.end with frag.begin and frag.end. The order is important. Also notice how we replaced frag.render found in earlier shader examples with camera use and mesh drawing. In fact, frag.render is internally implemented as drawing a very simple mesh (fullscreen rectangle).
Let's rotate the cube around to make it look more exciting, as well as to make sure we generated all the faces correctly.
include "std/g3d"
include "std/win"
include "std/frag"
W := 200
H := 200
context := win.init(W,H,win.CONTEXT_3D)
g3d.init(context);
frag.init(context);
mesh := g3d.Mesh{
vertices: list[vec[f32,3]]{
{ 1.,-1,-1},{ 1., 1,-1},{ 1., 1, 1},{ 1.,-1, 1},
{-1.,-1, 1},{-1., 1, 1},{-1., 1,-1},{-1.,-1,-1},
{-1., 1,-1},{-1., 1, 1},{ 1., 1, 1},{ 1., 1,-1},
{-1.,-1, 1},{-1.,-1,-1},{ 1.,-1,-1},{ 1.,-1, 1},
{ 1.,-1, 1},{ 1., 1, 1},{-1., 1, 1},{-1.,-1, 1},
{-1.,-1,-1},{-1., 1,-1},{ 1., 1,-1},{ 1.,-1,-1}
},indices: list[i32]{
0, 1, 2, 0, 2, 3, 4, 5, 6, 4, 6, 7,
8, 9, 10, 8, 10, 11, 12, 13, 14, 12, 14, 15,
16, 17, 18, 16, 18, 19, 20, 21, 22, 20, 22, 23
},normals: list[vec[f32,3]]{
{ 1., 0,0},{ 1., 0,0},{ 1., 0,0},{ 1., 0,0},
{-1., 0,0},{-1., 0,0},{-1., 0,0},{-1., 0,0},
{ 0., 1,0},{ 0., 1,0},{ 0., 1,0},{ 0., 1,0},
{ 0.,-1,0},{ 0.,-1,0},{ 0.,-1,0},{ 0.,-1,0},
{ 0.,0, 1},{ 0.,0, 1},{ 0.,0, 1},{ 0.,0, 1},
{ 0.,0,-1},{ 0.,0,-1},{ 0.,0,-1},{ 0.,0,-1},
}};
func normal_mat(
@varying normal : vec[f32,3],
):vec[f32,4]{
c := normal*0.5+0.5;
return {c.x,c.y,c.z,1.0};
}
shader := frag.program(
embed normal_mat as "fragment")
cam := g3d.Camera{};
cam.perspective(45,W/(H as f32),0.1,100.0);
cam.look_at({3,3,3},{0,0,0},g3d.AXIS_Y);
model := g3d.mat.id;
while (1){
model @*= g3d.mat.rotate_deg(g3d.AXIS_Y,1);
model @*= g3d.mat.rotate_deg(g3d.AXIS_X,0.5);
g3d.background(0);
frag.begin(shader)
cam.begin();
mesh.draw(model);
cam.end();
frag.end();
win.poll();
}
We rotate the cube by updating the transformation matrix used to draw the mesh (model matrix) each frame. Here we introduce the @* operator — a special operator denoting matrix multiplication in Dither. @*= is the shorthand for matrix multiply then assign — the same as * and *=. The g3d.mat.rotate_deg function is a convenience function that generates a rotation matrix that rotates anything multiplied to it around a certain axis by certain degrees.
Notice how the color of each face changes as the cube is rotating — this is because normals are in reference to the world.
In Dither, matrices are row-major and left-multiplied (order matters!). Let's illustrate that.
include "std/g3d"
include "std/win"
include "std/frag"
W := 200
H := 200
context := win.init(W,H,win.CONTEXT_3D)
g3d.init(context);
frag.init(context);
mesh := g3d.Mesh{
vertices: list[vec[f32,3]]{
{ 1.,-1,-1},{ 1., 1,-1},{ 1., 1, 1},{ 1.,-1, 1},
{-1.,-1, 1},{-1., 1, 1},{-1., 1,-1},{-1.,-1,-1},
{-1., 1,-1},{-1., 1, 1},{ 1., 1, 1},{ 1., 1,-1},
{-1.,-1, 1},{-1.,-1,-1},{ 1.,-1,-1},{ 1.,-1, 1},
{ 1.,-1, 1},{ 1., 1, 1},{-1., 1, 1},{-1.,-1, 1},
{-1.,-1,-1},{-1., 1,-1},{ 1., 1,-1},{ 1.,-1,-1}
},indices: list[i32]{
0, 1, 2, 0, 2, 3, 4, 5, 6, 4, 6, 7,
8, 9, 10, 8, 10, 11, 12, 13, 14, 12, 14, 15,
16, 17, 18, 16, 18, 19, 20, 21, 22, 20, 22, 23
},normals: list[vec[f32,3]]{
{ 1., 0,0},{ 1., 0,0},{ 1., 0,0},{ 1., 0,0},
{-1., 0,0},{-1., 0,0},{-1., 0,0},{-1., 0,0},
{ 0., 1,0},{ 0., 1,0},{ 0., 1,0},{ 0., 1,0},
{ 0.,-1,0},{ 0.,-1,0},{ 0.,-1,0},{ 0.,-1,0},
{ 0.,0, 1},{ 0.,0, 1},{ 0.,0, 1},{ 0.,0, 1},
{ 0.,0,-1},{ 0.,0,-1},{ 0.,0,-1},{ 0.,0,-1},
}};
func normal_mat(
@varying normal : vec[f32,3],
):vec[f32,4]{
c := normal*0.5+0.5;
return {c.x,c.y,c.z,1.0};
}
shader := frag.program(
embed normal_mat as "fragment")
cam := g3d.Camera{};
cam.perspective(45,W/(H as f32),0.1,100.0);
cam.look_at({0,8,8},{0,0,0},g3d.AXIS_Y);
frame := 0;
while (1){
g3d.background(0);
frag.begin(shader)
cam.begin();
mesh.draw(
g3d.mat.rotate_deg(g3d.AXIS_Y,frame) @*
g3d.mat.translate(0,0,2)
);
mesh.draw(
g3d.mat.translate(0,0,2) @*
g3d.mat.rotate_deg(g3d.AXIS_Y,frame)
);
cam.end();
frag.end();
win.poll();
frame++;
}
Here, one cube is translated first then rotated, so it exhibits this orbital motion, while the other copy is rotated then translated, so it just spins by itself somewhere.
We can also add texture to the cube.
include "std/g3d"
include "std/win"
include "std/frag"
include "std/img"
include "std/io"
include "std/arr"
W := 200
H := 200
context := win.init(W,H,win.CONTEXT_3D)
g3d.init(context);
frag.init(context);
mesh := g3d.Mesh{
vertices: list[vec[f32,3]]{
{ 1.,-1,-1},{ 1., 1,-1},{ 1., 1, 1},{ 1.,-1, 1},
{-1.,-1, 1},{-1., 1, 1},{-1., 1,-1},{-1.,-1,-1},
{-1., 1,-1},{-1., 1, 1},{ 1., 1, 1},{ 1., 1,-1},
{-1.,-1, 1},{-1.,-1,-1},{ 1.,-1,-1},{ 1.,-1, 1},
{ 1.,-1, 1},{ 1., 1, 1},{-1., 1, 1},{-1.,-1, 1},
{-1.,-1,-1},{-1., 1,-1},{ 1., 1,-1},{ 1.,-1,-1}
},indices: list[i32]{
0, 1, 2, 0, 2, 3, 4, 5, 6, 4, 6, 7,
8, 9, 10, 8, 10, 11, 12, 13, 14, 12, 14, 15,
16, 17, 18, 16, 18, 19, 20, 21, 22, 20, 22, 23
},uvs:list[vec[f32,2]]{
{0.,0},{1.,0},{1.,1},{0.,1},{0.,0},{1.,0},
{1.,1},{0.,1},{0.,0},{1.,0},{1.,1},{0.,1},
{0.,0},{1.,0},{1.,1},{0.,1},{0.,0},{1.,0},
{1.,1},{0.,1},{0.,0},{1.,0},{1.,1},{0.,1}
}};
func textured_mat(
@varying uv : vec[f32,2],
@uniform image : frag.Texture,
):vec[f32,4]{
return image.sample(uv);
}
shader := frag.program(
embed textured_mat as "fragment")
pixels := img.decode(io.read_file(
"assets/pepper.png"
));
image := frag.texture(...pixels.shape().yx);
image.write_pixels(pixels);
cam := g3d.Camera{};
cam.perspective(45,W/(H as f32),0.1,100.0);
cam.look_at({3,3,3},{0,0,0},g3d.AXIS_Y);
model := g3d.mat.id;
while (1){
model @*= g3d.mat.rotate_deg(g3d.AXIS_Y,1);
model @*= g3d.mat.rotate_deg(g3d.AXIS_X,0.5);
g3d.background(0.5);
frag.begin(shader);
frag.uniform("image",image);
cam.begin();
mesh.draw(model);
cam.end();
frag.end();
win.poll();
}
First, we need to supply UV-coordinates for each vertex of the cube. We update our shader to sample the texture at given UV-coordinates. We employ std/img library to load pixels and upload it to a frag.Texture, which then gets passed in as a uniform.
Recall that the default mode is to render triangle by triangle, but we do have other options. For example, we can render lines by overwriting the mode field of g3d.Mesh, like the below wireframe example:
include "std/g3d"
include "std/win"
W := 200
H := 200
g3d.init(win.init(W,H,win.CONTEXT_3D));
mesh := g3d.Mesh{
mode : g3d.MODE_LINE_LIST,
vertices: list[vec[f32,3]]{
{ 1.,-1,-1},{ 1., 1,-1},{ 1., 1, 1},{ 1.,-1, 1},
{-1.,-1, 1},{-1., 1, 1},{-1., 1,-1},{-1.,-1,-1},
{-1., 1,-1},{-1., 1, 1},{ 1., 1, 1},{ 1., 1,-1},
{-1.,-1, 1},{-1.,-1,-1},{ 1.,-1,-1},{ 1.,-1, 1},
{ 1.,-1, 1},{ 1., 1, 1},{-1., 1, 1},{-1.,-1, 1},
{-1.,-1,-1},{-1., 1,-1},{ 1., 1,-1},{ 1.,-1,-1}
},indices :list[i32]{
0, 1, 1, 2, 2, 3, 3, 0,
4, 5, 5, 6, 6, 7, 7, 4,
2, 5, 1, 6, 0, 7, 3, 4
}};
cam := g3d.Camera{};
cam.perspective(45,W/(H as f32),0.1,100.0);
cam.look_at({3,3,3},{0,0,0},g3d.AXIS_Y);
model := g3d.mat.id;
while (1){
model @*= g3d.mat.rotate_deg(g3d.AXIS_Y,1);
model @*= g3d.mat.rotate_deg(g3d.AXIS_X,0.5);
g3d.background(0.25,0.2,0.2);
cam.begin();
mesh.draw(model);
cam.end();
win.poll();
}
Notice that the indices list is also updated to specify pairs of line segment endpoints. Available options for mode include MODE_TRIG_LIST (default), MODE_TRIG_STRIP (continuous strip of triangles sharing adjacent edges), MODE_LINE_LIST, MODE_LINE_STRIP (polyline), and MODE_POINT_LIST.
Materials
So far we've been writing (somewhat) trivial shaders, now let's look at what we can do if we want to render something (somewhat) realistic.
But first, let's “upgrade” our mesh from the cube to something more interesting, so we have a better view of the effects. How about an icosahedron?
include "std/g3d"
include "std/win"
include "std/frag"
include "std/list"
include "std/vec"
W := 200
H := 200
context := win.init(W,H,win.CONTEXT_3D)
g3d.init(context);
frag.init(context);
func generate_icosahedron():g3d.Mesh{
PHI := 1.618034
vertices := list[vec[f32,3]]{
{-1,PHI,0},{1,PHI,0},{-1,-PHI,0},{1,-PHI,0},
{0,-1,PHI},{0,1,PHI},{0,-1,-PHI},{0,1,-PHI},
{PHI,0,-1},{PHI,0,1},{-PHI,0,-1},{-PHI,0,1}
};
faces := list[i32]{
0,11,5, 0, 5, 1, 0, 1, 7, 0,7,10, 0,10,11,
1, 5,9, 5,11, 4,11,10, 2,10,7, 6, 7, 1, 8,
3, 9,4, 3, 4, 2, 3, 2, 6, 3,6, 8, 3, 8, 9,
4, 9,5, 2, 4,11, 6, 2,10, 8,6, 7, 9, 8, 1
}
mesh := g3d.Mesh{};
for (i := 0; i < faces.length(); i+=3){
a := vertices[faces[i]];
b := vertices[faces[i+1]];
c := vertices[faces[i+2]];
mesh.vertices.push(a);
mesh.vertices.push(b);
mesh.vertices.push(c);
nml := (b-a).cross(c-b);
mesh.normals.push(nml);
mesh.normals.push(nml);
mesh.normals.push(nml);
}
return mesh;
};
mesh := generate_icosahedron();
func normal_mat(
@varying normal : vec[f32,3],
):vec[f32,4]{
c := normal*0.5+0.5;
return {c.x,c.y,c.z,1.0};
}
shader := frag.program(
embed normal_mat as "fragment")
cam := g3d.Camera{};
cam.perspective(45,W/(H as f32),0.1,100.0);
cam.look_at({0,0,6},{0,0,0},g3d.AXIS_Y);
model := g3d.mat.id;
while (1){
model @*= g3d.mat.rotate_deg(g3d.AXIS_Y,1);
model @*= g3d.mat.rotate_deg(g3d.AXIS_X,0.5);
g3d.background(0);
frag.begin(shader)
cam.begin();
mesh.draw(model);
cam.end();
frag.end();
win.poll();
}
This time, we use a function to generate the mesh — the icosahedron has 20 faces so it becomes a bit tedious to duplicate all the vertices for each face. Therefore, we loop over each of the faces and copy the vertices programmatically. Since the vertices are now in order, and each face is just 1 triangle, we no longer need the indices array.
We computed the face normals by doing a cross product on two edges of each face. Finally we applied a normal material like before, to ensure we got everything wired correctly.
Now, let's do the simplest form of shading, the Lambertian, also known as n dot l.
include "std/g3d"
include "std/win"
include "std/frag"
include "std/list"
include "std/vec"
include "std/math"
W := 200
H := 200
context := win.init(W,H,win.CONTEXT_3D)
g3d.init(context);
frag.init(context);
func generate_icosahedron():g3d.Mesh{
PHI := 1.618034
vertices := list[vec[f32,3]]{
{-1,PHI,0},{1,PHI,0},{-1,-PHI,0},{1,-PHI,0},
{0,-1,PHI},{0,1,PHI},{0,-1,-PHI},{0,1,-PHI},
{PHI,0,-1},{PHI,0,1},{-PHI,0,-1},{-PHI,0,1}
};
faces := list[i32]{
0,11,5, 0, 5, 1, 0, 1, 7, 0,7,10, 0,10,11,
1, 5,9, 5,11, 4,11,10, 2,10,7, 6, 7, 1, 8,
3, 9,4, 3, 4, 2, 3, 2, 6, 3,6, 8, 3, 8, 9,
4, 9,5, 2, 4,11, 6, 2,10, 8,6, 7, 9, 8, 1
}
mesh := g3d.Mesh{};
for (i := 0; i < faces.length(); i+=3){
a := vertices[faces[i]];
b := vertices[faces[i+1]];
c := vertices[faces[i+2]];
mesh.vertices.push(a);
mesh.vertices.push(b);
mesh.vertices.push(c);
nml := (b-a).cross(c-b);
mesh.normals.push(nml);
mesh.normals.push(nml);
mesh.normals.push(nml);
}
return mesh;
};
mesh := generate_icosahedron();
shader := frag.program(embed(func(
@varying normal : vec[f32,3],
@uniform lightdir : vec[f32,3]
):vec[f32,4]{
c := normal.dot(lightdir);
c = math.max(c,0.0)*0.8+0.2;
return {c,c,c,1.0};
}) as "fragment")
cam := g3d.Camera{};
cam.perspective(45,W/(H as f32),0.1,100.0);
cam.look_at({0,0,6},{0,0,0},g3d.AXIS_Y);
model := g3d.mat.id;
while (1){
model @*= g3d.mat.rotate_deg(g3d.AXIS_Y,1);
model @*= g3d.mat.rotate_deg(g3d.AXIS_X,0.5);
g3d.background(0);
frag.begin(shader);
frag.uniform("lightdir",({1.,2,3}).dir())
cam.begin();
mesh.draw(model);
cam.end();
frag.end();
win.poll();
}
As the name suggests, we take the dot product of n(normal) and l(light direction, or more precisely, negative light direction) and use the result to determine the brightness at each given spot.
Intuitively, this makes sense, because dot product is related to the cosine of the angle, so larger the dot product, smaller the angle, the more aligned the light direction is with the normal, and the more “head on” the light hits the surface, hence brighter the surface.
Notice how we clipped n•l to 0-1 then remapped to 0.2-1. The 0.2 can be regarded as ambient color, so even the darkest spot on the mesh is not pitch black.
Try playing with the "lightdir" uniform, or even animate it!
The Lambertian shading is simple, and great for matte-looking stuff. Now let's look at a slightly more sophisticated model, called Blinn-Phong, which can give us shiny surfaces.
include "std/g3d"
include "std/win"
include "std/frag"
include "std/list"
include "std/vec"
include "std/math"
W := 200
H := 200
context := win.init(W,H,win.CONTEXT_3D)
g3d.init(context);
frag.init(context);
func generate_icosahedron():g3d.Mesh{
PHI := 1.618034
vertices := list[vec[f32,3]]{
{-1,PHI,0},{1,PHI,0},{-1,-PHI,0},{1,-PHI,0},
{0,-1,PHI},{0,1,PHI},{0,-1,-PHI},{0,1,-PHI},
{PHI,0,-1},{PHI,0,1},{-PHI,0,-1},{-PHI,0,1}
};
faces := list[i32]{
0,11,5, 0, 5, 1, 0, 1, 7, 0,7,10, 0,10,11,
1, 5,9, 5,11, 4,11,10, 2,10,7, 6, 7, 1, 8,
3, 9,4, 3, 4, 2, 3, 2, 6, 3,6, 8, 3, 8, 9,
4, 9,5, 2, 4,11, 6, 2,10, 8,6, 7, 9, 8, 1
}
mesh := g3d.Mesh{};
for (i := 0; i < faces.length(); i+=3){
a := vertices[faces[i]];
b := vertices[faces[i+1]];
c := vertices[faces[i+2]];
mesh.vertices.push(a);
mesh.vertices.push(b);
mesh.vertices.push(c);
nml := (b-a).cross(c-b);
mesh.normals.push(nml);
mesh.normals.push(nml);
mesh.normals.push(nml);
}
return mesh;
};
mesh := generate_icosahedron();
shader := frag.program(embed(func(
@varying normal:vec[f32,3],
@varying position:vec[f32,3],
@uniform l:vec[f32,3],
@uniform eye:vec[f32,3],
@uniform base:vec[f32,3],
@uniform spec:vec[f32,3],
@uniform shininess:f32,
):vec[f32,4]{
view_dir := eye - position;
half_vec := (l + view_dir).dir();
ndl := math.max(normal.dot(l),0.0);
ndh := math.max(normal.dot(half_vec),0.0);
df := base * (ndl*0.9+0.1);
hl := spec * (ndh ** shininess);
c := df + hl;
return {c.x,c.y,c.z,1.0};
}) as "fragment")
cam := g3d.Camera{};
cam.perspective(45,W/(H as f32),0.1,100.0);
eye := {0.,0,6};
cam.look_at(eye,{0,0,0},g3d.AXIS_Y);
model := g3d.mat.id;
while (1){
model @*= g3d.mat.rotate_deg(g3d.AXIS_Y,1);
model @*= g3d.mat.rotate_deg(g3d.AXIS_X,0.5);
g3d.background(0.3);
frag.begin(shader);
frag.uniform("l",({1.,2,3}).dir());
frag.uniform("eye",eye);
frag.uniform("base",{0.7,0.8,0.9});
frag.uniform("spec",{0.6,0.3,0.0});
frag.uniform("shininess",32.0);
cam.begin();
mesh.draw(model);
cam.end();
frag.end();
win.poll();
}
This shading consists of two components: one is a diffuse shading using the same n•l formula, and on top of it, we add a specular shading for the highlights. In theory, the intensity of the specular is related to how aligned are the direction of the reflected light and the direction of viewing (Phong model). However, we take an approximation and calculate the “half vector” (view direction + light direction, normalized), and use the dot product of half vector and normal to determine intensity of specular (Blinn's modification). There's also the shininess constant, which makes the highlight more focused or spread out by raising the specular to a power.
Finally let's look at a simple yet useful shading — depth.
include "std/g3d"
include "std/win"
include "std/frag"
include "std/list"
include "std/vec"
include "std/math"
W := 200
H := 200
context := win.init(W,H,win.CONTEXT_3D)
g3d.init(context);
frag.init(context);
func generate_icosahedron():g3d.Mesh{
PHI := 1.618034
vertices := list[vec[f32,3]]{
{-1,PHI,0},{1,PHI,0},{-1,-PHI,0},{1,-PHI,0},
{0,-1,PHI},{0,1,PHI},{0,-1,-PHI},{0,1,-PHI},
{PHI,0,-1},{PHI,0,1},{-PHI,0,-1},{-PHI,0,1}
};
faces := list[i32]{
0,11,5, 0, 5, 1, 0, 1, 7, 0,7,10, 0,10,11,
1, 5,9, 5,11, 4,11,10, 2,10,7, 6, 7, 1, 8,
3, 9,4, 3, 4, 2, 3, 2, 6, 3,6, 8, 3, 8, 9,
4, 9,5, 2, 4,11, 6, 2,10, 8,6, 7, 9, 8, 1
}
mesh := g3d.Mesh{};
for (i := 0; i < faces.length(); i+=3){
a := vertices[faces[i]];
b := vertices[faces[i+1]];
c := vertices[faces[i+2]];
mesh.vertices.push(a);
mesh.vertices.push(b);
mesh.vertices.push(c);
nml := (b-a).cross(c-b);
mesh.normals.push(nml);
mesh.normals.push(nml);
mesh.normals.push(nml);
}
return mesh;
};
mesh := generate_icosahedron();
shader := frag.program(embed(func(
@varying position:vec[f32,3],
@uniform view:vec[f32,4,4],
@uniform zmin:f32,
@uniform zmax:f32
):vec[f32,4]{
pw := {position.x, position.y, position.z, 1.0};
pw = view @* pw;
z := -pw.z/pw.w;
z = 1.0-(z-zmin)/(zmax-zmin);
return {z,z,z,1.0};
}) as "fragment")
zmin := 10.0;
zmax := 15.0;
cam := g3d.Camera{};
cam.perspective(45,W/(H as f32),zmin,zmax);
cam.look_at({0.,0,12},{0,0,0},g3d.AXIS_Y);
model := g3d.mat.id;
while (1){
model @*= g3d.mat.rotate_deg(g3d.AXIS_Y,1);
model @*= g3d.mat.rotate_deg(g3d.AXIS_X,0.5);
g3d.background(0.0);
frag.begin(shader);
frag.uniform("view",cam.view);
frag.uniform("zmin",zmin);
frag.uniform("zmax",zmax);
cam.begin();
for (i := 0; i < 10; i++){
mesh.draw(g3d.mat.translate(
i%7-3.5, i%5-2.5, -i%3
) @* model);
}
cam.end();
frag.end();
win.poll();
}
Here we pass the camera's view matrix back into the fragment shader, and use that to calculate each point's position in camera space. then we take that Z and normalize it to given range. An alternative method is also possible, which utilizes @builtin frag_coord's z to calculate Z before projection.
Bump Maps
To have a more detailed-looking mesh, we can, of course, just add more faces to the mesh and makes it actually more detailed. But that comes at the expense of, well, being more expensive. One way to cheat is to use a bump map, which allows us to fake small details on surfaces by perturbing the normals and causing shading to behave as if the details are there.
A bump map can be stored as an image. Each pixel denotes how much to bulge or dent at each given location (kinda like a height map). We can also go an extra step, to generate what's called a normal map, which saves us a step in the shader. But since we're doing everything procedurally anyways, we'll just go with a bump map.
Here we'll be lazy and just generate a Perlin noise for our bump map. This will give our mesh a natural bumpy look. We could also generate our map on the CPU and use write_pixels method of frag.Texture to upload it (if our bump map algorithm contains complicated stuff not easily expressed in parallel manner). But here, since GPU implementation of Perlin noise is built into Dither, we trivially write:
include "std/frag"
include "std/win"
include "std/rand"
include "std/math"
frag.init(win.init(200,200,win.CONTEXT_3D));
shader := frag.program(embed(func(
@varying uv : vec[f32,2],
):vec[f32,4]{
c := rand.noise(uv.x*10.0,uv.y*10.0,0.0);
gd := math.floor(uv*4.0);
if (((gd.x+gd.y)%2.0) as i32 == 0){
c *= 0.5;
}
return {c,c,c,1.0};
}) as "fragment")
while (1){
frag.begin(shader);
frag.render();
frag.end();
win.poll();
}
Now, let's put that bump map into use. Before we slap on additional shading, let's just normalize the resultant normals first.
include "std/g3d"
include "std/win"
include "std/frag"
include "std/list"
include "std/vec"
include "std/math"
include "std/rand"
W := 200
H := 200
context := win.init(W,H,win.CONTEXT_3D)
g3d.init(context);
frag.init(context);
mesh := g3d.Mesh{
vertices: list[vec[f32,3]]{
{ 1.,-1,-1},{ 1., 1,-1},{ 1., 1, 1},{ 1.,-1, 1},
{-1.,-1, 1},{-1., 1, 1},{-1., 1,-1},{-1.,-1,-1},
{-1., 1,-1},{-1., 1, 1},{ 1., 1, 1},{ 1., 1,-1},
{-1.,-1, 1},{-1.,-1,-1},{ 1.,-1,-1},{ 1.,-1, 1},
{ 1.,-1, 1},{ 1., 1, 1},{-1., 1, 1},{-1.,-1, 1},
{-1.,-1,-1},{-1., 1,-1},{ 1., 1,-1},{ 1.,-1,-1}
},indices: list[i32]{
0, 1, 2, 0, 2, 3, 4, 5, 6, 4, 6, 7,
8, 9, 10, 8, 10, 11, 12, 13, 14, 12, 14, 15,
16, 17, 18, 16, 18, 19, 20, 21, 22, 20, 22, 23
},normals: list[vec[f32,3]]{
{ 1., 0,0},{ 1., 0,0},{ 1., 0,0},{ 1., 0,0},
{-1., 0,0},{-1., 0,0},{-1., 0,0},{-1., 0,0},
{ 0., 1,0},{ 0., 1,0},{ 0., 1,0},{ 0., 1,0},
{ 0.,-1,0},{ 0.,-1,0},{ 0.,-1,0},{ 0.,-1,0},
{ 0.,0, 1},{ 0.,0, 1},{ 0.,0, 1},{ 0.,0, 1},
{ 0.,0,-1},{ 0.,0,-1},{ 0.,0,-1},{ 0.,0,-1},
},uvs:list[vec[f32,2]]{
{0.,0},{1.,0},{1.,1},{0.,1},{0.,0},{1.,0},
{1.,1},{0.,1},{0.,0},{1.,0},{1.,1},{0.,1},
{0.,0},{1.,0},{1.,1},{0.,1},{0.,0},{1.,0},
{1.,1},{0.,1},{0.,0},{1.,0},{1.,1},{0.,1}
}};
shader0 := frag.program(embed(func(
@varying uv : vec[f32,2],
):vec[f32,4]{
c := rand.noise(uv.x*10.0,uv.y*10.0,0.0);
gd := math.floor(uv*4.0);
if (((gd.x+gd.y)%2.0) as i32 == 0){
c *= 0.5;
}
return {c,c,c,1.0};
}) as "fragment")
func compute_tbn(
Ng : vec[f32,3],
dpdx:vec[f32,3],dpdy:vec[f32,3],
dtdx:vec[f32,2],dtdy:vec[f32,2],
):vec[f32,3,3]{
det := dtdx.x*dtdy.y - dtdy.x*dtdx.y;
T := dpdx * (dtdy.y/det) + dpdy * (-dtdx.y/det);
B := dpdx * (-dtdy.x/det) + dpdy * (dtdx.x/det);
T = (T - Ng * Ng.dot(T)).dir();
B = (B - Ng * Ng.dot(B)).dir();
N := T.cross(B).dir();
return {
T.x, T.y, T.z;
B.x, B.y, B.z;
N.x, N.y, N.z;
}
}
func gradient(
htex:frag.Texture, uv:vec[f32,2], texel:vec[f32,2]
):vec[f32,2]{
h := htex.sample(uv).r;
hU := htex.sample({(uv.x+texel.x)%1.0,uv.y}).r;
hV := htex.sample({uv.x,(uv.y+texel.y)%1.0}).r;
dhu := (hU - h) / texel.x;
dhv := (hV - h) / texel.y;
return {dhu,dhv};
}
func perturb(
tbn:vec[f32,3,3], dhuv:vec[f32,2], scale:f32
):vec[f32,3]{
T := {tbn[0,0],tbn[0,1],tbn[0,2]};
B := {tbn[1,0],tbn[1,1],tbn[1,2]};
N := {tbn[2,0],tbn[2,1],tbn[2,2]};
return (N - scale * (dhuv.x * T + dhuv.y * B)).dir();
}
shader1:= frag.program(embed(func(
@varying uv : vec[f32,2],
@uniform image : frag.Texture,
@builtin frag_coord : vec[f32,4],
@varying normal : vec[f32,3],
@varying position : vec[f32,3],
@derived d_position_dx : vec[f32,3],
@derived d_position_dy : vec[f32,3],
@derived d_uv_dx : vec[f32,2],
@derived d_uv_dy : vec[f32,2],
):vec[f32,4]{
texel := uv/frag_coord.xy;
dhuv := gradient(image,uv,texel);
tbn := compute_tbn(
normal,d_position_dx,d_position_dy,
d_uv_dx,d_uv_dy
);
nml := perturb(tbn,dhuv,0.05);
c := nml*0.5+0.5;
return {c.x,c.y,c.z,1.0};
}) as "fragment")
image := frag.texture(256,256);
cam := g3d.Camera{};
cam.perspective(45,W/(H as f32),0.1,100.0);
cam.look_at({0,0,5},{0,0,0},g3d.AXIS_Y);
model := g3d.mat.id;
while (1){
model @*= g3d.mat.rotate_deg(g3d.AXIS_Y,1);
model @*= g3d.mat.rotate_deg(g3d.AXIS_X,0.5);
frag.begin(shader0,image);
frag.render();
frag.end();
frag.begin(shader1);
g3d.background(0);
frag.uniform("image",image)
cam.begin();
mesh.draw(model);
cam.end();
frag.end();
win.poll();
}
There're quite a few new ideas introduced here.
First, we need to compute local normal edits by sampling neighboring pixels on the bump map and calculating the slope, i.e. how steep or flat it is at that point. This is done in the gradient function.
Next, we need to figure out how to transform those local normal edits to the space of the surface of the cube. To do so we need to compute the tangent — bitangent — normal matrix (or TBN for short). You might think you can nail it just by knowing the normal, but turns out we want things to be aligned with the UV too, hence the math is a bit more involved. We make use of d_position_dx/y and d_uv_dx/y, which gives us the delta change in position and UV for neighboring pixels. All this calculation is done in compute_tbn.
Finally we actually perturb the normals, which is relatively straightforward in comparison. This is done in perturb.
Great, now let's try out the Blinn-Phong shading on our bump-mapped cube. You can see that it looks kinda realistic!
include "std/g3d"
include "std/win"
include "std/frag"
include "std/list"
include "std/vec"
include "std/math"
include "std/rand"
W := 200
H := 200
context := win.init(W,H,win.CONTEXT_3D)
g3d.init(context);
frag.init(context);
mesh := g3d.Mesh{
vertices: list[vec[f32,3]]{
{ 1.,-1,-1},{ 1., 1,-1},{ 1., 1, 1},{ 1.,-1, 1},
{-1.,-1, 1},{-1., 1, 1},{-1., 1,-1},{-1.,-1,-1},
{-1., 1,-1},{-1., 1, 1},{ 1., 1, 1},{ 1., 1,-1},
{-1.,-1, 1},{-1.,-1,-1},{ 1.,-1,-1},{ 1.,-1, 1},
{ 1.,-1, 1},{ 1., 1, 1},{-1., 1, 1},{-1.,-1, 1},
{-1.,-1,-1},{-1., 1,-1},{ 1., 1,-1},{ 1.,-1,-1}
},indices: list[i32]{
0, 1, 2, 0, 2, 3, 4, 5, 6, 4, 6, 7,
8, 9, 10, 8, 10, 11, 12, 13, 14, 12, 14, 15,
16, 17, 18, 16, 18, 19, 20, 21, 22, 20, 22, 23
},normals: list[vec[f32,3]]{
{ 1., 0,0},{ 1., 0,0},{ 1., 0,0},{ 1., 0,0},
{-1., 0,0},{-1., 0,0},{-1., 0,0},{-1., 0,0},
{ 0., 1,0},{ 0., 1,0},{ 0., 1,0},{ 0., 1,0},
{ 0.,-1,0},{ 0.,-1,0},{ 0.,-1,0},{ 0.,-1,0},
{ 0.,0, 1},{ 0.,0, 1},{ 0.,0, 1},{ 0.,0, 1},
{ 0.,0,-1},{ 0.,0,-1},{ 0.,0,-1},{ 0.,0,-1},
},uvs:list[vec[f32,2]]{
{0.,0},{1.,0},{1.,1},{0.,1},{0.,0},{1.,0},
{1.,1},{0.,1},{0.,0},{1.,0},{1.,1},{0.,1},
{0.,0},{1.,0},{1.,1},{0.,1},{0.,0},{1.,0},
{1.,1},{0.,1},{0.,0},{1.,0},{1.,1},{0.,1}
}};
shader0 := frag.program(embed(func(
@varying uv : vec[f32,2],
):vec[f32,4]{
c := rand.noise(uv.x*10.0,uv.y*10.0,0.0);
gd := math.floor(uv*4.0);
if (((gd.x+gd.y)%2.0) as i32 == 0){
c *= 0.5;
}
return {c,c,c,1.0};
}) as "fragment")
func compute_tbn(
Ng : vec[f32,3],
dpdx:vec[f32,3],dpdy:vec[f32,3],
dtdx:vec[f32,2],dtdy:vec[f32,2],
):vec[f32,3,3]{
det := dtdx.x*dtdy.y - dtdy.x*dtdx.y;
T := dpdx * (dtdy.y/det) + dpdy * (-dtdx.y/det);
B := dpdx * (-dtdy.x/det) + dpdy * (dtdx.x/det);
T = (T - Ng * Ng.dot(T)).dir();
B = (B - Ng * Ng.dot(B)).dir();
N := T.cross(B).dir();
return {
T.x, T.y, T.z;
B.x, B.y, B.z;
N.x, N.y, N.z;
}
}
func gradient(
htex:frag.Texture, uv:vec[f32,2], texel:vec[f32,2]
):vec[f32,2]{
h := htex.sample(uv).r;
hU := htex.sample({(uv.x+texel.x)%1.0,uv.y}).r;
hV := htex.sample({uv.x,(uv.y+texel.y)%1.0}).r;
dhu := (hU - h) / texel.x;
dhv := (hV - h) / texel.y;
return {dhu,dhv};
}
func perturb(
tbn:vec[f32,3,3], dhuv:vec[f32,2], scale:f32
):vec[f32,3]{
T := {tbn[0,0],tbn[0,1],tbn[0,2]};
B := {tbn[1,0],tbn[1,1],tbn[1,2]};
N := {tbn[2,0],tbn[2,1],tbn[2,2]};
return (N - scale * (dhuv.x * T + dhuv.y * B)).dir();
}
shader1:= frag.program(embed(func(
@varying uv : vec[f32,2],
@uniform image : frag.Texture,
@builtin frag_coord : vec[f32,4],
@varying normal : vec[f32,3],
@varying position : vec[f32,3],
@derived d_position_dx : vec[f32,3],
@derived d_position_dy : vec[f32,3],
@derived d_uv_dx : vec[f32,2],
@derived d_uv_dy : vec[f32,2],
@uniform l:vec[f32,3],
@uniform eye:vec[f32,3],
@uniform base:vec[f32,3],
@uniform spec:vec[f32,3],
@uniform shininess:f32,
):vec[f32,4]{
texel := uv/frag_coord.xy;
dhuv := gradient(image,uv,texel);
tbn := compute_tbn(
normal,d_position_dx,d_position_dy,
d_uv_dx,d_uv_dy
);
nml := perturb(tbn,dhuv,0.05);
view_dir := eye - position;
half_vec := (l + view_dir).dir();
ndl := math.max(nml.dot(l),0.0);
ndh := math.max(nml.dot(half_vec),0.0);
df := base * (ndl*0.9+0.1);
hl := spec * (ndh ** shininess);
c := df + hl;
return {c.x,c.y,c.z,1.0};
}) as "fragment")
image := frag.texture(256,256);
cam := g3d.Camera{};
cam.perspective(45,W/(H as f32),0.1,100.0);
eye := {0.,0,5};
cam.look_at(eye,{0,0,0},g3d.AXIS_Y);
model := g3d.mat.id;
while (1){
model @*= g3d.mat.rotate_deg(g3d.AXIS_Y,1);
model @*= g3d.mat.rotate_deg(g3d.AXIS_X,0.5);
frag.begin(shader0,image);
frag.render();
frag.end();
frag.begin(shader1);
g3d.background(0.2);
frag.uniform("image",image);
frag.uniform("l",({1.,2,3}).dir());
frag.uniform("eye",eye);
frag.uniform("base",{0.9,0.7,0.5});
frag.uniform("spec",{0.5,0.5,0.5});
frag.uniform("shininess",32.0);
cam.begin();
mesh.draw(model);
cam.end();
frag.end();
win.poll();
}
Tweak the strength of the bump map by changing the scale argument passed into perturb. Play with the shininess and spec uniforms too to see what different materials you can simulate!
Shadows
Without shadows things can look as if they're floating and just a bit uncanny in general. However shadows are tricky to render too — it's not like you can just tick a checkbox called “shadows” and now you get shadows for everything magically.
Before we introduce “real” shadows, let's look at some sneaky tricks that are good enough for some situations — maybe those include yours!
First, you can bake the shadows into a texture. In another 3D software (such as Blender), you can use whatever expensive raytracing stuff to render the shadow very nicely, and generate a texture and matching UV coordinates. There're many tutorials for that. Then in g3d, you'd just render it like the textured cube example we showed earlier. We won't be going into further details.
Another method is to do planar shadows. If you just have some objects and they all cast shadows onto a flat floor (and not onto each other), then this method will easily give cleaner-looking shadows.
include "std/g3d"
include "std/win"
include "std/frag"
include "std/list"
include "std/vec"
include "std/math"
W := 200
H := 200
context := win.init(W,H,win.CONTEXT_3D)
g3d.init(context);
frag.init(context);
plane := g3d.Mesh{
vertices:list[vec[f32,3]]{
{-5,0,-5},{5,0,-5},{5,0,5},{-5,0,5}
},
indices:list[i32]{
0,1,2, 0,2,3
},
normals:list[vec[f32,3]]{
{0,1,0},{0,1,0},{0,1,0},{0,1,0}
},
uvs:list[vec[f32,2]]{
{0,0},{1,0},{1,1},{0,1}
}
}
func generate_icosahedron():g3d.Mesh{
PHI := 1.618034
vertices := list[vec[f32,3]]{
{-1,PHI,0},{1,PHI,0},{-1,-PHI,0},{1,-PHI,0},
{0,-1,PHI},{0,1,PHI},{0,-1,-PHI},{0,1,-PHI},
{PHI,0,-1},{PHI,0,1},{-PHI,0,-1},{-PHI,0,1}
};
faces := list[i32]{
0,11,5, 0, 5, 1, 0, 1, 7, 0,7,10, 0,10,11,
1, 5,9, 5,11, 4,11,10, 2,10,7, 6, 7, 1, 8,
3, 9,4, 3, 4, 2, 3, 2, 6, 3,6, 8, 3, 8, 9,
4, 9,5, 2, 4,11, 6, 2,10, 8,6, 7, 9, 8, 1
}
mesh := g3d.Mesh{};
for (i := 0; i < faces.length(); i+=3){
a := vertices[faces[i]];
b := vertices[faces[i+1]];
c := vertices[faces[i+2]];
mesh.vertices.push(a);
mesh.vertices.push(b);
mesh.vertices.push(c);
nml := (b-a).cross(c-b);
mesh.normals.push(nml);
mesh.normals.push(nml);
mesh.normals.push(nml);
}
return mesh;
};
mesh := generate_icosahedron();
shader0 := frag.program(embed(func(
):vec[f32,4]{
return {0,0,0,1.0};
}) as "fragment")
shader1 := frag.program(embed(func(
@varying normal : vec[f32,3],
@uniform ld : vec[f32,3]
):vec[f32,4]{
c := normal.dot(ld);
c = math.max(c,0.0)*0.8+0.2;
return {c,c,c,1.0};
}) as "fragment")
shader2 := frag.program(embed(func(
@varying uv : vec[f32,2],
@uniform image : frag.Texture
):vec[f32,4]{
return image.sample(uv);
}) as "fragment")
cam := g3d.Camera{};
cam.perspective(45,W/(H as f32),0.1,100.0);
cam.look_at({5,7,5},{0,2,0},g3d.AXIS_Y);
shadow := frag.texture(W,W);
light := g3d.Camera{}
ld := ({-1,2.,1}).dir();
light.proj = g3d.mat.scale(0.2) @* {
1,-ld.x/ld.y,0,0;
0,-ld.z/ld.y,1,0;
0, 0,0,0;
0, 0,0,1.
}
model := g3d.mat.id;
while (1){
model @*= g3d.mat.rotate_deg(g3d.AXIS_Y,1);
model @*= g3d.mat.rotate_deg(g3d.AXIS_X,0.5);
mmodel := g3d.mat.translate(0,2,0)@*model;
frag.begin(shader0,shadow);
g3d.background(1);
light.begin();
mesh.draw(mmodel);
light.end();
frag.end();
frag.begin(shader1);
g3d.background(0.1,0.2,0.3);
frag.uniform("ld",ld)
cam.begin();
mesh.draw(mmodel);
cam.end();
frag.end();
frag.begin(shader2);
frag.uniform("image",shadow);
cam.begin();
plane.draw(g3d.mat.id);
cam.end();
frag.end();
win.poll();
}
The idea is pretty simple. We create a special camera, whose projection matrix simply projects the whole scene onto the y=0 plane given a light direction. We draw the scene with that camera and the simplest shader possible: all objects are completely black and the background is white. Then we take that B&W rendering of the scene and use it as the texture for the floor.
The shadow looks quite crisp (“hard”), which might be what you want, but sometimes we'd like softer shadows. This can be achieved by doing a blur when sampling the shadow. Here I also reduced the contrast by changing the fore/background color in the shadow texture.
include "std/g3d"
include "std/win"
include "std/frag"
include "std/list"
include "std/vec"
include "std/math"
W := 200
H := 200
context := win.init(W,H,win.CONTEXT_3D)
g3d.init(context);
frag.init(context);
plane := g3d.Mesh{
vertices:list[vec[f32,3]]{
{-5,0,-5},{5,0,-5},{5,0,5},{-5,0,5}
},
indices:list[i32]{
0,1,2, 0,2,3
},
normals:list[vec[f32,3]]{
{0,1,0},{0,1,0},{0,1,0},{0,1,0}
},
uvs:list[vec[f32,2]]{
{0,0},{1,0},{1,1},{0,1}
}
}
func generate_icosahedron():g3d.Mesh{
PHI := 1.618034
vertices := list[vec[f32,3]]{
{-1,PHI,0},{1,PHI,0},{-1,-PHI,0},{1,-PHI,0},
{0,-1,PHI},{0,1,PHI},{0,-1,-PHI},{0,1,-PHI},
{PHI,0,-1},{PHI,0,1},{-PHI,0,-1},{-PHI,0,1}
};
faces := list[i32]{
0,11,5, 0, 5, 1, 0, 1, 7, 0,7,10, 0,10,11,
1, 5,9, 5,11, 4,11,10, 2,10,7, 6, 7, 1, 8,
3, 9,4, 3, 4, 2, 3, 2, 6, 3,6, 8, 3, 8, 9,
4, 9,5, 2, 4,11, 6, 2,10, 8,6, 7, 9, 8, 1
}
mesh := g3d.Mesh{};
for (i := 0; i < faces.length(); i+=3){
a := vertices[faces[i]];
b := vertices[faces[i+1]];
c := vertices[faces[i+2]];
mesh.vertices.push(a);
mesh.vertices.push(b);
mesh.vertices.push(c);
nml := (b-a).cross(c-b);
mesh.normals.push(nml);
mesh.normals.push(nml);
mesh.normals.push(nml);
}
return mesh;
};
mesh := generate_icosahedron();
shader0 := frag.program(embed(func(
):vec[f32,4]{
return {0.1,0.1,0.1,1.0};
}) as "fragment")
shader1 := frag.program(embed(func(
@varying normal : vec[f32,3],
@uniform ld : vec[f32,3]
):vec[f32,4]{
c := normal.dot(ld);
c = math.max(c,0.0)*0.8+0.2;
return {c,c,c,1.0};
}) as "fragment")
shader2 := frag.program(embed(func(
@varying uv : vec[f32,2],
@uniform image : frag.Texture
):vec[f32,4]{
samp := {0.,0.,0.,0.};
for (i := -2; i <= 2; i++){
for (j := -2; j <= 2; j++){
samp += image.sample(uv+{
i as f32 * 0.01,
j as f32 * 0.01,
});
}
}
return samp/25.0;
}) as "fragment")
cam := g3d.Camera{};
cam.perspective(45,W/(H as f32),0.1,100.0);
cam.look_at({5,7,5},{0,2,0},g3d.AXIS_Y);
shadow := frag.texture(W,W);
light := g3d.Camera{}
ld := ({-1,2.,1}).dir();
light.proj = g3d.mat.scale(0.2) @* {
1,-ld.x/ld.y,0,0;
0,-ld.z/ld.y,1,0;
0, 0,0,0;
0, 0,0,1.
}
model := g3d.mat.id;
while (1){
model @*= g3d.mat.rotate_deg(g3d.AXIS_Y,1);
model @*= g3d.mat.rotate_deg(g3d.AXIS_X,0.5);
mmodel := g3d.mat.translate(0,2,0)@*model;
frag.begin(shader0,shadow);
g3d.background(0.9);
light.begin();
mesh.draw(mmodel);
light.end();
frag.end();
frag.begin(shader1);
g3d.background(0.1,0.2,0.3);
frag.uniform("ld",ld)
cam.begin();
mesh.draw(mmodel);
cam.end();
frag.end();
frag.begin(shader2);
frag.uniform("image",shadow);
cam.begin();
plane.draw(g3d.mat.id);
cam.end();
frag.end();
win.poll();
}
Now let's look at how to make “real” shadows, that allows anything to cast shadows on anything.
include "std/g3d"
include "std/frag"
include "std/win"
include "std/list"
include "std/arr"
include "std/time"
include "std/vec"
include "std/math"
include "std/img"
include "std/io"
W := 200;
H := 200;
context := win.init(W,H,win.CONTEXT_3D);
g3d.init(context);
frag.init(context);
plane := g3d.Mesh{
vertices:list[vec[f32,3]]{
{-5,0,-5},{5,0,-5},{5,0,5},{-5,0,5}
},
indices:list[i32]{
0,1,2, 0,2,3
},
normals:list[vec[f32,3]]{
{0,1,0},{0,1,0},{0,1,0},{0,1,0}
},
uvs:list[vec[f32,2]]{
{0,0},{1,0},{1,1},{0,1}
}
}
func generate_icosahedron():g3d.Mesh{
PHI := 1.618034
vertices := list[vec[f32,3]]{
{-1,PHI,0},{1,PHI,0},{-1,-PHI,0},{1,-PHI,0},
{0,-1,PHI},{0,1,PHI},{0,-1,-PHI},{0,1,-PHI},
{PHI,0,-1},{PHI,0,1},{-PHI,0,-1},{-PHI,0,1}
};
faces := list[i32]{
0,11,5, 0, 5, 1, 0, 1, 7, 0,7,10, 0,10,11,
1, 5,9, 5,11, 4,11,10, 2,10,7, 6, 7, 1, 8,
3, 9,4, 3, 4, 2, 3, 2, 6, 3,6, 8, 3, 8, 9,
4, 9,5, 2, 4,11, 6, 2,10, 8,6, 7, 9, 8, 1
}
mesh := g3d.Mesh{};
for (i := 0; i < faces.length(); i+=3){
a := vertices[faces[i]];
b := vertices[faces[i+1]];
c := vertices[faces[i+2]];
mesh.vertices.push(a);
mesh.vertices.push(b);
mesh.vertices.push(c);
nml := (b-a).cross(c-b);
mesh.normals.push(nml);
mesh.normals.push(nml);
mesh.normals.push(nml);
}
return mesh;
};
mesh := generate_icosahedron();
shader0 := frag.program(embed(func(
@varying normal:vec[f32,3],
@varying uv:vec[f32,2],
@varying position:vec[f32,3],
@uniform shadow_map:frag.Texture,
@uniform shadow_matrix:vec[f32,4,4],
@uniform ld:vec[f32,3]
):vec[f32,4]{
ndl := normal.dot(ld);
bias := math.min(math.max(
0.02*math.tan(math.acos(ndl)),
0.0),0.02);
shadow_coord := (shadow_matrix @* {
position.x,position.y,position.z,1.0
}).xyzw;
sh := (shadow_coord.xyz/shadow_coord.w)*0.5+0.5;
lam := math.max(ndl,0.0)*0.5+0.5;
if (shadow_map.sample(sh.xy).x<sh.z-bias){
lam*=0.3;
}
return {lam,lam,lam,1.0};
}) as "fragment");
shader1 := frag.program(embed(func(
@builtin frag_coord:vec[f32,4],
@varying normal:vec[f32,3]
):vec[f32,4]{
c := frag_coord.z;
return {c,c,c,1.0};
}) as "fragment");
cam := g3d.Camera{};
cam.look_at({-7,7,-7},{0,0,0},g3d.AXIS_Y);
cam.perspective(60,W/(H as f32),0.1,100.0);
shadow := frag.texture(512,512);
frame := 0;
func draw_scene(){
plane.draw(g3d.mat.id);
mesh.draw(g3d.mat.translate(-1,4,1) );
mesh.draw(g3d.mat.translate(1,2,-1) );
mesh.draw(g3d.mat.translate(-3,1,-3));
mesh.draw(g3d.mat.translate(-3,1,3) );
}
while (1){
time.fps(120);
ang := frame*0.05;
ld := ({math.cos(ang),0.5,math.sin(ang)}).dir();
light := g3d.Camera{};
light.look_at(ld,{0.,0,0},g3d.AXIS_Y);
light.ortho(-8,8,-8,8,-10,20);
frag.begin(shader1,shadow);
g3d.background(0.0);
light.begin();
draw_scene();
light.end();
frag.end();
frag.begin(shader0);
g3d.background(0.);
frag.uniform("shadow_map",shadow);
shadow_matrix := light.proj@*light.view;
frag.uniform("shadow_matrix",shadow_matrix);
frag.uniform("ld",ld);
cam.begin();
draw_scene();
cam.end();
frag.end();
e := win.poll();
frame++;
}
It follows a similar general idea: we first render some sort of texture from the light's view, then use that to somehow inform the drawing of the main scene.
From the light's view (this time encoded as an ortho projection matrix), we render the depth of the scene. This tells us for each point visible to the light, how far it is from the light. This texture is called the shadow map. Then, in the main shader, we transform each pixel's corresponding 3D coordinate into the space of the light, and figures out if the distance to the light is greater than that stored in our shadow map. If it is greater, then it means there's certain point that is closer to the light, hence blocking the light, which means the current point is in the shadows.
We add a small bias to reduce a type of artifact called shadow acne caused by precision limitations. The bias is adjusted according to the sharpness of the angle between the surface and the light at each given point. Try tweaking the amount of bias (or getting rid of it completely) to have fun with artifacts.
Again, we can add some blur to make the shadow look softer, just like with the planar shadows, please try it out yourself!
Text
Let's take a small break from the math-heavy stuff and look at text drawing in g3d.
g3d provides a fixed 8x16 pixel font. It is mostly intended for debug info. If fancier typographic features are needed, you should look into other methods such as building your own font texture. That said, you can do a lot with the built-in font if you like the pixel aesthetics.
Let's start with drawing some 2D text on the screen.
include "std/g3d"
include "std/win"
W := 200;
H := 200;
context := win.init(W,H,win.CONTEXT_3D);
g3d.init(context);
hud := g3d.Camera{};
hud.ortho(0,W,H,0,-1,1);
while (1){
g3d.background(0.7,0.8,0.8);
hud.begin();
g3d.text("Hello world!", g3d.mat.id);
hud.end();
win.poll();
}
g3d assumes little about how you want your text. So we need to first set up a special camera for it. The ortho camera provided above basically converts the screen into a 2D coordinate system with upper left corner at (0,0). Then, we can draw the text with the transformation matrix set to the identity matrix, thus putting the words at the origin.
Let's have some text of various position, scale and even rotation:
include "std/g3d"
include "std/win"
W := 200;
H := 200;
context := win.init(W,H,win.CONTEXT_3D);
g3d.init(context);
hud := g3d.Camera{};
hud.ortho(0,W,H,0,-1,1);
while (1){
g3d.background(0.0);
hud.begin();
g3d.text("Hello world!", g3d.mat.id);
g3d.text("Hello again!", g3d.mat.translate(16,16,0));
g3d.text("Really!",
g3d.mat.translate(0,32,0) @* g3d.mat.scale(2)
);
g3d.text("Oops!",
g3d.mat.translate(8,64,0) @*
g3d.mat.rotate_deg(g3d.AXIS_Z,10)
);
hud.end();
win.poll();
}
Notice how you can give a full transformation matrix for posing the text — you can actually place it at different depths, and if the depth buffer has not been cleared (with background), then it can be occluded by 3D geometry! This is very handy for drawing info that follows moving objects in the scene. See the following example:
include "std/g3d"
include "std/win"
include "std/frag"
include "std/list"
include "std/vec"
W := 200
H := 200
context := win.init(W,H,win.CONTEXT_3D)
g3d.init(context);
frag.init(context);
PHI := 1.618034
vertices := list[vec[f32,3]]{
{-1,PHI,0},{1,PHI,0},{-1,-PHI,0},{1,-PHI,0},
{0,-1,PHI},{0,1,PHI},{0,-1,-PHI},{0,1,-PHI},
{PHI,0,-1},{PHI,0,1},{-PHI,0,-1},{-PHI,0,1}
};
func generate_icosahedron():g3d.Mesh{
faces := list[i32]{
0,11,5, 0, 5, 1, 0, 1, 7, 0,7,10, 0,10,11,
1, 5,9, 5,11, 4,11,10, 2,10,7, 6, 7, 1, 8,
3, 9,4, 3, 4, 2, 3, 2, 6, 3,6, 8, 3, 8, 9,
4, 9,5, 2, 4,11, 6, 2,10, 8,6, 7, 9, 8, 1
}
mesh := g3d.Mesh{};
for (i := 0; i < faces.length(); i+=3){
a := vertices[faces[i]];
b := vertices[faces[i+1]];
c := vertices[faces[i+2]];
mesh.vertices.push(a);
mesh.vertices.push(b);
mesh.vertices.push(c);
nml := (b-a).cross(c-b);
mesh.normals.push(nml);
mesh.normals.push(nml);
mesh.normals.push(nml);
}
return mesh;
};
mesh := generate_icosahedron();
func normal_mat(
@varying normal : vec[f32,3],
):vec[f32,4]{
c := normal*0.5+0.5;
return {c.x,c.y,c.z,1.0};
}
shader := frag.program(
embed normal_mat as "fragment")
cam := g3d.Camera{};
cam.perspective(45,W/(H as f32),0.1,100.0);
cam.look_at({0,0,6},{0,0,0},g3d.AXIS_Y);
hud := g3d.Camera{};
hud.ortho(0,W,H,0,-1,1);
model := g3d.mat.id;
while (1){
model @*= g3d.mat.rotate_deg(g3d.AXIS_Y,1);
model @*= g3d.mat.rotate_deg(g3d.AXIS_X,0.5);
g3d.background(0.5);
frag.begin(shader)
cam.begin();
mesh.draw(model);
cam.end();
frag.end();
hud.begin();
for (i := 0; i < vertices.length(); i++){
v := vertices[i];
pop := g3d.mat.translate(0,0,0.1);
p := cam.proj @*
cam.view @*
pop @* model @* {v.x;v.y;v.z;1.0}
q := {
(0.5+p.x/p.w*0.5)*W -8,
(0.5-p.y/p.w*0.5)*H -8,
-p.z/p.w,
1.0,
}
g3d.text("#%{i}", g3d.mat.translate(...q.xyz));
}
hud.end();
win.poll();
}
Here we computed the projection of every vertex we wanted to label by reusing the camera's matrices. This ensures the depth calculation is consistent and occlusion are rendered correctly.
The text doesn't have to face the screen — we can also flap it around like any mesh:
include "std/g3d"
include "std/win"
W := 200;
H := 200;
context := win.init(W,H,win.CONTEXT_3D);
g3d.init(context);
cam := g3d.Camera{};
cam.perspective(45,W/(H as f32),0.1,1000.0);
cam.look_at({0,0,50},{0,0,0},g3d.AXIS_Y);
model := g3d.mat.id;
while (1){
model @*= g3d.mat.rotate_deg(g3d.AXIS_Y,2);
model @*= g3d.mat.rotate_deg(g3d.AXIS_X,1);
g3d.background(1.0,1.0,0.7);
cam.begin();
g3d.text("Head",
model @* g3d.mat.scale(1,-1,1) @*
g3d.mat.translate(-16,-8,0.01)
);
g3d.text("Tail",
model @* g3d.mat.rotate_deg(g3d.AXIS_Z,180) @*
g3d.mat.translate(-16,-8,-0.01)
);
cam.end();
win.poll();
}
Conclusion
We've looked at quite a few things with Dither and 3D. While this is not a comprehensive guide, we hope it provides a good foundation for you to figure the rest of the things (such as different type of light sources and multiple copies of them) out.
Let's put everything we've learned together and make this neat little demo:
include "std/g3d"
include "std/win"
include "std/frag"
include "std/list"
include "std/vec"
include "std/math"
include "std/time"
include "std/io"
include "std/rand"
W := 200
H := 200
PHI := 1.618034
context := win.init(W,H,win.CONTEXT_3D)
g3d.init(context);
frag.init(context);
vertices := list[vec[f32,3]]{
{-1,PHI,0},{1,PHI,0},{-1,-PHI,0},{1,-PHI,0},
{0,-1,PHI},{0,1,PHI},{0,-1,-PHI},{0,1,-PHI},
{PHI,0,-1},{PHI,0,1},{-PHI,0,-1},{-PHI,0,1}
};
func generate_icosahedron():g3d.Mesh{
faces := list[i32]{
11, 10, 2, 1, 5, 9, 7, 1, 8, 0, 5, 1, 0, 1, 7,
0, 11, 5, 0, 7, 10, 0, 10, 11, 5, 11, 4, 10, 7, 6,
4, 9, 5, 8, 6, 7, 3, 8, 9, 3, 9, 4, 3, 6, 8,
3, 4, 2, 3, 2, 6, 2, 4, 11, 6, 2, 10, 9, 8, 1
}
mesh := g3d.Mesh{};
for (i := 0; i < faces.length(); i+=3){
a := vertices[faces[i]];
b := vertices[faces[i+1]];
c := vertices[faces[i+2]];
mesh.vertices.push(a);
mesh.vertices.push(b);
mesh.vertices.push(c);
nml := (b-a).cross(c-b).dir();
mesh.normals.push(nml);
mesh.normals.push(nml);
mesh.normals.push(nml);
row := (i/3)/5;
col := (i/3)%5;
mesh.uvs.push({col*0.2+0.1,1-(row*0.2+0.03)});
mesh.uvs.push({col*0.2, 1-(row*0.2+0.2 )});
mesh.uvs.push({col*0.2+0.2,1-(row*0.2+0.2 )});
}
return mesh;
};
mesh := generate_icosahedron();
plane := g3d.Mesh{
vertices:list[vec[f32,3]]{
{-4.,-4,0},{4.,-4,0},{4.,4,0},{-4.,4,0}
},
indices:list[i32]{
0,1,2, 0,2,3
},
normals:list[vec[f32,3]]{
{0,0,1},{0,0,1},{0,0,1},{0,0,1}
},
uvs:list[vec[f32,2]]{
{0,0},{1,0},{1,1},{0,1}
}
}
shader0 := frag.program(embed(func(
@varying uv : vec[f32,2],
@uniform font_atlas : frag.Texture,
):vec[f32,4]{
return font_atlas.sample(uv);
}) as "fragment")
func compute_tbn(
Ng : vec[f32,3],
dpdx:vec[f32,3],dpdy:vec[f32,3],
dtdx:vec[f32,2],dtdy:vec[f32,2],
):vec[f32,3,3]{
det := dtdx.x*dtdy.y - dtdy.x*dtdx.y;
T := dpdx * (dtdy.y/det) + dpdy * (-dtdx.y/det);
B := dpdx * (-dtdy.x/det) + dpdy * (dtdx.x/det);
T = (T - Ng * Ng.dot(T)).dir();
B = (B - Ng * Ng.dot(B)).dir();
N := T.cross(B).dir();
return {
T.x, T.y, T.z;
B.x, B.y, B.z;
N.x, N.y, N.z;
}
}
func gradient(
htex:frag.Texture, uv:vec[f32,2], texel:vec[f32,2]
):vec[f32,2]{
h := 0.5-0.5*htex.sample(uv).r;
hU := 0.5-0.5*htex.sample({(uv.x+texel.x)%1.0,uv.y}).r;
hV := 0.5-0.5*htex.sample({uv.x,(uv.y+texel.y)%1.0}).r;
dhu := (hU - h) / texel.x;
dhv := (hV - h) / texel.y;
return {dhu,dhv};
}
func perturb(tbn:vec[f32,3,3], dhuv:vec[f32,2], scale:f32):vec[f32,3]{
T := {tbn[0,0],tbn[0,1],tbn[0,2]};
B := {tbn[1,0],tbn[1,1],tbn[1,2]};
N := {tbn[2,0],tbn[2,1],tbn[2,2]};
return (N - scale * (dhuv.x * T + dhuv.y * B)).dir();
}
shader1 := frag.program(embed(func(
@varying uv : vec[f32,2],
@uniform image : frag.Texture,
@varying normal : vec[f32,3],
@varying position : vec[f32,3],
@uniform eye : vec[f32,3],
@uniform spec : vec[f32,3],
@uniform shininess:f32,
@uniform ld : vec[f32,3],
@builtin frag_coord : vec[f32,4],
@derived d_position_dx : vec[f32,3],
@derived d_position_dy : vec[f32,3],
@derived d_uv_dx : vec[f32,2],
@derived d_uv_dy : vec[f32,2],
):vec[f32,4]{
texel := uv/frag_coord.xy;
dhuv := gradient(image,uv,texel);
tbn := compute_tbn(
normal,d_position_dx,d_position_dy,
d_uv_dx,d_uv_dy
);
nml := perturb(tbn,dhuv,0.01);
g := image.sample(uv).x;
b := 1.0;
if (g < 0.5){
b = 0.0;
}
base := {1.0,b*0.9+0.1,b};
view_dir := eye - position;
half_vec := (ld + view_dir).dir();
ndl := math.max(nml.dot(ld),0.0);
ndh := math.max(nml.dot(half_vec),0.0);
df := base * (ndl*0.8+0.2);
hl := spec * (ndh ** shininess);
c := df + hl;
return {c.x,c.y,c.z,1.0};
}) as "fragment");
shader2 := frag.program(embed(func(
@varying uv : vec[f32,2],
):vec[f32,4]{
a := uv.x*math.PI*2.;
b := uv.y;
if (uv.y > 0.5){
b = 1.0 - uv.y;
}
c := rand.noise(b*10.0,math.cos(a)*2.0+17.0,math.sin(a)*2.0+13.0);
return {c,c,c,1.0};
}) as "fragment")
shader3 := frag.program(embed(func(
@varying uv : vec[f32,2],
@uniform image : frag.Texture,
@uniform shadow : frag.Texture,
@varying normal : vec[f32,3],
@varying position : vec[f32,3],
@uniform eye : vec[f32,3],
@uniform spec : vec[f32,3],
@uniform shininess:f32,
@uniform ld : vec[f32,3],
@uniform offset : vec[f32,2],
@builtin frag_coord : vec[f32,4],
@derived d_position_dx : vec[f32,3],
@derived d_position_dy : vec[f32,3],
@derived d_uv_dx : vec[f32,2],
@derived d_uv_dy : vec[f32,2],
):vec[f32,4]{
texel := uv/frag_coord.xy;
uvof := {(uv.x+offset.x)%1.0, (uv.y+offset.y)%1.0};
dhuv := gradient(image,uvof,texel);
tbn := compute_tbn(
normal,d_position_dx,d_position_dy,
d_uv_dx,d_uv_dy
);
nml := perturb(tbn,dhuv,0.01);
g := image.sample(uvof).x;
h := 0.;
ft := (uv-{0.5,0.5}).mag()*4.0;
n := 0;
for (i:=-2; i<=2; i++){
for (j:=-2; j<=2; j++){
h = h + shadow.sample(uv+{(i as f32),j}*texel*ft).x;
n++;
}
}
h = (h/(n as f32))*0.8+0.2;
base0 := {0.0,0.0,0.0};
base1 := {0.5,0.5,0.5};
view_dir := eye - position;
half_vec := (ld + view_dir).dir();
ndl := math.max(nml.dot(ld),0.0);
ndh := math.max(nml.dot(half_vec),0.0);
df := base0 * (1.-ndl) + base1 * ndl;
hl := spec * (ndh ** shininess);
c := df + hl;
c *= h;
return {c.x,c.y,c.z,1.0};
}) as "fragment");
shader4 := frag.program(embed(func(
):vec[f32,4]{
return {0.,0.,0.,1.};
}) as "fragment")
image0 := frag.texture(150,150);
image1 := frag.texture(W,H);
shadow := frag.texture(W,H);
cam := g3d.Camera{};
cam.perspective(30,W/(H as f32),0.1,100.0);
eye := {0.,0.,11.}
cam.look_at(eye,{0,0,0},g3d.AXIS_Y);
ld := ({-1.,1,2}).dir();
light := g3d.Camera{}
light.proj = g3d.mat.scale(0.25) @* {
1.0, 0.0, -ld.x/ld.z, 0.0;
0.0, 1.0, -ld.y/ld.z, 0.0;
0.0, 0.0, 0.0, 0.0;
0.0, 0.0, 0.0, 1.0
}
base_model := g3d.mat.id;
model := g3d.mat.id;
txtcam := g3d.Camera{};
txtcam.ortho(0,150,150,0,-1,1);
hud := g3d.Camera{};
hud.ortho(0,W,H,0,-1,1);
axis := g3d.AXIS_Z;
targ_ang := 0.;
curr_ang := 0.;
bg_offset := {0.,0.};
need_roll := 1;
func roll(){
base_model = model @* base_model;
targ_nml := mesh.normals[rand.random(20) as i32 * 3];
targ_nml = (base_model @* {targ_nml.x; targ_nml.y; targ_nml.z; 1.0}).xyz;
axis = targ_nml.cross(g3d.AXIS_Z);
ndz := targ_nml.dot(g3d.AXIS_Z);
targ_ang = math.acos(math.min(math.max(ndz,-0.999),0.999));
targ_ang += math.PI*4;
curr_ang = 0.;
model = g3d.mat.id;
}
while (1){
time.fps(120)
minz := 0.;
for (i := 0; i < vertices.length(); i++){
v := vertices[i];
z := (model @* base_model @* {v.x;v.y;v.z;1.0}).z;
minz <?= z;
}
model = g3d.mat.rotate_deg(axis,curr_ang*180/math.PI);
da := math.abs(curr_ang-targ_ang);
old_ang := curr_ang;
if (da<0.0001){
if (need_roll){
roll();
need_roll = 0;
}
}else if (da<1.0){
curr_ang = curr_ang * 0.9 + targ_ang * 0.1;
}else if (curr_ang > targ_ang){
curr_ang -= 0.1;
}else if (curr_ang < targ_ang){
curr_ang += 0.1;
}
dang := math.abs(curr_ang-old_ang)
bg_offset += {axis.y,-axis.x}.dir()*0.15*dang;
bg_offset = {(bg_offset.x+1.0)%1.0,(bg_offset.y+1.0)%1.0}
frag.begin(shader0,image0);
g3d.background(0);
txtcam.begin();
for (i:=0; i < 5; i++){
for (j := 0; j < 5; j++){
idx := i*5+j+1;
if (idx < 10){
g3d.text("%{idx}%{(idx==6||idx==9)?".":""}",g3d.mat.translate(j*30+11,i*30+13,0));
}else{
g3d.text("%{idx}",g3d.mat.translate(j*30+7,i*30+13,0));
}
}
}
txtcam.end();
frag.end();
mmodel := g3d.mat.translate(0,0,-minz) @* model @* base_model;
frag.begin(shader2,image1);
frag.render();
frag.end();
frag.begin(shader4,shadow);
g3d.background(1);
light.begin();
mesh.draw(mmodel);
light.end();
frag.end();
frag.begin(shader1);
g3d.background(0.5);
frag.uniform("ld",ld);
frag.uniform("eye",eye);
frag.uniform("spec",{1.0,0.9,0.8});
frag.uniform("shininess",64.0);
frag.uniform("image",image0);
cam.begin();
mesh.draw(mmodel);
cam.end();
frag.end();
frag.begin(shader3);
frag.uniform("ld",ld);
frag.uniform("eye",eye);
frag.uniform("spec",{1.0,0.9,0.8});
frag.uniform("shininess",64.0);
frag.uniform("image",image1);
frag.uniform("shadow",shadow);
frag.uniform("offset",bg_offset);
cam.begin();
plane.draw(g3d.mat.id);
cam.end();
frag.end();
hud.begin();
g3d.text(" Press SPACE to roll ", g3d.mat.translate(W-21*8,H-16,0));
hud.end();
e := win.poll();
if (e.type == win.KEY_PRESSED){
if (e.key == ' '){
need_roll = 1;
}
}
}