Search code examples
haskelliomutableraytracing

How to read settings and geometric shapes from a file in Haskell for later use, with operations in between?


I can write simple algorithms in Haskell, and even successfully coded a very simple graphics raytracer (spheres, planes, rectangles) as a learning tool (I wrote a more complex one in C++, for an online course). All settings for this raytracer were hardcoded as constants, be it the desired image width/height, camera position, ambient light intensity, etc. Example:

imageWidth = 1600
imageHeight = 900
bgColor = Color 0 0 0
maxDepth = 5
ambientIntensity = Color 0 0 0

However, as soon as I tried to extend the raytracer to read these settings and the scene itself (positions of objects, lights, etc) from a file, I hit a brick wall. Example scene file:

size 1600 900
output generated_image.png
ambient 0.1 0.1 0.1
triangle 0.5 1.4 2.8
triangle 0.5 2.4 3.8
sphere -5.5 -5.5 0 4
sphere +5.5 +5.5 0 4

Important: The scene file additionally includes matrix operations (translate, rotate, etc), which I should store in a stack as I read the file, as well as material definitions for the objects. If I read a sphere in a certain line, that sphere should use the material and matrix transformation that are set as of that line. Then, some more matrix transformations and material settings may or may not follow before reading another object (sphere, triangle, etc), and so on.

It seems to me that this task involves some severe data mutation. Due to Haskell's pure nature and my limited knowledge, I'm having problems with IO types and how to proceed in general, and my Internet research honestly didn't help a lot.

I know how to read the scene file using readFile, get each line using lines, separate parameters using words and even convert those to Ints/Floats as needed using read. I believe I should apply a function to each line, probably using mapM or mapM_, which should detect the used command (size, ambient, sphere, etc), and act as needed. But actions are very different for each command. While "size" only requires that I save the image width and height in values, "sphere" would require that I read values, use the currently active matrix transformation, currently active material and then store it in a list somewhere. I can't read everything and then act, or I would have to also store the order of operations and the problem would be the same... But, even in the simpler "size" case, I'm clueless about how to do this, as these are all operations that involve mutation.

Concretely:

  1. How should I go about binding a value read from a file to a name, in order to use it later? Ex.: imageWidth and imageHeight. With only one setting in the file, I could do this by simply returning the read value from the reader function. This is not the case...

  2. Should I create a new data type named "Scene" with named parameters (they are many), which contains all the settings to later use in the raytracer? This is how I would do it in C++, but here it pollutes the function namespace (if that is how I should call it) with all the arguments.

  3. How could I achieve this mutation of values? I'm assuming I need pointers or some impure Haskell functionality, and I believe only this initial setup would require such things. Later on, when the image is generated, I should be able to access the stored values as usual, using pure functions. Is this possible? None of the settings read from the file are supposed to change in runtime, but they involve "mutation" of data while reading, especially in the case of materials, the stack of matrix transformations and even adding to the list of objects.

I apologize for the long question. I realize it is also rather abstract and hasn't got a "code solution". If my questions are too broad, could you recommend a resource where such a problem is tackled in a clear way? I'm feeling that I also need to learn quite a lot about Haskell before achieving this.

Many thanks.


