Scratch off reveal with HTML5 canvas -- Add fade out element

Viewed 297

I've followed this tutorial to create a "draw to reveal" effect on my website: http://thenewcode.com/1120/Scratch-Off-Reveal-with-HTML5-Canvas. I'd like the 'front' image to be this video asset, and I'd also like the "revealed" path (or each dot drawn) to fade out after a set time after it is drawn (like a fading trail behind the mouse), but could not figure out how to do it. I've tried re-drawing the img after each drawDot() call, but that seems to just draw the entire "revealed" image. Thanks!

Edit: added request for video to be 'front'

2 Answers

Finally, here is my solution:

Every time a dot is drawn, set a clearDot:

setClearDot(brushPos.x, brushPos.y, CLEARDOT_DELAY_MILLISECONDS)

Which will be drawn after a set delay:

function setClearDot(x, y, delay) {
  setTimeout(() => {
    drawClearDot(x, y)
  }, delay);
}

Draw a cleardot:

function drawClearDot(x, y) {
  const squareX = x - brushRadius
  const squareY = y - brushRadius
  const squareSize = brushRadius * 2
  bridgeCanvas.globalCompositeOperation = "source-over";
  bridgeCanvas.drawImage(img, squareX, squareY, squareSize, squareSize, squareX, squareY, squareSize, squareSize);
}

Hopefully a square works well enough, first two vars get the co-ords of the dot and turn them into top left corner of the square. Composite operation has to be changed back to normal (This took me many hours to figure out... it was painful trust me). Then we draw part of the coloured image back.

Here's a working snippet with my solution:

CLEARDOT_DELAY_MILLISECONDS = 1500;

var bridge = document.getElementById("bridge"),
  bridgeCanvas = bridge.getContext('2d'),
  brushRadius = (bridge.width / 100) * 5,
  img = new Image();

if (brushRadius < 50) {
  brushRadius = 50
}

img.onload = function() {
  bridgeCanvas.drawImage(img, 0, 0, bridge.width, bridge.height);
}
img.loc = 'https://s3-us-west-2.amazonaws.com/s.cdpn.io/4273/';
img.filename = 'calgary-bridge-2013.jpg';
if (window.devicePixelRatio >= 2) {
  var nameParts = img.filename.split('.');
  img.src = img.loc + nameParts[0] + "-2x" + "." + nameParts[1];
} else {
  img.src = img.loc + img.filename;
}

function detectLeftButton(event) {
  if ('buttons' in event) {
    return event.buttons === 1;
  } else if ('which' in event) {
    return event.which === 1;
  } else {
    return event.button === 1;
  }
}

function getBrushPos(xRef, yRef) {
  var bridgeRect = bridge.getBoundingClientRect();
  return {
    x: Math.floor((xRef - bridgeRect.left) / (bridgeRect.right - bridgeRect.left) * bridge.width),
    y: Math.floor((yRef - bridgeRect.top) / (bridgeRect.bottom - bridgeRect.top) * bridge.height)
  };
}

function drawDot(mouseX, mouseY) {
  bridgeCanvas.beginPath();
  bridgeCanvas.arc(mouseX, mouseY, brushRadius, 0, 2 * Math.PI, true);
  bridgeCanvas.fillStyle = '#000';
  bridgeCanvas.globalCompositeOperation = "destination-out";
  bridgeCanvas.fill();
}

function drawClearDot(x, y) {
  const squareX = x - brushRadius
  const squareY = y - brushRadius
  const squareSize = brushRadius * 2
  bridgeCanvas.globalCompositeOperation = "source-over";
  bridgeCanvas.drawImage(img, squareX, squareY, squareSize, squareSize, squareX, squareY, squareSize, squareSize);
}

bridge.addEventListener("mousemove", function(e) {
  var brushPos = getBrushPos(e.clientX, e.clientY);
  var leftBut = detectLeftButton(e);
  if (leftBut) {
    drawDot(brushPos.x, brushPos.y);
    setClearDot(brushPos.x, brushPos.y, CLEARDOT_DELAY_MILLISECONDS)
  }
}, false);

bridge.addEventListener("touchmove", function(e) {
  e.preventDefault();
  var touch = e.targetTouches[0];
  if (touch) {
    var brushPos = getBrushPos(touch.pageX, touch.pageY);
    drawDot(brushPos.x, brushPos.y);
    setClearDot(brushPos.x, brushPos.y, CLEARDOT_DELAY_MILLISECONDS)
  }
}, false);

function setClearDot(x, y, delay) {
  setTimeout(() => {
    drawClearDot(x, y)
  }, delay);
}
body {
  margin: 0;
}

#bridge {
  display: block;
  margin: 0 auto;
  background-image: url('https://s3-us-west-2.amazonaws.com/s.cdpn.io/4273/calgary-bridge-1943.jpg');
  background-image: -webkit-image-set(url('https://s3-us-west-2.amazonaws.com/s.cdpn.io/4273/calgary-bridge-1943.jpg') 1x, url('https://s3-us-west-2.amazonaws.com/s.cdpn.io/4273/calgary-bridge-1943-2x.jpg') 2x);
  background-size: cover;
  width: 100%;
  max-width: 750px;
  height: auto;
  cursor: crosshair;
  cursor: url(https://s3-us-west-2.amazonaws.com/s.cdpn.io/4273/circular-cursor.png) 53 53, crosshair;
}

