For clarity, I have not rebuilt DOOM in CSS...yet.
No this is far simpler:
- rendering the output of DOOM
- into a single div
- using a single
background-image: linear-gradient
block. - all client side in the browser!
Is it silly? Yes
Why did I do it? I take it you have never read my articles before, I do silly things with web technologies to learn things...
Why should you read this nonsense? - well you get to play DOOM rendered with CSS for a start!
But seriously, the code can show you some interesting things about interacting with WASM and scaling an image from a <canvas>
.
I also deep dive into averaging pixel values from a 1D array, so if that interests you, that might be useful too!
Oh and a huge shoutout to Cornelius Diekmann, who did the hard work of porting DOOM to WASM
Anyway, enough preamble, you are here to play "Doom in CSS*"
Doom rendered in CSS
On mobile, controls are below the game, for PC controls are explained in the pen.
You have to click on the game before input is recognised
(Also if you are on PC clicking the buttons below the game will not work, they are mobile only).
Note: codepen doesn't seem to like this on some devices, you can play it on my server instead if that happens: grahamthe.dev/demos/doom/
So what is going on here?
It just looked like low quality Doom right?
BUT - if you dared to inspect the output you probably crashed chrome...
You see, we are doing the following:
- Getting the output from
doom.wasm
and putting it on a<canvas>
element. - We are hiding the canvas element and then using JS to gather pixel data
- We take that pixel data, find the average of every 4 pixels to halve the resolution.
- We then convert those new pixels to a CSS linear gradient.
- We apply that linear gradient to the game div using
background-image: linear-gradient
A single linear gradient generated is over 1MB of CSS (actually 2MB), so sadly I can't show you here what it looks like (or on CodePen!) as it is too large!
And we are creating that 60+ times a second...web browsers and CSS parsing is pretty impressive to be able to handle that!
Now I am not going to cover everything, but one thing that was interesting was turning the <canvas>
data into an array and then getting pixel data for rescaling, so let's cover that:
Simple way to get average pixel colour
I had an issue.
Rendering the game in CSS at 640*400 made Web browsers cry!
So I needed to downscale the image to 320*200.
There are loads of ways to do this, but I chose a simple pixel averaging method.
There are some interesting things in the code, but I think the resizing function is one of the most interesting and may be useful for you at some point.
It's especially interesting if you have never dealt with an array of pixel data before (as it is a 1D representation of a 2D image, so traversing it is interesting!).
Here is the code for grabbing the average across pixel data for reference:
function rgbaToHex(r, g, b) {
return (
"#" +
[r, g, b].map((x) => Math.round(x).toString(16).padStart(2, "0")).join("")
)
}
function averageBlockColour(data, startX, startY, width, blockSize) {
let r = 0, g = 0, b = 0;
for (let y = startY; y < startY + blockSize; y++) {
for (let x = startX; x < startX + blockSize; x++) {
const i = (y * width + x) * 4;
r += data[i];
g += data[i + 1];
b += data[i + 2];
}
}
const size = blockSize * blockSize;
return rgbaToHex(r / size, g / size, b / size);
}
This averageBlockColour
function is useful if you ever want to do simple image resizing (for a thumbnail for example).
It is limited to clean multiples (2 pixel, 3 pixel block size etc.), but gives a good idea of how to get average colours of a set of pixels.
The interesting part is const i = (y * width + x) * 4
This is because we are using a Uint8ClampedArray
where each pixel is represented by 4 bytes, 1 for red, 1 for green, 1 for blue and 1 for the alpha channel.
We use this as we need to move around an array that is in 1 dimension and grab pixel data in 2 dimensions.
Pixel data explanation
We need to be able to move around in blocks to average the colours.
These blocks are X pixels wide and X pixels tall.
This means jumping past the rest of an images row data to get the second (or third, or fourth...) rows data as everything is stored in one long line.
Let me try and explain with a quick "diagram":
Image (3x2 pixels):
Row 0: RGBA0: (0,0) RGBA1: (1,0) RGBA2: (2,0)
Row 1: RGBA3: (0,1) RGBA4: (1,1) RGBA5: (2,1)
Array data: [ RGBA0 | RGBA1 | RGBA2 | RGBA3 | RGBA4 | RGBA5 ]
Image pos: (0,0) (1,0) (2,0) (0,1) (1,1) (2,1)
Array pos: 0-3 4-7 8-11 12-15 16-19 20-23
So now you can see how each row of our image is stacked one after the other, you can see why we need to jump ahead.
So our function takes:
- data: our array of pixel data,
- startX: the left most position of the pixels we want data for (in 2 dimensions)
- startY: the top most position of the pixels we want data for (in 2 dimensions)
- width: the total width of our image data (so we can skip rows)
- blockSize: the height and width of the number of pixels we want to average.
If we wanted to get the average of the first 2 by 2 block of pixels here we would pass:
-
data:
[ RGBA0 | RGBA1 | RGBA2 | RGBA3 | RGBA4 | RGBA5 ]
- startX: 0
- startY: 0
- width: 3
- blockSize: 2
Within our loops we get:
//const i = (y * w + x) * 4;
const i = (0 * 3 + 0) * 4 = start at array position 0: RGBA0
const i = (1 * 3 + 0) * 4 = start at array position 12: RGBA3
const i = (0 * 3 + 1) * 4 = start at array position 4: RGBA1
const i = (1 * 3 + 1) * 4 = start at array position 16: RGBA4
Which is pixel data for:
(0,0, 1,0)
(0,1, 1,1)
Then if we want to get the average of the next 4 pixels we just pass:
-
data:
[ RGBA0 | RGBA1 | RGBA2 | RGBA3 | RGBA4 | RGBA5 ]
- startX: 1 <-- increment the start by 1
- startY: 0
- width: 3
- blockSize: 2
Within our loops we now get:
//const i = (y * w + x) * 4;
const i = (0 * 3 + 1) * 4 = start at array position 4: RGBA1
const i = (1 * 3 + 1) * 4 = start at array position 16: RGBA4
const i = (0 * 3 + 2) * 4 = start at array position 8: RGBA2
const i = (1 * 3 + 2) * 4 = start at array position 20: RGBA5
Which is pixel data for:
(1,0, 2,0)
(1,1, 2,1)
Now we have the raw pixel data
The rest of the process is easier to understand
We have some RGBA data for a pixel - which might look like [200,57,83,255]
.
We just add up the values of each part:
r += data[i]; //red
g += data[i + 1]; //green
b += data[i + 2]; //blue
//we deliberately don't grab the "a" (alpha) channel as it will always be 255 - the same as opacity: 1 or non-transparent.
Once we have done this for our 4 pixels (our 2 loops for y and x) we will end up with a total R, G and B value for those 4 pixels (y is 0 and 1, x is 0 and 1 in the first instance and y is 0 and 1 and now x is 1 and 2 in the second instance).
We then just take the average of them:
const size = blockSize * blockSize; // (2 * 2)
// avg red, avg green, avg blue
return rgbaToHex(r / size, g / size, b / size);
And then we pass it to a function that turns variables of red, green and blue into a valid hex value.
function rgbaToHex(r, g, b) {
return (
"#" +
[r, g, b].map((x) => Math.round(x).toString(16).padStart(2, "0")).join("")
)
}
Let's break this down into steps:
- Start with a
#
- Take each of the R, G, B values in order and do the following:
- Round the value (as we had averages and we need integers)
- Convert the raw value to hexidecimal (0-9A-F to change the string to base16)
- make sure that smaller numbers (0-15) are padded with a 0 so we always get 2 digits for each of the R, G and B values (so we always get a total of 6 characters(
- join the R, G and B hex values together.
So if we had [200.2,6.9,88.4] as our R, G and B values we would get:
A = 10, B = 11, C = 12, D = 13, E = 14, F = 15
- Start -> "#"
- 200.2 -> round (200) -> (12 * 16) + 8 = C8
- 6.9 -> round (7) -> (0 * 16) + 7 = 7
- 88.4 -> round (88) -> (5 * 16) + 8 = 58
- Pad -> C8, 07, 58
- Join -> #C80758
And there we have it, R200, G7, B88 is hex code #c80758.
That's a wrap
There are some really interesting parts around sending commands to WASM applications in there, I would encourage you to explore those yourself, along with the super interesting article by Cornelius Diekmann on porting DOOM to WASM I mentioned earlier.
ย ย
ย ย
See you all in the next one, and have a great weekend
Top comments (30)
This is great! One little suggestion: Add
user-select: none;
to the controls.Ahhh, I should have tested it more on mobile, added, hopefully that fixes it!
Awesome
Awesome
As always, amazing work!
Replacing the OLED display with CSS
Haha, maybe we should be rendering games and tv shows in CSS gradients to take, full advantage of OLEDs :-P
"James Cameron, hear me out, Avatar 5... in CSS"
๐คฃ๐
How else are we going to max those lch() colors?
this is so fun haha, love seeing people push css to the absolute limit for no reason except curiosity
No reason? Are ytou trying to say that you don't think it would be a good idea to render a whole website using CSS gradients? :-P
Haha no I'm not saying that. More of a joke.
I'm a big fan of gradients :)
Awesome
This is hilarious and honestly the amount of browser abuse here cracks me up. anyone else ever dig way too deep just because youre curious?
Literally my whole back catalogue is me digging too deep! hahahaha ๐
honestly this kinda stuff feels like magic to me, love seeing how folks break things down to make it possible. you ever find yourself just sucked into a problem so deep you forget how weird it is?
insane seeing doom run like that in a single div, honestly stuff like this always makes me wonder - you think messing with limits like this actually teaches more than just following tutorials?
I think you learn things more deeply doing stupid stuff, as to do something that is not normal means you have to work out the solution yourself plus understand all the fundamental parts of whatever you are playing with to know where you can push the boundaries / combine things in unexpected ways.
I don't think I would learn the same depth from tutorials (not that tutorials are bad though, just different purpose!)
This is awesome. I always love your articles, keep it up!
Awww thanks! ๐
Honestly, I love seeing web tech pushed this far just for the fun of it. Did you notice any major differences in performance across browsers while testing this?
Not really, firefox was a little more stressed at higher resolutions but as everything else uses chromium under the hood they all performed pretty much the same.
To be fair, I just did a "does it look jerky" manual look, never did any true performance checking. Feel free to steal my stuff and up the resolution and do some browser comparisons, would be pretty cool to see which are faster with CSS gradient parsing / massive CSS file parsing! ๐
hehe tops