Convergence
As we all know the Internet is in desperate need for another sketchy ray tracer. So I kept on digging with mine and added some exciting new features, namely
- specular reflections,
- that “graph paper”-esque texture you see in so many test renders,
- gamma-corrected image output and
- something I call “incremental rendering”
The images below show the incremental rendering in action. From left to right there have been more samples taken; move the mouse over the images to see the numbers.
![]() |
![]() |
![]() |
The idea to enable incremetal rendering is dead simple. You simple keep the image in memory and throw samples at it, from top-left to bottom-right (or in any order you desire). When you reach the bottom-left corner you write out what you’ve got so far and start over from the top-left again. Here is the main rendering “loop” which does the trick:
render :: Int -> Image -> Scene -> Camera -> Integrator -> IO ()
render pass img scene cam int = do
prng <- newStdGen
img' <- return $! fromRand $ runRand prng (onePass img scene cam int)
writeFile ("pass-" ++ (printf "%05d" pass) ++ ".ppm") (imageToPpm img')
render (pass + 1) img' scene cam int
I think this is pretty concise, but when I first tried to run it I was faced with excessive memory consumption, swapping and evil. With some profiling I could spot the reason for that: Haskell is a lazy language. This means that my Image (using a not-unboxed array to hold the pixel values at that time) did not really hold the colour values I wanted to store there -- it contained only thunks for them. You can think of a thunk as an "recipe" describing how to compute that value in case it is needed later on. While this laziness allows to do some cool tricks with Haskel, in this particular case it was shooting my foot. I'm pretty sure I need all that pixel values when writing the image to a file.
Moving to "unboxed" arrays (the getPixel and putPixel functions below were needed for that because only some simple data types can be stored in such an array) did not really resolve the problem: While an Image is just two Ints (width and heigth) and a bunch of Floats (the colour values) now, Haskell came up with another neat idea to get me: You never know if the Image as a whole is needed later on, so why bother with rendering it? I resolved that by throwing a good amount of "seq" statements at random places, until it was finally running in constant space. I think when Haskell's language designers wanted to create a lazy language they really meant it. After all that fuzz, below is the Image module in it's current state. It's missing the accompanying "Color" module which I'll post once I got that RGB, XYZ, Spectrum color space conversion stuff straight.
module Image(
Image, ImageSample(..),
imageWidth, imageHeight,
imageToPpm, makeImage, addSample) where
import Data.Array.Diff
import Color
-- | places a @WeightedSpectrum@ in an @Image@
data ImageSample = ImageSample {
samplePosX :: ! Float,
samplePosY :: ! Float,
sampleSpectrum :: ! WeightedSpectrum
} deriving Show
-- | an image has a width, a height and some pixels
data Image = Image {
imageWidth :: Int,
imageHeight :: Int,
_imagePixels :: (DiffUArray Int Float)
}
-- | extracts the pixel at the specified offset from an Image
getPixel :: Image -> Int -> WeightedSpectrum
getPixel (Image _ _ p) o = (p ! o', s) where
s = fromXyz (p ! (o' + 1), p ! (o' + 2), p ! (o' + 3))
o' = o * 4
-- | puts an pixel to the specified offset in an Image
putPixel :: Image -> Int -> WeightedSpectrum -> Image
putPixel (Image w h p) o (sw, s) = seq p' Image w h p' where
(sx, sy, sz) = toXyz s
p' = p // [ (o', sw), (o' + 1, sx), (o' + 2, sy), (o' + 3, sz) ]
o' = o * 4
-- | converts an image to ppm format
imageToPpm :: Image -> String
imageToPpm i@(Image w h _) = header ++ spixels 0
where
header = "P3\n" ++ show w ++ " " ++ show h ++ "\n255\n"
spixels pos
| pos == (w*h) = []
| otherwise = (ppmPixel $ getPixel i pos) ++ spixels (pos + 1)
-- | applies gamma correction to an RGB triple
gamma :: Float -> (Float, Float, Float) -> (Float, Float, Float)
gamma x (r, g, b) = (r ** x', g ** x', b ** x') where
x' = 1 / x
-- | converts a Float in [0..1] to an Int in [0..255], clamping values outside [0..1]
clamp :: Float -> Int
clamp v = round ( (min 1 (max 0 v)) * 255 )
-- | converts a @WeightedSpectrum@ into what's expected to be found in a ppm file
ppmPixel :: WeightedSpectrum -> String
ppmPixel ws = (toString . (gamma 2.2) .toRgb . mulWeight) ws
where
toString (r, g, b) = show (clamp r) ++ " " ++
show (clamp g) ++ " " ++ show (clamp b) ++ " "
-- | converts a weighted spectrum to a plain spectrum by dividing out the weight
mulWeight :: WeightedSpectrum -> Spectrum
mulWeight (0, _) = black
mulWeight (w, s) = sScale s (1.0 / w)
-- | adds an sample to the specified image and returns the updated image
addSample :: Image -> ImageSample -> Image
addSample img@(Image w h _) (ImageSample sx sy (sw, ss))
| isx > maxX || isy > maxY = img
| otherwise = seq img' img'
where
img' = seq img seq newPixel putPixel img offset newPixel
isx = floor sx
isy = floor sy
maxX = w - 1
maxY = h - 1
offset = isy * w + isx
(oldW, oldS) = getPixel img offset
newPixel = (oldW + sw, oldS + ss)
-- | creates a new all-black image of the specified width and height
makeImage :: Int -> Int -> Image
makeImage w h = Image w h pixels where
pixels = listArray (0, pxCount - 1) (repeat 0.0)
pxCount = w * h * 4 :: Int
PS: Yeah, it's amusing how I mix the british "Colour" and the american "Color" spellings, I know that. :-)
About this entry
You’re currently reading “Convergence”, an entry on Waldheinz
- Published:
- 4.28.10 / 5pm
- Category:
- English
- Tags:
- coding, graphics, haskell, raytracing



No comments
Jump to comment form | comments rss [?] | trackback uri [?]