packages feed

raytrace-0.2.0.0: src/Graphics/Ray/Geometry.hs

{-# LANGUAGE InstanceSigs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ViewPatterns #-}
module Graphics.Ray.Geometry 
  ( -- * Geometry
    Geometry(Geometry), boundingBox, pureGeometry, transform, moving
    -- * Surfaces and Volumes
  , sphere, planeShape, parallelogram, cuboid, triangle, triangleMesh, constantMedium
    -- * Polygonal Meshes
  , Mesh(Mesh), transformVertices, parseObj, readObj
    -- * Groups
  , group, bvhNode, bvhTree
    -- * Transformations
  , translate, rotateX, rotateY, rotateZ, scale
  ) where

import Graphics.Ray.Core

import Linear (V2(V2), V3(V3), dot, quadrance, (*^), (^/), cross, norm, M44, inv44, (!*), V4(V4))
import qualified Linear.V4 as V4
import Data.Massiv.Array (U, (!))
import qualified Data.Massiv.Array as A
import System.Random (StdGen, random)
import Control.Monad.State (State, state)
import Control.Monad (guard, foldM)
import Control.Applicative ((<|>))
import Data.List (sortOn)
import Data.Bifunctor (first, second)
import Data.Functor.Identity (Identity(Identity), runIdentity)
import Data.Functor ((<&>))
import Text.Read (readMaybe)
import Data.Char (isDigit)
import GHC.Base (inline)

-- | A @'Geometry' m a@ has a bounding box (used in the implementation of bounding volume hierarchies),
-- as well as a function that takes a time, a ray, and an open interval, and in the @m@ monad, produces either @Nothing@
-- (if the ray does not intersect the shape within that interval) or a tuple consisting of a 'HitRecord' and a value of type @a@.
-- The time parameter is between 0 and 1 and allows for motion blur effects.
-- Typically, @m@ is either 'Identity' or @'State' 'StdGen'@, and @a@ is either @()@ or 'Geometry.Material.Material'. 
-- Use the '(<$)' operator to add a material to a geometry.
data Geometry m a = Geometry Box (Double -> Ray -> Interval -> m (Maybe (HitRecord, a)))

instance Functor m => Functor (Geometry m) where
  {-# SPECIALISE fmap :: (a -> b) -> Geometry Identity a -> Geometry Identity b #-}
  fmap :: (a -> b) -> Geometry m a -> Geometry m b
  fmap f (Geometry bbox hit) = Geometry bbox (\ray time ival -> fmap (fmap (second f)) (hit ray time ival))

-- | Promote a pure geometry to a monadic one.
pureGeometry :: Applicative m => Geometry Identity a -> Geometry m a
pureGeometry (Geometry bbox f) = Geometry bbox (\ray time ival -> pure (runIdentity (f ray time ival)))

-- | Get a geometry's bounding box.
boundingBox :: Geometry m a -> Box
boundingBox (Geometry b _) = b

-- | Construct a sphere with the given center and radius.
sphere :: Point3 -> Double -> Geometry Identity ()
sphere center radius = let
  diag = V3 radius radius radius
  bbox = fromCorners (center - diag) (center + diag)

  hitSphere _ (Ray orig dir) bounds = Identity $ do
    let oc = center - orig
    let h = dot dir oc 
    let c = quadrance oc - radius*radius

    let discriminant = h*h - c
    guard (discriminant >= 0)
    
    let sqrtd = sqrt discriminant
    let root1 = h - sqrtd
    let root2 = h + sqrtd

    t <- 
      if inInterval bounds root1 
        then Just root1 
      else if inInterval bounds root2
        then Just root2
      else Nothing
    
    let point = orig + t *^ dir
    let outwardNormal = (point - center) ^/ radius
    let frontSide = dot dir outwardNormal <= 0
    let hit = HitRecord
          { hr_t = t
          , hr_point = point
          , hr_normal = if frontSide then outwardNormal else -outwardNormal
          , hr_frontSide = frontSide
          , hr_uv = sphereUV outwardNormal -- only computed when necessary thanks to laziness
          }
    Just (hit, ())
  
  in Geometry bbox hitSphere

-- [private]
-- With default camera settings (-z direction is forward, +y direction is up),
-- texture images will be wrapped around the sphere starting and ending on the
-- far side of the sphere.
sphereUV :: Vec3 -> V2 Double
sphereUV (V3 x y z) = V2 u v
  where
    u = atan2 x z / (2 * pi) + 0.5
    v = acos (-y) / pi 

-- | Construct a subset of a plane. (See 'parallelogram' and 'triangle' for two instances of this.)
-- Which side is the \"front side\" is determined by the right hand rule.
{-# INLINABLE planeShape #-}
planeShape 
  :: Point3 -- ^ (0, 0) point on the plane
  -> Vec3 -- ^ First basis vector
  -> Vec3 -- ^ Second basis vector
  -> (Double -> Double -> Bool) -- ^ Whether a point on the plane is in the shape
  -> (Double -> Double -> V2 Double) -- ^ Texture coordinates
  -> Box -- ^ Bounding box (this is padded by a small amount to ensure all dimensions are positive)
  -> Geometry Identity ()
planeShape q u v test getUV bbox = let
  cp = cross u v
  norm_cp = norm cp
  normal = cp ^/ norm_cp
  normalS = normal ^/ norm_cp

  hitShape _ (Ray orig dir) bounds = Identity $ do
    let denom = dot normal dir
    guard (abs denom > 1e-8)
    let t = dot normal (q - orig) / denom
    guard (inInterval bounds t)
    let p = orig + t *^ dir
    let p_rel = p - q
    let a = normalS `dot` (p_rel `cross` v)
    let b = normalS `dot` (u `cross` p_rel)
    guard (test a b)
    let frontSide = denom < 0

    let hit = HitRecord
          { hr_t = t
          , hr_point = p
          , hr_normal = if frontSide then normal else -normal
          , hr_frontSide = frontSide
          , hr_uv = getUV a b
          }
    Just (hit, ())

  in Geometry (padBox 0.0001 bbox) hitShape

-- | Construct a parallelogram from a corner point and two edge vectors.
parallelogram :: Point3 -> Vec3 -> Vec3 -> Geometry Identity ()
parallelogram q u v = let
  bbox = boxHull [ q, q + u, q + v, q + u + v ] 
  test a b = 0 <= a && a <= 1 && 0 <= b && b <= 1
  in inline planeShape q u v test V2 bbox

-- | Construct an axis-aligned rectangular cuboid (implemented as a 'group' of parallelograms).
cuboid :: Box -> Geometry Identity ()
cuboid (V3 (xmin, xmax) (ymin, ymax) (zmin, zmax)) = let
  dx = V3 (xmax - xmin) 0 0
  dy = V3 0 (ymax - ymin) 0
  dz = V3 0 0 (zmax - zmin)
  in group 
    [ parallelogram (V3 xmin ymin zmax) dx dy -- front
    , parallelogram (V3 xmax ymin zmin) (-dx) dy -- back
    , parallelogram (V3 xmin ymin zmin) dz dy -- left
    , parallelogram (V3 xmax ymin zmax) (-dz) dy -- right
    , parallelogram (V3 xmin ymax zmax) dx (-dz) -- top
    , parallelogram (V3 xmin ymin zmin) dx dz -- bottom
    ]

-- | Construct a triangle from three corner points and their texture coordinates.
triangle :: (Point3, V2 Double) -> (Point3, V2 Double) -> (Point3, V2 Double) -> Geometry Identity ()
triangle (p0, uv0) (p1, uv1) (p2, uv2) = let
  s1 = p1 - p0
  s2 = p2 - p0
  bbox = boxHull [ p0, p1, p2 ]
  test a b = a >= 0 && b >= 0 && a + b <= 1
  getUV a b = (1 - a - b) *^ uv0 + a *^ uv1 + b *^ uv2
  in inline planeShape p0 s1 s2 test getUV bbox

-- | A collection of triangles.
data Mesh = Mesh 
  (A.Vector U Point3) -- ^ Array of vertex locations.
  (A.Vector U (V2 Double)) -- ^ Array of texture coordinates.
  [V3 (Int, Maybe Int)] -- ^ List of triangles. Each triangle has three vertices, whose locations and (optional) texture coordinates
                        -- are defined by indexing into the two arrays.
  deriving (Show)

-- | Apply an affine transformation (represented as a 4 by 4 matrix whose bottom row is 0 0 0 1) to the vertices of a mesh.
transformVertices :: M44 Double -> Mesh -> Mesh
transformVertices m (Mesh vs vts fs) = 
  let vs' = A.compute (A.map (dropLast . (m !*) . V4.point) vs) in
  Mesh vs' vts fs

-- TODO: use IO exceptions instead of Either?
-- | Parse the .obj file at the given location.
readObj :: FilePath -> IO (Either String Mesh) 
readObj path = first ((path ++ ", ") ++) . parseObj <$> readFile path

-- | Parse a Wavefront .obj file.
--
-- * Comments beginning with @#@ are ignored, as are all lines that do not
--   begin with @v @, @vt @, or @f @. 
-- * Faces with more than three vertices are allowed, but are triangulated before
--   adding them to the 'Mesh' object.
-- * Indices can be either positive or negative. For example, if there are 20
--   @v@ statements in the file, the vertex indices of a face must be in the
--   range [1, 20] or [-20, -1], with -1 meaning the last vertex. In either case,
--   they are converted into 0-based indices.
parseObj :: String -> Either String Mesh
parseObj file = do
  let (vLines, vtLines, fLines) = partitionLines (removeComments file)
  vs <- mapM parseV vLines
  vts <- mapM parseVT vtLines
  fs <- concat <$> mapM (parseF (length vs) (length vts)) fLines
  Right (Mesh (A.fromList A.Seq vs) (A.fromList A.Seq vts) fs)
  
  where
    removeComments :: String -> [String]
    removeComments = map (takeWhile (/= '#')) . lines

    partitionLines :: [String] -> ([(Int, String)], [(Int, String)], [(Int, String)])
    partitionLines = foldr addLine ([], [], []) . zip [1..]

    addLine (k, line) (vs, vts, fs) =
      case line of
        'v':' ':rest -> ((k, rest) : vs, vts, fs)
        'v':'t':' ':rest -> (vs, (k, rest) : vts, fs)
        'f':' ':rest -> (vs, vts, (k, rest) : fs)
        _ -> (vs, vts, fs)

    withLine :: Int -> String -> String
    withLine k err = "line " ++ show k ++ ": " ++ err
    
    -- a 'v' statement must begin with three decimal numbers
    parseV (k, line) = 
      case words line of
        (readMaybe -> Just x) : (readMaybe -> Just y) : (readMaybe -> Just z) : _ -> Right (V3 x y z)
        _ -> Left (withLine k "invalid 'v' statement")
    
    -- a 'vt' statement must begin with two decimal numbers (or consist of a single decimal number, in which case v defaults to 0)
    parseVT (k, line) =
      case words line of
        [readMaybe -> Just u] -> Right (V2 u 0)
        (readMaybe -> Just u) : (readMaybe -> Just v) : _ -> Right (V2 u v)
        _ -> Left (withLine k "invalid 'vt' statement")

    parseF numVs numVTs (k, line) = 
      case mapM (getIndices numVs numVTs) (words line) of
          Left err -> Left (withLine k err)
          Right (i:is) | length is >= 2 -> Right (map (uncurry (V3 i)) (pairs is))
          Right _ -> Left (withLine k "invalid 'f' statement (fewer than 3 vertices)")

    pairs :: [a] -> [(a, a)]
    pairs = \case
      [] -> []
      [_] -> []
      x:xs@(y:_) -> (x, y) : pairs xs

    processIx len i 
      | 1 <= i && i <= len = Right (i - 1)
      | -len <= i && i <= -1 = Right (i + len)
      | otherwise = Left ("index out of bounds: " ++ show i)

    getIndices :: Int -> Int -> String -> Either String (Int, Maybe Int)
    getIndices numVs numVTs str = do
      (i, rest) <- extractInt str
      i' <- processIx numVs i
      case rest of
        "" -> Right (i', Nothing)
        '/':'/':_ -> Right (i', Nothing)
        '/':str' -> do
          (j, _) <- extractInt str'
          j' <- processIx numVTs j
          Right (i', Just j')
        c:_ -> Left ("unexpected character '" ++ c : "'")

    extractInt :: String -> Either String (Int, String)
    extractInt = \case
      '-':str -> first negate <$> extractNat str
      str -> extractNat str
    
    extractNat :: String -> Either String (Int, String)
    extractNat str = 
      let (ds, rest) = span isDigit str in
      case readMaybe ds of
        Nothing -> Left "expected number"
        Just i -> Right (i, rest)

-- | Realize a 'Mesh' as a 'Geometry' (implemented as a 'bvhTree' of triangles).
triangleMesh :: Mesh -> Geometry Identity ()
triangleMesh (Mesh verts uvs tris) = 
  bvhTree $ flip map tris $ \(V3 (i0, j0) (i1, j1) (i2, j2)) -> let
    uv0 = maybe (V2 0 0) (uvs !) j0
    uv1 = maybe (V2 1 0) (uvs !) j1
    uv2 = maybe (V2 0 1) (uvs !) j2
    in triangle (verts ! i0, uv0) (verts ! i1, uv1) (verts ! i2, uv2)

-- | Construct a constant-density medium; useful for subsurface scattering and fog effects.
-- Typical materials are 'Graphics.Material.isotropic', 'Graphics.Material.anisotropic', and 'Graphics.Material.pitchBlack'.
constantMedium
  :: Double -- ^ Density 
  -> Geometry Identity () -- ^ Surface (assumed to be a closed surface with the \"front side\" facing outwards)
  -> Geometry (State StdGen) ()
constantMedium density (Geometry bbox hitObj) = let
  negInvDensity = -(1 / density)

  hitMedium :: Double -> Ray -> Interval -> State StdGen (Maybe (HitRecord, ()))
  hitMedium time ray@(Ray orig dir) (tmin, tmax) = 
    case do (hit1, ()) <- runIdentity (hitObj time ray (tmin, infinity)) 
            if hr_frontSide hit1 
              then do
                guard (hr_t hit1 < tmax)
                (hit2, ()) <- runIdentity (hitObj time ray (hr_t hit1, infinity))
                Just (hr_t hit1, min tmax (hr_t hit2))
              else Just (tmin, min tmax (hr_t hit1))
    of
      Nothing -> pure Nothing -- ray is never in fog within interval
      Just (t1, t2) -> state random <&> \rand ->
         do let inDist = t2 - t1
            let hitDist = negInvDensity * log rand
            guard (hitDist < inDist)
            let t = t1 + hitDist
            let hit = HitRecord
                  { hr_t = t
                  , hr_point = orig + t *^ dir
                  , hr_normal = -dir -- arbitrary
                  , hr_frontSide = True
                  , hr_uv = V2 0 0 -- arbitrary
                  }
            Just (hit, ())

  in Geometry bbox hitMedium

-- | Group multiple geometric objects into a single object. When testing if a ray hits a group, 
-- every constituent of the group is tested without regard to its position. With a large number of objects,
-- use 'bvhTree' for greater efficiency.
{-# SPECIALISE group :: [Geometry Identity a] -> Geometry Identity a #-}
group :: Monad m => [Geometry m a] -> Geometry m a
group obs = let
  bbox = boxJoin (map boundingBox obs)

  hitGroup ray time (tmin, tmax) =
    let try (tmax', knownHit) (Geometry _ hitObj) =
          hitObj ray time (tmin, tmax') <&> \case
            Nothing -> (tmax', knownHit)
            Just (hit, mat) -> (hr_t hit, Just (hit, mat))
    in snd <$> foldM try (tmax, Nothing) obs
  
  in Geometry bbox hitGroup

-- | A single node in a bounding volume hierarchy. Before testing whether a ray hits each child,
-- it tests whether the ray hits a bounding box containing the two children.
{-# SPECIALISE bvhNode :: Geometry Identity a -> Geometry Identity a -> Geometry Identity a #-}
bvhNode :: Monad m => Geometry m a -> Geometry m a -> Geometry m a
bvhNode (Geometry bboxLeft hitLeft) (Geometry bboxRight hitRight) = let
  bbox = boxJoin [bboxLeft, bboxRight]

  hitBvhNode time ray (tmin, tmax)
    | overlapsBox bbox ray (tmin, tmax) = 
      hitLeft time ray (tmin, tmax) >>= \case
        Nothing -> hitRight time ray (tmin, tmax)
        res@(Just (hit, _)) -> fmap (<|> res) (hitRight time ray (tmin, hr_t hit))
    | otherwise = pure Nothing
  
  in Geometry bbox hitBvhNode

-- | Group multiple geometric objects into a single object. 
-- The objects are organized into a tree based on their positions, and then a 'bvhNode'
-- is created for each node in the tree.
{-# SPECIALISE bvhTree :: [Geometry Identity a] -> Geometry Identity a #-}
bvhTree :: Monad m => [Geometry m a] -> Geometry m a
bvhTree = \case
  [] -> error "bvhTree: empty list"
  [obj] -> obj
  obs -> let
    d = longestDim (boxJoin (map boundingBox obs))
    obs' = sortOn (midpoint . component d . boundingBox) obs
    (left, right) = splitAt (length obs `div` 2) obs'
    in bvhNode (bvhTree left) (bvhTree right)

-- | Apply an affine transformation (represented as a 4 by 4 matrix whose bottom row is 0 0 0 1) to a geometric object.
-- The transformation should be Euclidean (translation, rotation, reflection, or a composition thereof); otherwise, the
-- normal vectors of the resulting geometry will be incorrect.
transform :: Functor m => M44 Double -> Geometry m a -> Geometry m a
transform m (Geometry bbox hitObj) = let
  m34 = dropLast m
  inv_m = dropLast (inv44 m)
  corners = map ((m34 !*) . V4.point) (allCorners bbox)
  bbox' = boxHull corners
  in Geometry bbox' $ \time (Ray orig dir) ival ->
    let ray' = Ray (inv_m !* V4.point orig) (inv_m !* V4.vector dir) in
    flip (fmap . fmap . first) (hitObj time ray' ival) $ \hit@(HitRecord {..}) ->
      hit { hr_point = m34 !* V4.point hr_point, hr_normal = m34 !* V4.vector hr_normal }

-- | Translation.
translate :: Vec3 -> M44 Double
translate (V3 x y z) = V4
  (V4 1 0 0 x)
  (V4 0 1 0 y)
  (V4 0 0 1 z)
  (V4 0 0 0 1)

-- | Rotation about the X axis.
rotateX :: Double -> M44 Double
rotateX angle = V4
  (V4 1 0 0 0)
  (V4 0 c (-s) 0)
  (V4 0 s c 0)
  (V4 0 0 0 1)
  where
    c = cos angle
    s = sin angle

-- | Rotation about the Y axis.
rotateY :: Double -> M44 Double
rotateY angle = V4
  (V4 c 0 s 0)
  (V4 0 1 0 0)
  (V4 (-s) 0 c 0)
  (V4 0 0 0 1)
  where 
    c = cos angle
    s = sin angle

-- | Rotation about the Z axis.
rotateZ :: Double -> M44 Double
rotateZ angle = V4
  (V4 c (-s) 0 0)
  (V4 s c 0 0)
  (V4 0 0 1 0)
  (V4 0 0 0 1)
  where
    c = cos angle
    s = sin angle

-- | Scaling centered at the origin. This should not be used with 'transform'.
scale :: Double -> M44 Double
scale a = V4
  (V4 a 0 0 0)
  (V4 0 a 0 0)
  (V4 0 0 a 0)
  (V4 0 0 0 1)

-- [private]
dropLast :: V4 a -> V3 a
dropLast (V4 x y z _) = V3 x y z

-- TODO: this produces incorrect results for objects with solid textures
-- | Create a motion-blurred object that is translated by the first vector at time 0
-- and by the second vector at time 1.
moving :: Functor m => Vec3 -> Vec3 -> Geometry m a -> Geometry m a
moving v0 v1 (Geometry bbox hitObj) =
  let bbox' = boxJoin [shiftBox v0 bbox, shiftBox v1 bbox] in
  Geometry bbox' $ \time (Ray orig dir) ival -> let
    shift = (1 - time) *^ v0 + time *^ v1
    ray' = Ray (orig - shift) dir
    in flip (fmap . fmap . first) (hitObj time ray' ival) $ \hit -> 
      hit { hr_point = hr_point hit + shift }