Solution

  • It seems now this question is simply about parsing your particular file format. So I will show you how to use a commonly used parsing library, Parsec, to do it. If you are not familiar with parsec and applicative style parsing, please read the section in RWH. This will essentially be a fully featured parser, so it is quite long.

    I will repeat it once more: using mutation to do this in Haskell is simply wrong. Only a masochist would even attempt it. Please push all ideas of mutation out of your mind.

    First, write datatypes to represent everything:

    type Point3D = (Float, Float, Float)
    
    data SceneObject 
      = Sphere Point3D Float            
      | Triangle Point3D Point3D Point3D
        deriving Show
    
    data SceneTransform 
      = Translate Float Float Float        
      | Rotate Float Float Float           
        deriving Show
    

    Notice we seperate things into transformations and objects. The distinction, in general, is that transformations are things which can be applied to objects. Then, the entire scene:

    data SceneConfig x = SceneConfig 
      { sc_height :: Int
      , sc_width :: Int
      , sc_out :: FilePath 
      , sc_objs :: x
      } deriving Show 
    

    Notice the objects are a parameter. This is because we will first parse the data exactly as it is found in the file, then write a function which will transform the data to a more convenient format. We will not do something absurd like trying to parse files and transform the parsed data simultaneously.

    {-# LANGUAGE RecordWildCards, NamedFieldPuns #-}
    
    import Text.Parsec hiding ((<|>))
    import Text.ParserCombinators.Parsec.Number
    import Control.Applicative hiding (many)
    
    type Parser = Parsec String ()
    
    parseFloat :: Parser Float
    parseFloat = spaces >> (sign <*> (either fromInteger id <$> decimalFloat))
    
    parsePoint3D :: Parser Point3D
    parsePoint3D = spaces >> ((,,) <$> parseFloat <*> parseFloat <*> parseFloat)
    

    These are helper functions for parsing basic things. We parse points as floats separated by whitespace.

    parseTranslate = 
      string "translate" >> Translate <$> parseFloat <*> parseFloat <*> parseFloat
    

    The above is quite simple: a translate object is the string "Translate" followed by three floats. The other possible objects look pretty much exactly the same:

    parseRotate = 
      string "rotate"    >> Rotate <$> parseFloat <*> parseFloat <*> parseFloat
    
    parseSphere = 
      string "sphere"    >> Sphere <$> parsePoint3D <*> parseFloat
    
    parseTriangle = 
      string "triangle"  >> Triangle <$> parsePoint3D <*> parsePoint3D <*> parsePoint3D
    

    We need a parser which parses any of these. choice takes a list of parsers and succeeds on the first one of them which succeeds:

    parseObjOrTransform :: Parser (Either SceneObject SceneTransform)
    parseObjOrTransform = choice $ map try $
      [ Left <$> parseSphere
      , Left <$> parseTriangle
      , Right <$> parseRotate
      , Right <$> parseTranslate
      ]
    

    Now we are ready to parse the entire config:

    parseSceneConfigWith :: Parser x -> Parser (SceneConfig x)
    parseSceneConfigWith p = do 
      string "size" 
      sc_height <- spaces >> int
      sc_width  <- spaces >> int 
      char '\n'
    
      string "output"
      sc_out <- spaces >> many1 (noneOf "\n\t\"<>|/\\?*: ")
      char '\n'
    
      sc_objs <- p
      return $ SceneConfig { .. }
    

    This requires that "size" and "output" are placed in the correct order. You can, of course, change this; but this way is the simplest.

    Now we parse the data including objects and transformations - but again, we do not do computation on them while parsing:

    parseSceneRaw :: Parser (SceneConfig [Either SceneObject SceneTransform])
    parseSceneRaw = parseSceneConfigWith (sepEndBy parseObjOrTransform (char '\n'))
    

    Now we are ready to apply transforms to objects:

    appTr :: SceneTransform -> SceneObject -> SceneObject
    appTr (Translate dx dy dz) obj = 
      case obj of
        (Sphere p0 r) -> Sphere (dp ~+~ p0) r
        (Triangle p0 p1 p2) -> Triangle (dp ~+~ p0) (dp ~+~ p1) (dp ~+~ p2) 
       where dp = (dx, dy, dz)
    appTr _ _ = error "TODO"
    
    applyTransforms :: [Either SceneObject SceneTransform] -> [SceneObject]
    applyTransforms [] = []
    applyTransforms (Left obj : xs) = obj : applyTransforms xs
    applyTransforms (Right tf : xs) = applyTransforms (map f xs) where 
      f (Left obj) = Left $ appTr tf obj 
      f x = x
    

    The logic of this function is fairly simple. It applies each transform it encounters to every subsequent object. You could do this with a matrix stack, but it is overkill, at least for the subset of your datatype I have implemented.

    Then, for convenience, we can write a parser which performs parseSceneRaw, then applies the transforms:

    parseScene :: Parser (SceneConfig [SceneObject])
    parseScene = do 
      SceneConfig { sc_objs, .. } <- parseSceneRaw 
      return $ SceneConfig { sc_objs = applyTransforms sc_objs, .. }
    

    Then a simple test case:

    testFile :: String
    testFile = unlines 
      ["size 1600 900"
      ,"output generated_image.png"
      ,"translate 0 1 0"
      ,"triangle 0.5 1.4 2.8 4.5 2.3 3.1 9.6 1.4 0.0"
      ,"translate 10 10 10"
      ,"sphere -5.5 -5.5 0 4"
      ,"translate -100 -100 -100"
      ,"sphere 5.5 5.5 0 4"
      ]
    
    testMain = print (parse parseSceneRaw "" testFile) >> 
               print (parse parseScene    "" testFile)