---
title: "Raindrops"
date: "2013-12-23"
---


This is a Javascript demo that simulates ray-traced raindrops.

<style>
#holder {
    height: 600px;
    width: 0;
    margin: 0 auto;
    position: relative;
    cursor: none;
}
#holder canvas, #holder img {
    margin: 0 -300px;
    position: absolute;
    top: 0;
    left: 0;
    max-width: none;
}
</style>
<div id="holder">
<canvas id="pic" width="600" height="600"></canvas>
<img src="background3.jpg" width="600" alt="Night scene" />
<canvas id="raindrops" width="600" height="600"></canvas>
</div>

You can click to add new raindrops.

Each raindrop is represented using Gaussian [metaballs](https://en.wikipedia.org/wiki/Metaballs) centered at positions $\vec{x}_i = (u_i, v_i)$. A pixel at position $\vec{x} = (u,v)$ is part of a raindrop if:

$$
\sum_{i=0}^n \operatorname{exp}\left(\frac{\|\vec{x}-\vec{x}_i\|^2}{\sigma}\right) > \text{threshold}
$$

If the pixel is within a raindrop, it has a corresponding surface normal that is the gradient of the above function. To get the refracted image, we then query the colour of the image a point offset from the pixel by some constant times the normal vector. Some shading is also done with luminosity dependent on normal.

Background image credit: [Matanaka farm buildings by Karora, Public Domain](https://commons.wikimedia.org/wiki/File:Matanaka_-_Granary,_Privy_%26_Schoolhouse.jpg)

<script>
      var requestAnimFrame = window.requestAnimationFrame ||
      window.webkitRequestAnimationFrame ||
        window.mozRequestAnimationFrame ||
        window.msRequestAnimationFrame ||
        window.oRequestAnimationFrame ||
        function(callback, element) { setTimeout(callback, 1000/60); };
      var metametaball = [];
      var mballx = [], mbally = [];
      function getMousePos(evt) {
        if(evt.offsetX) {
          mouseX = evt.offsetX; mouseY = evt.offsetY;
        } else if(evt.layerX) {
          mouseX = evt.layerX; mouseY = evt.layerY;
        }
        return { x: 2*mouseX, y: 2*mouseY };
      };
      function metaball(x, y) {
        return 1*Math.exp((-x*x-y*y)/1000);
      };
      function dmetaball(x, y) {
        return 5*Math.exp((-x*x-y*y)/1000);
      };
      var loaded = false;
      var imageObj = new Image();
      var raindrops, context, imdata;
      var pic;
      var height, width;
      var mousePos = {x:-999, y:-999}, mousePos_ = {x:-999, y:-999};
      var changed;
      var redraw = 100;
      var update = function() {
        if(changed && loaded) {
          draw();
          changed = false;
        }
        requestAnimFrame(update);
      }
      var sigmoid = function(x) {
        var parameter = 50;
        return parameter*(2/(1+Math.exp(-x/parameter))-1);
      }
      var drawpixel = function(x, y) {
        var z = metaball(x-mousePos.x, y-mousePos.y);
        var dz = dmetaball(x-mousePos.x, y-mousePos.y);
        var mx = mballx[x][y] -(x-mousePos.x)*dz;
        var my = mbally[x][y] -(y-mousePos.y)*dz;
        z += metametaball[x][y];
        //z = sigmoid(z);
        mx = sigmoid(mx);
        my = sigmoid(my);
        if(z>0.6) {
          var r, g, b, xx, yy;
          xx = (~~(6*(0.1*(x-width/2) + mx)+width/2) + (width))%(width);
          yy = (~~(6*(0.1*(y-height/2) + my)+height/2) + (height))%(height);
          var index = (x + y * width) * 4;
          var index2 = (xx + yy * width) * 4;
          imdata.data[index+0] = pic[index2+0]*(0.79-0.01*(my));//*Math.sqrt(z);
          imdata.data[index+1] = pic[index2+1]*(0.79-0.01*(my));//*Math.sqrt(z);
          imdata.data[index+2] = pic[index2+2]*(0.79-0.01*(my));//*Math.sqrt(z);
          imdata.data[index+3] = 255;
        } else {
          var index = (x + y * width) * 4;
          imdata.data[index+0] = 0;
          imdata.data[index+1] = 0;
          imdata.data[index+2] = 0;
          imdata.data[index+3] = 0;
        }
      }
      var draw = function() {
        for(var x=Math.max(mousePos.x-redraw, 0); x<Math.min(mousePos.x+redraw, width); x++){
          for(var y=Math.max(mousePos.y-redraw, 0); y<Math.min(mousePos.y+redraw, height); y++){
            drawpixel(x, y);
          }
        }
        for(var x=Math.max(mousePos_.x-redraw, 0); x<Math.min(mousePos_.x+redraw, width); x++){
          for(var y=Math.max(mousePos_.y-redraw, 0); y<Math.min(mousePos_.y+redraw, height); y++){
            drawpixel(x, y);
          }
        }
        context.putImageData(imdata, 0, 0);
      }
      var addDrop = function(ax, ay) {
        if(metametaball[ax][ay] >1) return;
        for(var j=0; j<3; j++) {
          var _x = ax + ~~(Math.random()*40-20);
          var _y = ay + ~~(Math.random()*60-30);
          console.log(_x, _y);
          for(var x=Math.max(_x-redraw, 0); x<Math.min(_x+redraw, width); x++){
            for(var y=Math.max(_y-redraw, 0); y<Math.min(_y+redraw, height); y++){
              var z = metaball(x-_x, y-_y)/3;
              var dz = dmetaball(x-_x, y-_y);
              metametaball[x][y] += z;
              mballx[x][y] += -(x-_x)*dz;
              mbally[x][y] += -(y-_y)*dz;
            }
          }
        }
        for(var x=Math.max(ax-redraw, 0); x<Math.min(ax+redraw, width); x++){
          for(var y=Math.max(ay-redraw, 0); y<Math.min(ay+redraw, height); y++){
            drawpixel(x, y);
          }
        }
        changed = true;
      }
      var rain = function() {
        console.log('raining');
        var rainPos = {x:~~(Math.random()*width), y:~~(Math.random()*height)};
        addDrop(rainPos.x, rainPos.y);
        setTimeout(rain, 500+Math.random()*3000);
      }
      window.onload = function() {
        raindrops = document.getElementById('raindrops');
        height = raindrops.height;
        width = raindrops.width;
        context = raindrops.getContext('2d');
        /* I treat all devices as being Hi-DPI (i.e. a pixel ratio of 2)
        because even on normal displays, this looks better as it is like
        anti-aliasing. */
        raindrops.width = width * 2;
        raindrops.height = height * 2;

        raindrops.style.width = width + 'px';
        raindrops.style.height = height + 'px';
        context.scale(2, 2);
        width *=2;
        height *=2;

        imdata = context.createImageData(width, height);
        for(var x=0; x<width; x++){
          metametaball[x] = [];
          mballx[x] = [];
          mbally[x] = [];
          for(var y=0; y<width; y++){
            metametaball[x][y] = 0;
            mballx[x][y] = 0;
            mbally[x][y] = 0;
          }
        }
        imageObj.onload = function() {
          console.log('image loaded');
          var canvas = document.getElementById('pic');
          var piccontext = canvas.getContext('2d');
          canvas.width = width;
          canvas.height = height;

          canvas.style.width = width/2 + 'px';
          canvas.style.height = height/2 + 'px';
          context.scale(2, 2);
          piccontext.drawImage(imageObj, 0, 0);
          var picimdata = piccontext.getImageData(0, 0, width, height);
          pic = picimdata.data;
          loaded = true;
          rain();
        };
        imageObj.src = 'background2.jpg';

        raindrops.addEventListener('click', function(evt) {
          if(changed) return;
          mousePos_ = mousePos;
          mousePos = getMousePos(evt);
          addDrop(mousePos.x, mousePos.y);
        }, false);
        raindrops.addEventListener('mousemove', function(evt) {
          if(changed) return;
          mousePos_ = mousePos;
          mousePos = getMousePos(evt);
          changed = true;
        }, false);
        update();
      };
</script>
