D

DOOM……使用单个 DIV 和 CSS 渲染!🤯🔫💥

2025-06-07

DOOM……使用单个 DIV 和 CSS 渲染!🤯🔫💥

为了清楚起见,我还没有在 CSS 中重建 DOOM...。

不,这简单得多:

  • 渲染DOOM 的输出
  • 放入一个 div 中
  • 使用单个background-image: linear-gradient块。
  • 所有客户端都在浏览器中!

傻吗?是的

我为什么要这么做?我猜你之前肯定没读过我的文章,我经常用 Web 技术做一些傻事来学习……

你为什么要读这些废话? ——好吧,你可以先玩用 CSS 渲染的 DOOM!

但说真的,代码可以向您展示一些有关与 WASM 交互以及从 缩放图像的有趣的事情<canvas>

我还深入研究了从一维数组中平均像素值,因此如果您对此感兴趣,这也可能会有用!

哦,还要特别感谢 Cornelius Diekmann,他为将DOOM 移植到 WASM做出了巨大的贡献

好了,序言已经讲完了,下面是玩“CSS* 中的 Doom”的流程。

使用 CSS渲染的Doom

在移动设备上,控制位于游戏下方,而对于 PC,控制则在笔中进行了说明。

您必须点击游戏才能识别输入

(此外,如果您在 PC 上单击游戏下方的按钮将不起作用,因为它们仅适用于移动设备)。

注意:codepen 在某些设备上似乎不支持此功能,如果发生这种情况,您可以在我的服务器上玩它:grahamthe.dev/demos/doom/

那么这里发生了什么?

它看起来就像是低质量的《毁灭战士》对吧?

但是- 如果你敢检查输出,你可能会让 chrome 崩溃......

您看,我们正在做以下事情:

  • 从元素中获取输出doom.wasm并将其放在<canvas>元素上。
  • 我们隐藏画布元素,然后使用 JS 收集像素数据
  • 我们获取该像素数据,找到每 4 个像素的平均值以将分辨率减半。
  • 然后我们将这些新像素转换为 CSS 线性渐变。
  • 我们将线性渐变应用到游戏 div 上,使用background-image: linear-gradient

生成的单个线性渐变超过 1MB CSS(实际上是 2MB),因此很遗憾我无法在这里向您展示它的样子(或在 CodePen 上!)因为它太大了!

我们每秒创建 60 多次...Web 浏览器和 CSS 解析能够处理这一点非常令人印象深刻!

现在我不会涵盖所有内容,但有一件有趣的事情是将<canvas>数据转换为数组,然后获取像素数据进行重新缩放,所以让我们来介绍一下:

获取平均像素颜色的简单方法

我遇到了一个问题。

使用 CSS 以 640*400 渲染游戏让 Web 浏览器哭了!

所以我需要将图像缩小到 320*200。

有很多方法可以做到这一点,但我选择了一种简单的像素平均方法。

代码中有一些有趣的东西,但我认为调整大小功能是最有趣的功能之一,在某些时候可能会对您有用。

如果您以前从未处理过像素数据数组,那么它会特别有趣(因为它是 2D 图像的 1D 表示,所以遍历它很有趣!)。

以下是获取像素数据平均值的代码以供参考:

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);
}
Enter fullscreen mode Exit fullscreen mode

averageBlockColour如果您想要对图像进行简单的大小调整(例如缩略图),此功能非常有用。

它仅限于干净的倍数(2 像素、3 像素块大小等),但可以很好地说明如何获取一组像素的平均颜色。

有趣的是const i = (y * width + x) * 4

这是因为我们使用Uint8ClampedArray每个像素由 4 个字节表示的格式,其中 1 个字节表示红色,1 个字节表示绿色,1 个字节表示蓝色,1 个字节表示 Alpha 通道。

我们使用它是因为我们需要移动一维数组并抓取二维像素数据。

像素数据解释

我们需要能够以块为单位移动来平均颜色。

这些块的宽度为 X 像素,高度为 X 像素。

这意味着跳过图像行的其余部分来获取第二行(或第三行、第四行……)数据,因为所有内容都存储在一条长行中。