#bridgeContainer {
  text-align: center;
  font-family: Avenir, sans-serif;
}

#bridgeContainer figcaption {
  margin-top: 2rem;
}
<figure id="bridgeContainer">
  <canvas id="bridge" width="750" height="465"></canvas>
  <figcaption>Downtown Calgary in 2013 and 1943; mouse down or touch on photo to reveal</figcaption>
</figure>

That took longer than I'm willing to admit

Render a video

To render a video create a HTMLVideoElement set the src and listen for the canPlay event.

Use requestAnimationFrame]BM_127 to update the video. You can draw the HTMLVideoElement on a canvas directly using ctx.drawImage.

Scratch FX

To create the Scratch FX create a second canvas (call overlay in example) the same size as the background image. Fill that image with no transparent pixels.

Use the mouse (or touch) coords to draw lines on the overlay image (canvas) with ctx.globalCompositeOperation set to "destination-out". This will remove pixels where you draw.

Then set ctx.globalCompositeOperation to "source-atop" and draw the video element onto the overlay. This will only set non transparent pixels with the current video frame.

Then draw the background image to the canvas, and then the overlay.

All done. See demo for the nuts and bolts.

Demo

The following snippet does the above.

// Source wiki commons. Attribution in the leading credits.
const mediaSource = "http://upload.wikimedia.org/wikipedia/commons/7/79/Big_Buck_Bunny_small.ogv";

var showHelp = true; 
var bgReady = false;

// create and load background image
const bgImg = new Image;
bgImg.src = "https://i.stack.imgur.com/C7qq2.png?s=256&g=1";
bgImg.addEventListener("load", () => bgReady = true, {once: true});
const W = canvas.width, H = canvas.height;
const ctx = canvas.getContext("2d");

// Show state and setup help settings
ctx.font = "32px arial";
ctx.fillText("Loading...", 30, 60);
ctx.font = "24px arial";
ctx.fillStyle = "#FFF";
ctx.strokeStyle = "#000";
ctx.lineJoin = "round";
ctx.lineWidth = 4;  

// setup overlay canvas
const overlay = copyImg(canvas);
const ctxO = overlay.getContext("2d");
ctxO.fillRect(0, 0, W, H);
ctxO.lineJoin = "round";
ctxO.lineCap = "round";
function copyImg(img) {
  const can = Object.assign(document.createElement("canvas"), {width: img.width, height: img.height});
  return can;
}

// create video element and load media. Will call StartVideo when ready   
const video = createVideo(mediaSource, StartVideo);
function createVideo(mediaSource, animCallback) {
  const video = document.createElement("video"); 
  video.src = mediaSource;
  video.autoPlay = false;
  video.loop = true;
  video.muted = true;
  video.addEventListener("canplay", animCallback, {once: true});
  video.addEventListener("error", error, {once: true})  
  function error(e) { console.log("Could not load video!!! ", e); }  
  return video
}
function StartVideo() {
  video.play();
  renderLoop();
}

// Create simple mouse listeners
const mouse = createMouse();
function createMouse() {
  const m = {x : 0, y : 0, px: 0, py: 0, button : false, element: canvas, bound: null};  
  m.bounds = m.element.getBoundingClientRect();
  function mouseEvents(e) {
    m.x = e.pageX - m.bounds.left - scrollX;
    m.y = e.pageY - m.bounds.top - scrollY;
    mouse.button = e.type === "mousedown" ? true : e.type === "mouseup" ? false : mouse.button;
  }
  ["down", "up", "move"].forEach(name => document.addEventListener("mouse" + name, mouseEvents));
  return m;
}

// main render loop
function renderLoop() {
    if (bgReady) {
        ctx.drawImage(bgImg, 0, 0, W, H);
        if (mouse.button) {
            ctxO.globalCompositeOperation = "destination-out";
            ctxO.strokeStyle = "#0008";
            ctxO.beginPath();
            ctxO.lineTo(mouse.px, mouse.py);
            ctxO.lineTo(mouse.x, mouse.y);
            ctxO.lineWidth = 30;
            ctxO.stroke();
            ctxO.lineWidth = 26;
            ctxO.stroke();
            ctxO.lineWidth = 22;
            ctxO.stroke();
            showHelp = false;   
        }
        ctxO.globalCompositeOperation = "source-atop";
        ctxO.drawImage(video, 0, 0, W, H);
        ctx.drawImage(overlay, 0, 0, W, H);
        if (showHelp) {              
            ctx.strokeText( "Click drag to reveal", 32, 62);
            ctx.fillText( "Click drag to reveal", 30, 60);
        }
    }
    mouse.px = mouse.x;
    mouse.py = mouse.y;
    requestAnimationFrame(renderLoop);
}
canvas { position : absolute; top : 0px; left : 0px; }
<canvas id="canvas" width="256" height="256"></canvas>

Related