Challenge: Convert this small 3d-graphics imperative algorithm to Haskell

Today I came across Inigo Quilez’s algorithm for computing a mesh surface’s normals at every vertex: Inigo Quilez :: computer graphics, mathematics, shaders, fractals, demoscene and more

It explains some of the unnecessary computation that the naive algorithms do and presents a short and sweet imperative algorithm which is faster, simpler, and equally correct.

Now, I need to write this algorithm in Haskell. And this seemed like an interesting and short enough problem to pose to more people. A little challenge if you will :slight_smile:. Here is the algorithm:

void Mesh_normalize( Mesh *myself )
{
    Vert     *vert = myself->vert;
    Triangle *face = myself->face;

    for( int i=0; i<myself->mNumVerts; i++ ) vert[i].normal = vec3(0.0f);

    for( int i=0; i<myself->mNumFaces; i++ )
    {
        const int ia = face[i].v[0];
        const int ib = face[i].v[1];
        const int ic = face[i].v[2];

        const vec3 e1 = vert[ia].pos - vert[ib].pos;
        const vec3 e2 = vert[ic].pos - vert[ib].pos;
        const vec3 no = cross( e1, e2 );

        vert[ia].normal += no;
        vert[ib].normal += no;
        vert[ic].normal += no;
    }

    for( i=0; i<myself->mNumVerts; i++ ) verts[i].normal = normalize( verts[i].normal );
}

How would you implement this? I’d be happy to see both the most elegant functional versions of this and direct ST implementations alike.

If you are looking for a starting point (but feel free to do this however you’d like!), here’s roughly how I understand the type signature should be:

mesh_normalize :: [(Int, Int, Int)] -- ^ A list of faces, each composed of three indices into the vertices array
               -> [Vec3] -- ^ A list of vertices, with x,y,z being the vertex position in 3D space
               -> [Vec3] -- ^ The normal vector for each input vertex.

Or, say, using `[Int]` for the list of faces, where every 3 indices in a row form the face

Further thoughts: it could be cool to setup a little benchmark game so people can try them out

6 Likes

So essentially the normal vector for a vertex is an average of normal vectors for faces the vertex is a part of, normalized to unit length?

1 Like

That’s exactly right. I believe they’re typically called Vertex normals

I’d like to share my solution to this problem, which I’ve used to render this noise-enhanced sphere with the Blinn Phong lighting model (which takes the vertex normal as an input):

And on a sphere constructed by inflating a cube (which is why the seams are visible for now)

My solution is a pretty direct translation of the imperative algorithm, but it uses linearly-typed mutable arrays, which means there are no monads in sight! I think it is pretty neat. Here follows:

My (linearly typed) solution
computeNormals :: Vector Int  -- ^ Every three indices into the vertices array forms a face
               -> Vector Vec3 -- ^ The position of every vertex
               -> Vector Vec3 -- ^ The normal vector for each vertex
computeNormals ixs vs =
  unur $ Array.alloc (V.length vs) (vec3 0 0 0) $ \arr0 ->
    Array.freeze $
    Array.map Vec3.normalize $
      foldl' (\arr (Ur i) -> let
           ia = ixs ! (i)
           ib = ixs ! (i+1)
           ic = ixs ! (i+2)
           e1 = vs ! ia ^-^ vs ! ib
           e2 = vs ! ic ^-^ vs ! ib
           no = Vec3.cross e1 e2 
        in arr & ia += no
               & ib += no
               & ic += no
        ) arr0 [Ur i | i <- [0,3..V.length ixs - 1]]
  where
    (!) = (V.!)

    (+=) :: Int -> Vec3 -> Array.Array Vec3 %1 -> Array.Array Vec3
    (+=) i new arr0 = case Array.get i arr0 of
      (Ur exists, arr1) -> Array.set i (new ^+^ exists) arr1
5 Likes

A maximally Haskellised version, using monoidal-containers and linear.

Spoiler
meshNormalise :: Floating a => [V3 a] -> [V3 Int] -> [V3 a]
meshNormalise vertices
  = fmap (normalize . getSum) . elems
  . (foldMap . uncurry . foldMap) singleton
  . fmap (id &&& Sum . normal . fmap (vs !))
 where
  !vs = fromAscList (zip [0..] vertices)

normal :: Floating a => V3 (V3 a) -> V3 a
normal (V3 v1 v2 v3) = normalize $ (v3 - v2) `cross` (v2 - v1)

I’m too lazy to actually test it, so there are probably some minor errors.

3 Likes

Okay, I put together a sanity test:

_test :: Bool
_test = meshNormalise _octaVerts _octaFaces == _octaVerts

_octaVerts :: [V3 Double]
_octaVerts =
  [ V3  0  0  1 -- 0
  , V3  1  0  0 -- 1
  , V3  0  1  0 -- 2
  , V3 -1  0  0 -- 3
  , V3  0 -1  0 -- 4
  , V3  0  0 -1 -- 5
  ]

_octaFaces :: [V3 Int]
_octaFaces =
  [ V3 0 4 3
  , V3 0 3 2
  , V3 0 2 1
  , V3 0 1 4
  , V3 5 4 1
  , V3 5 1 2
  , V3 5 2 3
  , V3 5 3 4
  ]

