1. #! /usr/bin/python
  2.  
  3. # a rewrite of my pygame port of
  4. # youtube.com/watch?v=N8elxpSu9pw
  5. # which is by Bisqwit
  6. # -theinternetftw
  7.  
  8. import pygame, math, random, sys
  9.  
  10. def vMul(a,b):  return [ a[0]*b[0], a[1]*b[1], a[2]*b[2] ]
  11. def vMulD(a,b): return [ a[0]*b,    a[1]*b,    a[2]*b    ]
  12. def vAdd(a,b):  return [ a[0]+b[0], a[1]+b[1], a[2]+b[2] ]
  13. def vAddD(a,b): return [ a[0]+b,    a[1]+b,    a[2]+b    ]
  14. def vSub(a,b):  return [ a[0]-b[0], a[1]-b[1], a[2]-b[2] ]
  15. def vSubD(a,b): return [ a[0]-b,    a[1]-b,    a[2]-b    ]
  16. def vNeg(a):    return [-a[0],     -a[1],     -a[2]      ]
  17. def vPow(a,b):  return [ a[0]**b,   a[1]**b,   a[2]**b   ]
  18.  
  19. def vDot(a,b):  return a[0]*b[0] + a[1]*b[1] + a[2]*b[2]
  20. def vSqr(a):    return vDot(a,a)
  21. def vLen(a):    return math.sqrt(vSqr(a))
  22. def vNorm(a):   return vMulD(a, 1.0/vLen(a))
  23.  
  24. def vMirrorAround(a, axis):
  25.     n = vNorm(axis)
  26.     v = vDot(a,n)
  27.     return vSub(vMulD(n,v+v), a)
  28.  
  29. # for color vectors (rgb)
  30. def vLuma(a): return a[0]*0.299 + a[1]*0.587 + a[2]*0.114
  31. def vClamp(a):
  32.     for i in range(3):
  33.         if   a[i] < 0.0: a[i] = 0.0
  34.         elif a[i] > 1.0: a[i] = 1.0
  35.     return a
  36.  
  37. def vClampWithDesaturation(a):
  38.     # if the color represented by this triplet
  39.     # is too bright or too dim, decrease the saturation
  40.     # as much as required, while keeping the luma unmodified
  41.     l = vLuma(a)
  42.     if   l > 1.0: return [1.0, 1.0, 1.0]
  43.     elif l < 0.0: return [0.0, 0.0, 0.0]
  44.     # if any component is over the bounds,
  45.     # calculate how much the saturation must
  46.     # be reduced to achieve an in-bounds value.
  47.     # Since the luma was verified to be in 0..1,
  48.     # a maximum reduction of saturation to 0% will
  49.     # always produce an in-bounds value, but usually
  50.     # such a drastic reduction is not necessary.
  51.     # Because we're only doing relative modifications,
  52.     # we don't need the original saturation level of the
  53.     # pixel.
  54.     sat = 1.0
  55.     for c in a:
  56.         if   c > 1.0: sat = min(sat, (l-1.0) / (l-c))
  57.         elif c < 0.0: sat = min(sat,  l      / (l-c))
  58.     if sat != 1.0:
  59.         a = vAddD(vMulD(vSubD(a,l), sat),l)
  60.         a = vClamp(a)
  61.     return a
  62.  
  63. def vGetRotMatrix(ang):
  64.     cx,cy,cz = math.cos(ang[0]),math.cos(ang[1]),math.cos(ang[2])
  65.     sx,sy,sz = math.sin(ang[0]),math.sin(ang[1]),math.sin(ang[2])
  66.     sxsz,cxsz = sx*sz,cx*sz
  67.     cxcz,sxcz = cx*cz,sx*cz
  68.     return [ [cy*cz,          cy*sz,          -sy  ],
  69.              [sxcz*sy - cxsz, sxsz*sy + cxcz, sx*cy],
  70.              [cxcz*sy + sxsz, cxsz*sy - sxcz, cx*cy] ]
  71.  
  72. def mTransform(m, vec):
  73.     return [ vDot(m[0], vec), vDot(m[1], vec), vDot(m[2], vec) ]
  74.  
  75. class Plane:
  76.     def __init__(self, norm, off):
  77.         self.normal = norm
  78.         self.offset = off
  79. # declare six planes, each looks
  80. # towards the origin and is 30 units away
  81. planes = [ Plane( [0.0,0.0,-1.0], -30),
  82.            Plane( [0.0,1.0,0.0],  -30),
  83.            Plane( [0.0,-1.0,0.0], -30),
  84.            Plane( [1.0,0.0,0.0],  -30),
  85.            Plane( [0.0,0.0,1.0],  -30),
  86.            Plane( [-1.0,0.0,0.0], -30) ]
  87.  
  88. class Sphere:
  89.     def __init__(self, c, r):
  90.         self.center = c
  91.         self.radius = r
  92. # declare a few spheres
  93. spheres = [ Sphere( [0.0,0.0,0.0],    7.0),
  94.             Sphere( [19.4, -19.4, 0], 2.1),
  95.             Sphere( [-19.4, 19.4, 0], 2.1),
  96.             Sphere( [13.1, 5.1, 0.0], 1.1),
  97.             Sphere( [-5.1, -13.1, 0], 1.1),
  98.             Sphere( [-30.0,-30.0,15.0], 11.0),
  99.             Sphere( [15.0, -30.0,30.0], 6.0),
  100.             Sphere( [30.0, 15.0, -30.0],6.0) ]
  101.  
  102. class LightSource:
  103.     def __init__(self, w, c):
  104.         self.where = w
  105.         self.color = c
  106. #declare lightsources, each w/ a loc and a rgb color
  107. lights = [ LightSource( [-28.0,-14.0,  4.0],  [ .4,.51, .9] ),
  108.            LightSource( [-29.0,-29.0,-29.0],  [.95, .1, .1] ),
  109.            LightSource( [ 14.0, 29.0,-14.0],  [ .8, .8, .8] ),
  110.            LightSource( [ 29.0, 29.0, 29.0],  [1.0,1.0,1.0] ),
  111.            LightSource( [ 28.0,  0.0, 29.0],  [ .5, .6, .1] ) ]
  112.  
  113. numPlanes = len(planes)
  114. numSpheres = len(spheres)
  115. numLights = len(lights)
  116. MAXTRACE = 6
  117.  
  118. def rayFindObstacle(eye, dir, hitDist):
  119.     #try to intersect ray w/ each object, see which gives the closest hit
  120.     hitType = -1
  121.     hitIndex = -1
  122.     hitLoc = [0.0,0.0,0.0]
  123.     hitNormal = [0.0,0.0,0.0]
  124.     for i in range(numSpheres):
  125.         v = vSub(eye, spheres[i].center)
  126.         r = spheres[i].radius
  127.         dv = vDot(dir,v)
  128.         d2 = vSqr(dir)
  129.         sq = dv*dv - d2*(vSqr(v) - r*r)
  130.         #does the ray coincide w/ the sphere?
  131.         if (sq < 1e-6):
  132.             continue
  133.         #if so, where?
  134.         sqt = math.sqrt(sq)
  135.         dist = min(-dv-sqt, -dv+sqt) / d2
  136.         if dist < 1e-6 or dist >= hitDist:
  137.             continue
  138.         hitType = 1
  139.         hitIndex = i
  140.         hitDist = dist
  141.         hitLoc = vAdd(eye, vMulD(dir,hitDist))
  142.         hitNormal = vMulD(vSub(hitLoc,spheres[i].center), 1/r)
  143.     for i in range(numPlanes):
  144.         dv = -vDot(planes[i].normal,dir)
  145.         if dv > -1e-6:
  146.             continue
  147.         d2 = vDot(planes[i].normal,eye)
  148.         dist = (d2 + planes[i].offset) / dv
  149.         if dist < 1e-6 or dist >= hitDist:
  150.             continue
  151.         hitType = 0
  152.         hitIndex = i
  153.         hitDist = dist
  154.         hitLoc = vAdd(eye, vMulD(dir,hitDist))
  155.         hitNormal = vNeg(planes[i].normal)
  156.     return hitType, hitIndex, hitDist, hitLoc, hitNormal
  157.  
  158. numArealightVectors = 20
  159. arealightVectors = []
  160. def initArealightVectors():
  161.     for i in range(numArealightVectors):
  162.         temp = []
  163.         for i in range(3):
  164.             temp.append(2.0*(random.random() - 0.5))
  165.         arealightVectors.append( [temp[0],temp[1],temp[2]] )
  166.  
  167. def rayTrace(eye, dir, k):
  168.     hitDist = 1e6
  169.     hitType, hitIndex, hitDist, hitLoc, hitNormal = rayFindObstacle(eye,dir,hitDist)
  170.  
  171.     if hitType != -1:
  172.  
  173.         # Found an obstacle. Next, find out how it is illuminated.
  174.         # Shoot a ray to each lightsource, and determine if there
  175.         # is an obstacle behind it. This is called "diffuse light".
  176.         # To smooth out the infinitely sharp shadows caused by
  177.         # infinitely small point-lightsources, assume the lightsource
  178.         # is actually a cloud of small lightsources around its center.
  179.         diffuseLight = [0.0,0.0,0.0]
  180.         specularLight = [0.0,0.0,0.0]
  181.         pigment = [1.0, .98, .94] #default pigment
  182.         for i in range(numLights):
  183.             for j in range(numArealightVectors):
  184.                 v = vSub(vAdd(lights[i].where,arealightVectors[j]),hitLoc)
  185.                 lightDist = vLen(v)
  186.                 v = vNorm(v)
  187.                 diffuseEffect = vDot(hitNormal,v) / (numArealightVectors*1.0)
  188.                 attenuation = (1 + ((lightDist/34.0)**2.0))
  189.                 diffuseEffect /= attenuation
  190.                 if diffuseEffect > 1e-3:
  191.                     shadowDist = lightDist - 1e-4
  192.                     t,hi,hd,hl,hn = rayFindObstacle(vAdd(hitLoc,vMulD(v,1e-4)),v,shadowDist)
  193.                     if t == -1: #no obstacle occluding the light
  194.                         diffuseLight = vAdd(diffuseLight,vMulD(lights[i].color,diffuseEffect))
  195.  
  196.         if k > 1:
  197.             # add specular light/reflection, unless recursion depth is maxed
  198.             v = vNeg(dir)
  199.             v = vMirrorAround(v, hitNormal)
  200.             specularLight = rayTrace(vAdd(hitLoc, vMulD(v,1e-4)),v,k-1)
  201.  
  202.         if hitType == 0: #plane
  203.             diffuseLight = vMulD(diffuseLight,0.9)
  204.             specularLight = vMulD(specularLight,0.5)
  205.             # color the different walls differently
  206.             idx = hitIndex % 3
  207.             if   idx == 0: pigment = [0.9,0.7,0.6]
  208.             elif idx == 1: pigment = [0.6,0.7,0.7]
  209.             elif idx == 2: pigment = [0.5,0.8,0.3]
  210.  
  211.         elif hitType == 1: #sphere
  212.             diffuseLight = vMulD(diffuseLight,1.0)
  213.             specularLight = vMulD(specularLight, 0.34)
  214.        
  215.         return vMul(vAdd(diffuseLight,specularLight),pigment)
  216.  
  217.     #didn't hit anything, return black
  218.     return [0.0,0.0,0.0]
  219.  
  220. def main():
  221.  
  222.     pygame.display.init()
  223.     screenw, screenh = 640, 480
  224.     w, h = int(sys.argv[1]),int(sys.argv[2])
  225.     screen = pygame.display.set_mode((screenw, screenh), pygame.DOUBLEBUF)
  226.     frame = pygame.Surface((w,h))
  227.  
  228.     initArealightVectors()
  229.     camangle =      [  0.0,  0.0,  0.0]
  230.     camangledelta = [-.005,-.011,-.017]
  231.     camlook =       [  0.0,  0.0,  0.0]
  232.     camlookdelta =  [-.001, .005, .004]
  233.  
  234.     zoom = 46.0
  235.     zoomdelta = 0.99
  236.  
  237.     contrast = 32
  238.     contrast_offset = -0.17
  239.  
  240.     frames = []
  241.  
  242.     #original numFrames = 9300
  243.     numFrames = int(sys.argv[3])
  244.  
  245.     MAXTRACE = int(sys.argv[4])
  246.  
  247.     for frameNo in range(numFrames):
  248.         # Put camera between central sphere and walls
  249.         campos = [0.0,0.0,16.0]
  250.         camrotatematrix = vGetRotMatrix(camangle)
  251.         campos = mTransform(camrotatematrix, campos)
  252.         camlookmatrix = vGetRotMatrix(camlook)
  253.  
  254.         # Determine contrast ratio for this frame's pixels
  255.         thisframe_min = 100
  256.         thisframe_max = -100
  257.  
  258.         pixels = pygame.PixelArray(frame)
  259.                
  260.         for y in range(h):
  261.             for x in range(w):
  262.                 camray = [ x/float(w) - 0.5,
  263.                            y/float(h) - 0.5,
  264.                            zoom ]
  265.                 camray[0] *= 4.0/3 # Aspect Ratio Correction
  266.                 camray = vNorm(camray)
  267.                 camray = mTransform(camlookmatrix, camray)
  268.                 campix = rayTrace(campos, camray, MAXTRACE)
  269.  
  270.                 campix = vMulD(campix, 0.5) #Adjust brightness
  271.  
  272.                 # update frame luma info for automatic contrast adjuster
  273.                 lum = vLuma(campix)
  274.                 if lum < thisframe_min: thisframe_min = lum
  275.                 if lum > thisframe_max: thisframe_max = lum
  276.  
  277.                 # exagerate the colors to bring contrast better forth
  278.                 campix = vMulD(vAddD(campix,contrast_offset),contrast)
  279.  
  280.                 # Clamp, and compensate for display gamma (for dithering)
  281.                 # ...But don't actually dither. Because that shit is bananas.
  282.                 # Maybe later, I can look up the EGA palette, etc.
  283.                 campix = vClampWithDesaturation(campix)
  284.  
  285.                 # Draw pixel (use pygame)
  286.                 r = int(campix[0] * 255)
  287.                 g = int(campix[1] * 255)
  288.                 b = int(campix[2] * 255)
  289.  
  290.                 pixels[x][y] = pygame.Color(r,g,b)
  291.  
  292.                 #del pixels?
  293.                 pygame.transform.scale(frame, (screenw,screenh), screen)
  294.                 pygame.display.flip()
  295.  
  296.                 for e in pygame.event.get():
  297.                     if e.type == pygame.QUIT:
  298.                         pygame.quit()
  299.                         sys.exit()
  300.  
  301.         frames.append(frame.copy())
  302.  
  303.         print 'frame ',frameNo
  304.         sys.stdout.flush()
  305.  
  306.         # Tweak coordinates/camera params for next frame
  307.         much = 1.0
  308.  
  309.         # In the beginning, do some camera action (play with zoom)
  310.         if zoom <= 1.1:
  311.             zoom = 1.1
  312.         else:
  313.             if zoom > 40:
  314.                 if zoomdelta > 0.95:
  315.                     zoomdelta -= 0.001
  316.             elif zoom < 3:
  317.                 if zoomdelta < 0.99:
  318.                     zoomdelta += 0.001
  319.             zoom *= zoomdelta
  320.             much = 1.1 / ((zoom/1.1)**3.0)
  321.  
  322.         # Update rotation angle
  323.         camlook =  vAdd(camlook,  vMulD(camlookdelta,much))
  324.         camangle = vAdd(camangle, vMulD(camangledelta,much))
  325.  
  326.         # dynamically adjust the contrast based on the contents of the
  327.         # last frame
  328.  
  329.         middle = (thisframe_min + thisframe_max) * 0.5
  330.         span = (thisframe_max - thisframe_min)
  331.         thisframe_min = middle - span*0.60 # Avoid dark tones
  332.         thisframe_max = middle + span*0.37 # Emphasize bright tones
  333.  
  334.         new_contrast_offset = -thisframe_min
  335.         new_contrast = 1 / (thisframe_max - thisframe_min)
  336.  
  337.         # Avoid abrupt changes, though
  338.         l = 0.85
  339.         if frameNo == 0: l = 0.7
  340.         contrast_offset = (contrast_offset*l + new_contrast_offset*(1.0-l))
  341.         contrast        = (contrast       *l + new_contrast       *(1.0-l))
  342.  
  343.     while True:
  344.         for f in frames:
  345.             pygame.transform.scale(f, (screenw,screenh), screen)
  346.             pygame.display.flip()
  347.             pygame.time.wait(33)
  348.             for e in pygame.event.get():
  349.                 if e.type == pygame.QUIT:
  350.                     pygame.quit()
  351.                     sys.exit()
  352.            
  353. if __name__ == '__main__':
  354.     main()