Reverse-engineering Cuphead's film-grain effect - Behind the Code
Published 1/22/2022
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 ;)
tldr; there you go: https://github.com/MZanggl/film-grain
Let me share with you how I extracted it from their website.
Checking the HTML
As usual with these things, opening the 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>
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!
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))
}
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 layed 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);
// ...
}
}
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;
}`;
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
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);
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)
}
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 ;)