DEV Community 👩‍💻👨‍💻

Cover image for Reverse-engineering frontend (Cuphead's film-grain effect)! Can you?
Michael Z
Michael Z

Posted on • Originally published at michaelzanggl.com

Reverse-engineering frontend (Cuphead's film-grain effect)! Can you?

For quite a while I've been thinking how cool it would be to have a website in the style of the fantastic game Cuphead. How would that even look like? Then, out of nowhere, either Netflix, or Cuphead's team - not sure, releases https://cupheadcountdown.com.

Immediately, I noticed the film-grain effect on the website and wanted to have it ;)

If you are not sure what I mean, it's this: https://rqnmd.csb.app/


Let me share with you how I extracted it from their website.

I invite you to try it out for yourself, maybe you come up with a different approach!

If you want to go straight to the end, there you go: https://github.com/MZanggl/film-grain

Let's get started!

Checking the HTML

As usual with these things, opening the "Elements" tab in devtools was the first step to solving this puzzle.

Immediately I noticed it was using Nuxt.js due to elements like <div id="_nuxt">, not relevant yet, but it's at least an indication that the JavaScript will be most likely compiled and not a walk in the park to read.

Going inside <main> I found the accurately-named element <div class="filmGrain"> containing a canvas.
It was spanning the entire page with pointer-events turned off so you could still click around.

<style>
  .filmGrain {
    width: 100%;
    height: 100%;
    position: absolute;
    top: 0;
    left: 0;
    pointer-events: none;
  }

  .filmGrain canvas {
    width: 100%;
    height: 100%;
    mix-blend-mode: multiply;
    position: relative;
  }
</style>

<div class="filmGrain">
  <canvas></canvas>
</div>
Enter fullscreen mode Exit fullscreen mode

Unfortunately it's not so easy to look into a canvas, so that's where the next challenge lies.

Finding the relevant code for painting the Canvas

By focusing on the <canvas> element in the devtools "Elements" tab, you can access it in the console using $0.

Trying out various context types, I found out that it's using webgl.

$0.getContext('2d') // null
$0.getContext('webgl') // bingo!
Enter fullscreen mode Exit fullscreen mode

With this knowledge it's easier to find the relevant code in the compiled JavaScript.

In the "Sources" tab, I right-clicked on "www.cupheadcountdown.com" > "Search in Files" and searched for "webgl".
This yielded 3 results which I checked, after using my browser's "pretty print" option on the bottom left.

The third result looked very promising, here's a snippet from said code (compiled & pretty printed):

this.enable = function() {
    o.animID = requestAnimationFrame(o.render),
    window.addEventListener("resize", o.onResize)
}
,
this.disable = function() {
    cancelAnimationFrame(o.animID),
    window.removeEventListener("resize", o.onResize),
    o.animID = null
}
,
this.render = function(time) {
    o.animID = requestAnimationFrame(o.render),
    o.skipFrame++,
    o.skipFrame >= 10 && (o.skipFrame = 0,
    r.d(o.gl.canvas, .5),
    o.gl.viewport(0, 0, o.viewport.x, o.viewport.y),
    o.gl.useProgram(o.programInfo.program),
    r.e(o.gl, o.programInfo, o.bufferInfo),
    o.uniforms.time = .001 * time,
    o.uniforms.color1 = [o.color1.r, o.color1.g, o.color1.b],
    o.uniforms.color2 = [o.color2.r, o.color2.g, o.color2.b],
    o.uniforms.resolution = [o.viewport.x, o.viewport.y],
    r.f(o.programInfo, o.uniforms),
    r.c(o.gl, o.bufferInfo))
}
Enter fullscreen mode Exit fullscreen mode

Reverse-Engineering the compiled code

The code was fairly readable, frankly I had no idea what all these one-letter variable names were for... Though the frequently used variable o was easy as it was declared just at the top of the function as var o = this;. It's the Vue component instance.

With this, I laid out the code in a class, and I got most of it looking like regular code again.

class GrainRenderer {
  render(time) {
    this.animID = requestAnimationFrame(this.render.bind(this));
    this.skipFrame++;
    this.skipFrame >= 10 && (this.skipFrame = 0);
    r.d(this.gl.canvas, 0.5);
    this.gl.viewport(0, 0, this.viewport.x, this.viewport.y);
    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

What's interesting about the above code is that the variable names for a class are not shortened (this.skipFrame) and so it's very easy to comprehend all the other code. This is important for later.

Now it's to find out what the variable names "r", "h", and "c" stand for...

"r" is being used all over the place and contains lots of functions like "r.d", "r.c", or "r.f".
"c" and "h" are only being used once this.programInfo = r.b(this.gl, [c.a, h.a]);.

I realized the code is using requestAnimationFrame so the "render" method will run in a constant loop. This is where I now set a breakpoint and triggered the browser's debugger by focusing on the cupheadcountdown.com tab.

Luckily, c.a and h.a turned out to be just strings. Strings containing GLSL language, which is used for rendering webGL.

The code for c.a is simply:

attribute vec4 position;

void main() {
    gl_Position = position;
}`;
Enter fullscreen mode Exit fullscreen mode

while the other string was a lot bigger. It was what entailed the actual code to render the film-grain effect. The devs conveniently left comments in the code:

// Random spots
// Vignette
// Random lines
// Grain
Enter fullscreen mode Exit fullscreen mode

What's "r"...

Now to the final hurdle...

Stepping into some of r's functions with the debugger turned out that it's a rabbit-hole. Rather than digging deep, this got me thinking. Would they really go to such lengths or is this maybe a library? This is where the non-compiled variable names comes into play (like "this.programInfo").

Searching for webgl "programInfo" yielded a few promising results. And finally, the documentation of twgl.js looked like it contained all the relevant functions.

it's quite doable to map most functions by comparing the arguments the functions took, the order in which the code was executed, as well as the variable names.

// cuphead
this.programInfo = r.b(this.gl, [c.a, h.a]);
//twgl.js docs
const programInfo = twgl.createProgramInfo(gl, ["vs", "fs"]);

// cuphead
this.bufferInfo = r.a(this.gl, {
    position: [-1, -1, 0, 3, -1, 0, -1, 3, 0]
})
// twgl.js docs
const arrays = {
  position: [-1, -1, 0, 1, -1, 0, -1, 1, 0, -1, 1, 0, 1, -1, 0, 1, 1, 0],
};
const bufferInfo = twgl.createBufferInfoFromArrays(gl, arrays);

// cuphead
o.gl.useProgram(o.programInfo.program),
r.e(o.gl, o.programInfo, o.bufferInfo),
// ...
r.f(o.programInfo, o.uniforms),
r.c(o.gl, o.bufferInfo))
// twgl.js
gl.useProgram(programInfo.program);
twgl.setBuffersAndAttributes(gl, programInfo, bufferInfo);
twgl.setUniforms(programInfo, uniforms);
twgl.drawBufferInfo(gl, bufferInfo);
Enter fullscreen mode Exit fullscreen mode

The only difficult one was r.d(o.gl.canvas, .5). So I stepped into the function with the debugger and found this code:

function ze(canvas, t) {
    t = t || 1,
    t = Math.max(0, t);
    const e = canvas.clientWidth * t | 0
      , n = canvas.clientHeight * t | 0;
    return (canvas.width !== e || canvas.height !== n) && (canvas.width = e,
    canvas.height = n,
    !0)
}
Enter fullscreen mode Exit fullscreen mode

With this, I opened twgl.js' GitHub page and looked for for "Math.max". After a bit of searching I finally found this code: https://github.com/greggman/twgl.js/blob/42291da89afb019d1b5e32cd98686aa07cca063d/npm/base/dist/twgl.js#L4683-L4695. Got it!

And voila, puzzle solved.

Closing

This was a fun little challenge, I hope you could take something away from it. Even it's just that you should definitely play and (soon) watch Cuphead ;)

Would you have approached it differently?

Top comments (1)

Collapse
josef profile image
Josef Aidt

Great work Michael that effect is spot on!!

🌚 Browsing with dark mode makes you a better developer.

It's a scientific fact.