packages feed

layoutz-0.4.0.0: examples/RayMarcher.hs

{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE LambdaCase #-}

module Main where

import Layoutz
import Data.List (intercalate)
import Text.Printf (printf)

-- | 3D vector
data V3 = V3 !Double !Double !Double

vzero, vup :: V3
vzero = V3 0 0 0
vup   = V3 0 1 0

vadd, vsub, vcross :: V3 -> V3 -> V3
vadd (V3 ax ay az) (V3 bx by bz) = V3 (ax + bx) (ay + by) (az + bz)
vsub (V3 ax ay az) (V3 bx by bz) = V3 (ax - bx) (ay - by) (az - bz)
vcross (V3 ax ay az) (V3 bx by bz) =
  V3 (ay * bz - az * by) (az * bx - ax * bz) (ax * by - ay * bx)

vscale :: V3 -> Double -> V3
vscale (V3 x y z) s = V3 (x * s) (y * s) (z * s)

vdot :: V3 -> V3 -> Double
vdot (V3 ax ay az) (V3 bx by bz) = ax * bx + ay * by + az * bz

vlen :: V3 -> Double
vlen v = sqrt (vdot v v)

vneg :: V3 -> V3
vneg (V3 x y z) = V3 (-x) (-y) (-z)

vnorm :: V3 -> V3
vnorm v =
  let l = vlen v
  in if l < 1e-10 then vzero else vscale v (1.0 / l)

-- | One screen cell with character + 24-bit color
data Pixel = Pixel !Char !Int !Int !Int

-- | Render a list of pixels into a single ANSI string of width @w@
renderFrameBuffer :: [Pixel] -> Int -> String
renderFrameBuffer pixels w = intercalate "\n" (rowsOf pixels)
  where
    rowsOf [] = []
    rowsOf ps =
      let (rowPixels, rest) = splitAt w ps
      in renderRow rowPixels : rowsOf rest
    renderRow ps = concatMap pixelToAnsi ps ++ "\ESC[0m"
    pixelToAnsi (Pixel ch r g b) =
      "\ESC[38;2;" ++ show r ++ ";" ++ show g ++ ";" ++ show b ++ "m" ++ [ch]

data RayState = RayState
  { rsTheta       :: !Double
  , rsPhi         :: !Double
  , rsDist        :: !Double
  , rsMorph       :: !Double
  , rsMorphTarget :: !Double
  , rsAutoRotate  :: !Bool
  , rsTick        :: !Int
  }

initialState :: RayState
initialState = RayState
  { rsTheta = 0.0
  , rsPhi = 0.35
  , rsDist = 3.8
  , rsMorph = 0.0
  , rsMorphTarget = 0.0
  , rsAutoRotate = True
  , rsTick = 0
  }

data RayMsg
  = RTick
  | RotL | RotR | RotU | RotD
  | ZoomIn | ZoomOut
  | ToggleAuto
  | NextShape
  deriving (Show, Eq)

-- Render config
fbW, fbH :: Int
fbW = 60
fbH = 28

maxSteps :: Int
maxSteps = 50

maxDist, eps, morphSpeed :: Double
maxDist     = 20.0
eps         = 0.005
morphSpeed  = 0.06

ramp :: String
ramp = " .'`^\",:;Il!i><~+_-?][}{1)(|/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$"

shapeNames :: [String]
shapeNames = ["sphere", "torus", "cube"]

clampD :: Double -> Double -> Double -> Double
clampD lo hi x = max lo (min hi x)

mixD :: Double -> Double -> Double -> Double
mixD a b t = a * (1.0 - t) + b * t

smoothstep :: Double -> Double -> Double -> Double
smoothstep lo hi x =
  let t = clampD 0.0 1.0 ((x - lo) / (hi - lo))
  in t * t * (3.0 - 2.0 * t)

-- Signed-distance functions
sdSphere :: V3 -> Double -> Double
sdSphere p r = vlen p - r

sdTorus :: V3 -> Double -> Double -> Double
sdTorus (V3 x y z) bigR r =
  let qx = sqrt (x * x + z * z) - bigR
  in sqrt (qx * qx + y * y) - r

sdRoundBox :: V3 -> V3 -> Double -> Double
sdRoundBox (V3 px py pz) (V3 bx by bz) r =
  let qx = abs px - bx
      qy = abs py - by
      qz = abs pz - bz
      outer = vlen (V3 (max qx 0) (max qy 0) (max qz 0))
      inner = min (max qx (max qy qz)) 0.0
  in outer + inner - r

scene :: V3 -> Double -> Double
scene p morph =
  let t      = let m = morph - fromIntegral (3 * floor (morph / 3.0) :: Int)
               in if m < 0 then m + 3 else m
      sph    = sdSphere p 1.05
      tor    = sdTorus p 0.9 0.38
      cub    = sdRoundBox p (V3 0.72 0.72 0.72) 0.12
  in if t < 1.0      then mixD sph tor (smoothstep 0 1 t)
     else if t < 2.0 then mixD tor cub (smoothstep 0 1 (t - 1))
     else                 mixD cub sph (smoothstep 0 1 (t - 2))

calcNormal :: V3 -> Double -> V3
calcNormal (V3 x y z) morph =
  let e = 0.001
  in vnorm $ V3
       (scene (V3 (x + e) y z) morph - scene (V3 (x - e) y z) morph)
       (scene (V3 x (y + e) z) morph - scene (V3 x (y - e) z) morph)
       (scene (V3 x y (z + e)) morph - scene (V3 x y (z - e)) morph)

march :: V3 -> V3 -> Double -> Double
march ro rd morph = go 0.0 0
  where
    go !t !i
      | i >= maxSteps || t >= maxDist = -1.0
      | otherwise =
          let d = scene (vadd ro (vscale rd t)) morph
          in if d < eps then t else go (t + d) (i + 1)

lightDir :: V3
lightDir = vnorm (V3 0.8 1.0 (-0.6))

bgPixel :: V3 -> Pixel
bgPixel (V3 _ y _) =
  let vy = y * 0.5 + 0.5
      bg = max 4 (floor (18.0 - (1.0 - vy) * 8.0) :: Int)
  in Pixel ' ' bg bg (bg + 6)

shade :: V3 -> V3 -> Double -> Pixel
shade ro rd morph =
  let t = march ro rd morph
  in if t < 0
       then bgPixel rd
       else
         let hit  = vadd ro (vscale rd t)
             n    = calcNormal hit morph
             diff = max (vdot n lightDir) 0.0
             refl = vsub (vscale n (2.0 * vdot n lightDir)) lightDir
             spec = (max (vdot refl (vneg rd)) 0.0) ** 32.0 * 0.6
             ao   = 1.0 - clampD 0.0 0.4 (scene (vadd hit (vscale n 0.1)) morph * 5.0)
             lum  = clampD 0.0 1.0 ((0.08 + diff * 0.85 + spec) * ao)
             rampLen = length ramp
             idx = floor (clampD 0 (fromIntegral rampLen - 1) (lum * fromIntegral (rampLen - 1))) :: Int
             ch  = ramp !! idx
             V3 nx ny nz = n
             nx' = nx * 0.5 + 0.5
             ny' = ny * 0.5 + 0.5
             nz' = nz * 0.5 + 0.5
             rC  = floor (clampD 0 255 ((nx' * 0.55 + lum * 0.45) * 235 + 20)) :: Int
             gC  = floor (clampD 0 255 ((ny' * 0.45 + lum * 0.55) * 215 + 15)) :: Int
             bC  = floor (clampD 0 255 ((nz' * 0.50 + lum * 0.50 + 0.05) * 200 + 30)) :: Int
         in Pixel ch rC gC bC

renderFrame :: RayState -> L
renderFrame s =
  let theta = rsTheta s
      phi   = rsPhi s
      dist  = rsDist s
      morph = rsMorph s
      ro    = V3 (dist * sin theta * cos phi)
                 (dist * sin phi)
                 (dist * cos theta * cos phi)
      fwd   = vnorm (vneg ro)
      right = vnorm (vcross fwd vup)
      up    = vcross right fwd
      aspect = fromIntegral fbW / fromIntegral fbH * 0.48
      pixels = [ shade ro rd morph
               | py <- [0 .. fbH - 1]
               , let v = 0.5 - fromIntegral py / fromIntegral fbH
               , px <- [0 .. fbW - 1]
               , let u = (fromIntegral px / fromIntegral fbW - 0.5) * aspect
               , let rd = vnorm (vadd (vadd fwd (vscale right u)) (vscale up v))
               ]
  in text (renderFrameBuffer pixels fbW)

-- | Update state in response to a message
updateRay :: RayMsg -> RayState -> RayState
updateRay msg s = case msg of
  RTick ->
    let newTheta = if rsAutoRotate s then rsTheta s + 0.035 else rsTheta s
        diff     = rsMorphTarget s - rsMorph s
        newMorph = if abs diff < 0.01
                     then rsMorphTarget s
                     else rsMorph s + diff * morphSpeed
    in s { rsTheta = newTheta, rsMorph = newMorph, rsTick = rsTick s + 1 }
  RotL  -> s { rsTheta = rsTheta s - 0.15, rsAutoRotate = False }
  RotR  -> s { rsTheta = rsTheta s + 0.15, rsAutoRotate = False }
  RotU  -> s { rsPhi   = min (rsPhi s + 0.1)  1.3 }
  RotD  -> s { rsPhi   = max (rsPhi s - 0.1) (-1.3) }
  ZoomIn  -> s { rsDist = max (rsDist s - 0.25) 2.0 }
  ZoomOut -> s { rsDist = min (rsDist s + 0.25) 8.0 }
  ToggleAuto -> s { rsAutoRotate = not (rsAutoRotate s) }
  NextShape  ->
    let next = (round (rsMorphTarget s) + 1) `mod` 3 :: Int
    in s { rsMorphTarget = fromIntegral next }

-- | Build the visible UI for one frame
viewRay :: RayState -> L
viewRay s =
  let fb        = renderFrame s
      targetIdx = (round (rsMorphTarget s) `mod` 3 + 3) `mod` 3
      settled   = abs (rsMorph s - rsMorphTarget s) < 0.02
      shapeName =
        if settled
          then shapeNames !! targetIdx
          else
            let fromIdx = (round (rsMorph s) `mod` 3 + 3) `mod` 3
            in (shapeNames !! fromIdx) ++ " -> " ++ (shapeNames !! targetIdx)

      sparkData =
        [ 8.0 + 4.0 * sin (fromIntegral (rsTick s + i) * 0.4)
        | i <- [0 .. 29 :: Int]
        ]

      twoPi = 2 * pi :: Double

      cameraStats = withColor ColorBrightBlue $ kv
        [ ("th",   printf "%.2f" (rsTheta s - twoPi * fromIntegral (floor (rsTheta s / twoPi) :: Int)))
        , ("ph",   printf "%.2f" (rsPhi s))
        , ("zoom", printf "%.1f" (rsDist s))
        ]

      rotateLine = tightRow
        [ withColor ColorBrightBlack (text "rotate: ")
        , if rsAutoRotate s
            then withStyle StyleBold (withColor ColorBrightGreen (text "auto"))
            else withColor ColorBrightYellow (text "manual")
        ]

      controls = withStyle StyleDim $ layout
        [ withColor ColorBrightYellow (text "<-^v>  orbit")
        , withColor ColorBrightYellow (text "+/-    zoom")
        , withColor ColorBrightYellow (text "m      shape")
        , withColor ColorBrightYellow (text "a      auto")
        ]

      camera = layout
        [ cameraStats
        , br
        , rotateLine
        , br
        , withColor ColorBrightYellow (text "~12 fps")
        , withColor ColorBrightCyan (plotSparkline sparkData)
        , br
        , withColor ColorBrightMagenta (kv [("shape", shapeName)])
        , br
        , withColor ColorBrightCyan  (spinner "render" (rsTick s `div` 2) SpinnerDots)
        , withColor ColorBrightGreen (spinner "scene"  (rsTick s `div` 3) SpinnerClock)
        , withColor ColorBrightYellow(spinner "light"  (rsTick s `div` 2) SpinnerBounce)
        , br
        , controls
        ]

      cameraBox = withColor ColorBrightMagenta $ setBorder BorderRound $ box "Camera" [camera]

      title = withStyle StyleBold (withColor ColorBrightCyan (text "Ray Marcher"))
  in row [layout [title, fb], cameraBox]

rayMarcher :: LayoutzApp RayState RayMsg
rayMarcher = LayoutzApp
  { appInit = (initialState, CmdNone)
  , appUpdate = \msg state -> (updateRay msg state, CmdNone)
  , appSubscriptions = \_ -> subBatch
      [ subEveryMs 80 RTick
      , subKeyPress $ \case
          KeyLeft      -> Just RotL
          KeyRight     -> Just RotR
          KeyUp        -> Just RotU
          KeyDown      -> Just RotD
          KeyChar '+'  -> Just ZoomIn
          KeyChar '='  -> Just ZoomIn
          KeyChar '-'  -> Just ZoomOut
          KeyChar 'a'  -> Just ToggleAuto
          KeyChar 'A'  -> Just ToggleAuto
          KeyChar 'm'  -> Just NextShape
          KeyChar 'M'  -> Just NextShape
          _            -> Nothing
      ]
  , appView = viewRay
  }

main :: IO ()
main = runApp rayMarcher