让我尝试用一​​个简短的“图表”来解释一下:

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
Enter fullscreen mode Exit fullscreen mode

现在您可以看到我们的图像的每一行是如何一个接一个地堆叠的,您可以明白为什么我们需要向前跳。

所以我们的函数需要:

  • 数据:我们的像素数据数组,
  • startX:我们想要数据的像素最左边的位置(二维)
  • startY:我们想要数据的像素的最顶部位置(二维)
  • 宽度:图像数据的总宽度(因此我们可以跳过行)
  • blockSize:我们想要平均的像素数的高度和宽度。

如果我们想获得第一个 2 x 2 像素块的平均值,我们可以这样传递:

  • 数据: [ RGBA0 | RGBA1 | RGBA2 | RGBA3 | RGBA4 | RGBA5 ]
  • 起始X: 0
  • 起始Y: 0
  • 宽度: 3
  • 块大小: 2

在我们的循环中我们得到:

//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
Enter fullscreen mode Exit fullscreen mode

像素数据为:
(0,0, 1,0)
(0,1, 1,1)

然后,如果我们想获得接下来 4 个像素的平均值,我们只需传递:

  • 数据: [ RGBA0 | RGBA1 | RGBA2 | RGBA3 | RGBA4 | RGBA5 ]
  • startX: 1 <-- 将起始位置加 1
  • 起始Y: 0
  • 宽度: 3
  • 块大小: 2

在我们的循环中我们现在得到:

//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
Enter fullscreen mode Exit fullscreen mode

像素数据为:
(1,0, 2,0)
(1,1, 2,1)

现在我们有了原始像素数据

其余过程更容易理解

我们有一些像素的 RGBA 数据 - 可能看起来像[200,57,83,255]

我们只需将每个部分的值加起来:

  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.
Enter fullscreen mode Exit fullscreen mode

一旦我们对 4 个像素(y 和 x 的 2 个循环)完成此操作,我们将得到这 4 个像素的总 R、G 和 B 值(在第一个实例中 y 为 0 和 1,x 为 0 和 1,在第二个实例中 y 为 0 和 1,现在 x 为 1 和 2)。

然后我们取它们的平均值:

  const size = blockSize * blockSize; // (2 * 2)
  //               avg red,    avg green,  avg blue
  return rgbaToHex(r / size,   g / size,   b / size);    
Enter fullscreen mode Exit fullscreen mode

然后我们将其传递给一个函数,该函数将红色、绿色和蓝色的变量转换为有效的十六进制值。

function rgbaToHex(r, g, b) {
  return (
    "#" +
    [r, g, b].map((x) => Math.round(x).toString(16).padStart(2, "0")).join("")
  )
}
Enter fullscreen mode Exit fullscreen mode

让我们将其分解为几个步骤:

  • #
  • 按顺序获取每个 R、G、B 值并执行以下操作:
    • 对值进行四舍五入(因为我们有平均值,所以需要整数)
    • 将原始值转换为十六进制(0-9A-F 将字符串更改为 base16)
    • 确保较小的数字(0-15)用 0 填充,这样我们总是能得到 R、G 和 B 值各 2 位数字(因此我们总是能得到总共 6 个字符(
    • 将 R、G 和 B 十六进制值连接在一起。

因此,如果我们将 [200.2,6.9,88.4] 作为我们的 R、G 和 B 值,我们将得到:

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
Enter fullscreen mode Exit fullscreen mode

我们有它,R200,G7,B88 是十六进制代码#c80758。

就这样结束了

其中有一些关于向 WASM 应用程序发送命令的非常有趣的部分,我鼓励您自己去探索这些部分,以及我之前提到的 Cornelius Diekmann 撰写的关于将 DOOM 移植到 WASM 的超级有趣的文章。


  

如果你觉得这篇文章很有趣(或者让你分心……哈哈),那么别忘了点赞并与他人分享。它确实帮助了我!

  

下期再见,祝大家周末愉快

文章来源:https://dev.to/grahamthedev/doomrendered-using-a-single-div-and-css-1fal
PREV
我对如何做好 Code Review 的看法。我的建议
NEXT
使用 PassportJS 构建 NodeJS Web 应用进行身份验证