Generating videos with literate Futhark
A video is just a collection of images called frames. However,
for memory reasons it is usually best not to generate all the
frames simultaneously. Therefore the :video
command is a bit
more intricate than :img
. To generate a video, we must define a
frame function that takes as input a state and returns a frame
and a new state value.
First we define a function for converting a light intensity into an
u32
encoding an ARGB colour.
def grey (light: f32) : u32 =
let x = u32.f32(255 * f32.min 1 (f32.max 0 light))
in (x << 16) | (x << 8) | x
Then we define a frame function where some of the parameters will
be partially applied. The state parameter t
represents the
current time, and is increased by the time delta td
for every
frame.
entry frame (width: i64) (height: i64) (td: f32) (t: f32): ([height][width]u32, f32) =
0.5+f32.cos(t*10)/2))),
(replicate height (replicate width (grey( t + td)
> :video (frame 100i64 100i64 0.02f32, 0f32, 314i64);
fps: 24
format: gif
That is not terribly exciting. Let’s try something more interesting - specifically, a simple ray marcher that renders a blobby sphere. First, we’ll need to do some vector calculations, so we’ll import code from another example.
module vectors = import "3d-vectors"
module vec3 = vectors.mk_vspace_3d f32
type vec3 = vec3.vector
Next we’ll define a function for converting a point on a sphere into latitude and longtitude (UV mapping):
def uv (p: vec3) : (f32,f32) =
let d = vec3.normalise p
in (0.5 + f32.atan2 d.x d.z / (2*f32.pi),
0.5 + f32.asin d.y / f32.pi)
Now we can define a function for determining the radius of a blobby sphere at a given point.
def radius_at (t: f32) (p: vec3) : f32 =
let (u,v) = uv p
in (1+f32.sin(u*20*f32.pi+t)*f32.sin(t))/2 +
1+f32.cos(v*20*f32.pi+t)*f32.sin(t))/2 (
The signed distance function is now trivial.
def sdf (t: f32) (p: vec3) : f32 =
vec3.length p - radius_at t p
To trace the sphere, we perform ray marching into the scene, with up to 128 steps. If we make it to 128, we assume a miss. The logic is a bit convoluted due to lack of recursive functions.
type hit = #hit vec3 | #miss
def trace t (orig: vec3) (dir: vec3) : hit =
let not_done (i, _) = i < 128
let march (i, pos) =
let d = sdf t pos
in if d < 0
then (1337, pos)
else (i + 1, pos vec3.+ ((f32.max (d*0.1) 0.01) `vec3.scale` dir))
in iterate_while not_done march (0,orig)
if i == 1337 then #hit hit else #miss |> \(i, hit) ->
Finally, we’ll need a way to compute a surface normal for lighting. This can be done with a single invocation of reverse-mode automatic differentiation.
def grad f x = vjp f x 1f32
def distance_field_normal t pos =
vec3.normalise (grad (sdf t) pos)
This concludes the actual geometry code. Now we just have to construct camera rays.
def camera_ray width height i j =
let fov = f32.pi/3
let x = (f32.i64 i + 0.5) - f32.i64 width/2
let y = -(f32.i64 j + 0.5) + f32.i64 height/2
let z = -(f32.i64 height)/(2*f32.tan(fov/2))
in vec3.normalise {x,y,z}
The actual frame function is quite straightforward - for each pixel generate a ray and see if it collides with the blobby sphere. We use the surface normal to reflect lighting from a light sourse at (10,10,10).
entry blob (width: i64) (height: i64) (td: f32) (t: f32): ([height][width]u32, f32) =
let f j i =
let dir = camera_ray width height i j
in match trace t {x=0, y=0, z=3} dir
case #miss ->
0xFFFFFF
case #hit hit ->
let light_dir = vec3.normalise ({x=10, y=10, z=10} vec3.- hit)
let light_intensity = light_dir `vec3.dot` distance_field_normal t hit
in grey light_intensity
in (tabulate_2d height width f, t + td)
Finally we can view our sphere it in all its moderately pixelated glory.
> :video (blob 640i64 480i64 0.0314f32, 0f32, 100i64);
fps: 24
format: gif