My code passes this at least, so that’s nice.

Edit: It’s an octahedron, not a tetrahedron … derp.

3 Likes

Niiiice. I suppose the seams would be visible on a subdivided icosphere too?

Can you compute the “bent normals” in the same go?
It’s the thing that detects a crevice between the faces and produces a half-vector between them. Then if you have both the face normal and the crevice normal, then a dot product can be used for some ambient occlusion for a very nice looking (and cheap!) effect.

1 Like

You can implement it like this with accelerate. This passes the type checker, but I haven’t tested it yet:

import Data.Array.Accelerate
import Data.Array.Accelerate.Linear

type Vec3 = V3 Double

meshNormalise :: Acc (Array DIM2 Int) -> Acc (Vector Vec3) -> Acc (Vector Vec3)
meshNormalise faces vertices =
  let
    nFaces, nVertices :: Exp Int
    (I2 nFaces _) = shape faces
    (I1 nVertices) = shape vertices
    i0, i1, i2 :: Acc (Vector Int)
    i0 = backpermute (I1 nFaces) (\(I1 i) -> I2 i 0) faces
    i1 = backpermute (I1 nFaces) (\(I1 i) -> I2 i 1) faces
    i2 = backpermute (I1 nFaces) (\(I1 i) -> I2 i 2) faces
    v0, v1, v2 :: Acc (Vector Vec3)
    v0 = gather i0 vertices
    v1 = gather i1 vertices
    v2 = gather i2 vertices
    faceNormals :: Acc (Vector Vec3)
    faceNormals = zipWith3 (\x y z -> cross (x - y) (z - y)) v0 v1 v2
    zeros :: Acc (Vector Vec3)
    zeros = fill (I1 nVertices) 0
    n0, n1, n2 :: Acc (Vector Vec3)
    n0 = permute (+) zeros (\i -> Just_ (I1 (i0 ! i))) faceNormals
    n1 = permute (+) zeros (\i -> Just_ (I1 (i1 ! i))) faceNormals
    n2 = permute (+) zeros (\i -> Just_ (I1 (i2 ! i))) faceNormals
  in map normalize $ zipWith3 (\x y z -> x + y + z) n0 n1 n2

The nice thing is that this will probably all fuse away to become equivalent to the reference C solution and you get automatic parallelism for free (and it can run on NVidia GPUs)!

Let me see if I can test it.

7 Likes

I made it faster:

meshNormaliseOpt :: Acc (Array DIM2 Int) -> Acc (Vector Vec3) -> Acc (Vector Vec3)
meshNormaliseOpt faces vertices =
  let
    nFaces, nVertices :: Exp Int
    I2 nFaces _ = shape faces
    I1 nVertices = shape vertices
    i0, i1, i2 :: Acc (Vector Int)
    i0 = backpermute (I1 nFaces) (\(I1 i) -> I2 i 0) faces
    i1 = backpermute (I1 nFaces) (\(I1 i) -> I2 i 1) faces
    i2 = backpermute (I1 nFaces) (\(I1 i) -> I2 i 2) faces
    v0, v1, v2 :: Acc (Vector Vec3)
    v0 = gather i0 vertices
    v1 = gather i1 vertices
    v2 = gather i2 vertices
    faceNormals :: Acc (Vector Vec3)
    faceNormals = zipWith3 (\x y z -> cross (x - y) (z - y)) v0 v1 v2
    zeros :: Acc (Vector Vec3)
    zeros = fill (I1 nVertices) 0
    unnorm :: Acc (Vector Vec3)
    unnorm = permute (+) zeros (\(I2 i j) -> Just_ (I1 (faces ! I2 i j))) $
               backpermute (I2 nFaces 3) (\(I2 i _) -> I1 i) faceNormals
  in map normalize unnorm

In particular merging the permutes has a big impact, about 2x on my CPU. Didn’t try GPU yet.

3 Likes

My grug brained direct translation :grin:

mkVerticesSmooth points faces = Vector.create do
  output <- Mutable.new uniquePoints
  -- write baseline data
  Vector.iforM_ points \ix ->
    Mutable.write output ix . mkVertex

  accs <- Mutable.replicate uniquePoints 0
  for_ faces \(Face ia ib ic) -> do
    (Vec3.Packed pa, _) <- Mutable.unsafeRead output ia
    (Vec3.Packed pb, _) <- Mutable.unsafeRead output ib
    (Vec3.Packed pc, _) <- Mutable.unsafeRead output ic
    -- local flat normal
    let faceNormal = Vec3.cross (pa - pb) (pc - pb)
    -- distribute, proportional to face area
    Mutable.modify accs (+ faceNormal) ia
    Mutable.modify accs (+ faceNormal) ib
    Mutable.modify accs (+ faceNormal) ic
  -- collect and apply
  Mutable.iforM_ accs \ix nAcc -> do
    let n = Vec3.Packed $ Vec3.normalize nAcc
    Mutable.modify output (fmap \va -> va{LitColored.vaNormal = n}) ix
  pure output
  where
    uniquePoints = Vector.length points

Now that surface lighting makes more sense I can make more exaggerated ridges:

Previously I had to tone everything down because the normals were a lie:

Thanks for the tip!

4 Likes