项目坍缩浮岛等距渲染器

等距渲染器

不依赖任何 3D 库,用正交投影 + 画家算法把几千个方块画成一座有体积感的岛。取景只有两个数,而这两个数都不该"看着差不多"拍出来。

已完成

职责

把 island-mesh 产出的柱子画到 Canvas 2D 上,并处理相机(拖拽旋转、滚轮缩放)。

为什么不用 three.js

这个场景没有光照、没有贴图、没有透视投影、没有骨骼动画。它需要的全部是:

把世界坐标的方块,按正交投影画成屏幕上的多边形,远的先画。

一个正交相机加一个画家算法就够了。省下的是一个 600 KB 的依赖、一次网络请求, 以及手机上不必要的帧率开销。代价是要自己处理下面这几个坑。

优化 ① · 预计算三根世界轴的屏幕偏移

正交投影是线性的,于是“世界点 → 屏幕”可以直接写成:

screen = origin + x·EX + y·EY + z·EZ

EX / EY / EZ 是三根世界轴各自的屏幕偏移向量,一帧算一次:

要什么 怎么来
相机方向 dir (cosφ·sinθ, sinφ, cosφ·cosθ)
右向量 right normalize(cross(worldUp, dir)),其中 worldUp = (0,1,0)
上向量 up cross(dir, right)

之后每个顶点只是乘加,不再有三角函数、不再有矩阵乘法。这是能在一帧里画几千个面的前提。

规模 柱子数 单帧绘制耗时 占 60 fps 预算
14×14 94 1.95 ms 12%
22×22 254 3.76 ms 23%
30×30 480 6.41 ms 38%

⚠️ 口径:这是无头浏览器(软件光栅)下同步连画 120 遍取的平均值,是悲观下限, 真机有 GPU 合成会更快。之所以量“单帧绘制耗时”而不是 fps —— 无头环境的 rAF 被锁在 60 Hz, 数 fps 永远得到 60,拿这个数说“性能好”等于什么都没说。

优化 ② · 面的可见性只跟相机方向有关

正交投影下,“这个面朝不朝相机”跟物体位置完全无关,只跟 dir 的符号有关:

vis = { px: dx > 0, nx: dx < 0, py: dy > 0, ny: dy < 0, pz: dz > 0, nz: dz < 0 }

一帧算一次,几千个面共享。不用逐面做点积。

顺带一个好处:点选格子时“哪一面朝我”直接读这张表就知道,不必逐面判朝向。

优化 ③ · 排序键只用列中心

一根柱子的各个面本来就有固定顺序 —— 先地幔侧面 → 再地表侧面 → 最后顶面, 所以列内部不会互相遮挡,不需要逐面参与排序。

跨列则按列中心的世界深度排一次即可:

key = cx·dir.x + cz·dir.z + (h + bottom)/2 · dir.y + 0.001·(h − bottom)

⚠️ 最后那个 0.001·(h − bottom) 是平局打破项,不能省。 两列深度完全相同时(正对着相机的那些列),谁先画会影响接缝处那一像素的颜色。

⚠️ 也不要改成逐面排序(对每个面算中心深度再排)。除了慢,还会因为浮点噪声 让同一根柱子的相邻面互相咬边 —— 表现是柱子侧面出现锯齿状的彩色杂线。

取景 · 只有两个数,而且两个都不该拍

原点先落在屏幕正中,再往上抬 lift、再按 fit 定缩放:

ox = W/2 − 网格中心的投影
oy = H/2 + 网格中心的投影 − lift · up.y · scale
数 量纲 它应该等于什么 拍错了会怎样
lift 世界格(与缩放无关) 岛体在 y 上的重心高度(含锥底),实测 ≈ 2 岛稳定偏离屏幕中心
fit 屏幕比例(基准 min(W,H)/n) 由“包围盒该占屏多少”反推,实测 1.06 岛缩在中间,四周空一大圈

之所以强调“不该拍”:

  • lift 早先被写成 5.6 —— 那是拿底面还比较浅的旧版本凑出来的数。后来底面公式改深, 它没跟着动,结果整座岛稳定偏高 11% 屏高。而且因为它每帧都一致,看着永远“就是这样”, 不像 bug,像设计。
  • fit 早先写 0.70,实量下来岛只占屏 49% × 51%。

怎么量:把每根柱子的 8 个角(顶面 4 个 + 底面 4 个)都投到屏幕上求包围盒。 好消息是正交投影下包围盒边长严格正比于 fit(与原点无关), 所以调它不是试错,是解一步比例:0.70 × 75/49.4 ≈ 1.06。

同一套量法也用来验证居中:lift 改成 2.0 之后,包围盒中心相对屏幕中心的偏移 从 −11.1% 屏高收到 −0.5%。

已知的坑

坑 症状 修法
原点忘了加屏幕中心 整座岛稳定贴在画布左上角 ox = W/2 − 网格中心投影。因为每帧一致,看起来像“故意设计”,最难查
lift 拍一个数,底面改了不跟着改 岛稳定偏离屏幕中心(实测偏高 11% 屏高) lift = 岛体重心高度(世界格),实测 2.0
fit 拍一个数 岛缩在中间、四周空一整圈(实测只占 49%) 量投影包围盒反推;正比关系 → 一步比例
颜色函数套两层 树冠变成纯黑 shade() 只吃 #rrggbb。把它的输出再喂进去 → parseInt('rg') = NaN
每帧拼 rgb() 字符串 大网格下 GC 抖动 明暗缓存 Map,同一个色同一档明度只拼一次
单帧不限流 30×30 时坍缩动画一顿一顿 主循环里单帧最多推进 40 格,余下留到下一帧

装饰物

树和房子是“叠叠乐”味道的主要来源,规则极简:

地形 条件 长出什么
林地 deco > 0.34 就跳过 树干(0.26 宽)+ 两层树冠(0.94 / 0.64)
草地 deco > 0.905 白墙 + 红顶的小房子

⚠️ 树冠的两层必须是不同明度的同一个绿,而且明度要在最外层调一次就完。 先算 shade(green, 0.84) 再把结果喂给 shade() 就会得到黑色 —— 这是“颜色函数套两层”那个坑的现场。

源码

render.js

                /**
 * 等距体素渲染器 —— 不依赖任何 3D 库,一百多行把一堆柱子画成一座岛。
 * ---------------------------------------------------------------------------
 * 为什么不用 three.js:
 *   这个场景没有光照、没有贴图、没有透视、没有动画骨骼 —— 只需要
 *   "把方块按正交投影画成多边形,远的先画"。一个正交相机 + 画家算法就够,
 *   换来的是首屏零依赖、零网络请求、手机上也不会掉帧。
 *
 * 三条关键实现(各自都踩过坑):
 *
 * ① **预计算三根世界轴的屏幕偏移**
 *    正交投影是线性的,所以"世界点 → 屏幕"可以写成
 *      screen = origin + x·EX + y·EY + z·EZ
 *    三根轴各算一次,之后每个顶点只做乘加。这是能在一帧里画几千个面的前提。
 *
 * ② **取景只有两个数:fit 和 lift**
 *    原点先落在屏幕中心 —— 只把"网格中心"摆到原点、忘了加 W/2、H/2,
 *    整座岛会稳定地贴在画布左上角;而且因为每帧都一致,看起来"像是设计成这样的",
 *    很容易查半天。
 *    - `lift` 往视觉上抬一点:岛体大半在 y < 0(锥形底座),重心不在网格平面上。
 *      ⚠️ 它的量纲是**世界格**、与缩放无关,所以它应该正好等于岛体在 y 上的重心
 *      高度(含锥底,实测约 2),不能"看着差不多"拍一个。
 *    - `fit` 决定岛占屏幕多大。⚠️ 也别拍 —— 把每根柱子的 8 个角投到屏幕上量包围盒,
 *      它才是可验证的。而且在正交投影下包围盒边长严格正比于 `fit`,换算是一步比例。
 *
 * ③ **排序键只用列中心,不逐面排**
 *    一根柱子的各个面本来就有固定的绘制顺序(先地幔侧 → 再地表侧 → 最后顶面),
 *    所以每列内部不会互相遮挡;跨列用列中心的世界深度排序即可。
 *    逐面排序不但慢,还会因为浮点噪声让相邻面互相咬边。
 */

/** 六个面"朝不朝相机"只跟相机方向有关 → 一帧算一次,别每面算 */
export function setupProjection({ W, H, yaw, pitch, dist, n, lift = 2.0, fit = 1.06 }){
  const cy = Math.cos(yaw), sy = Math.sin(yaw);
  const cp = Math.cos(pitch), sp = Math.sin(pitch);

  const dx = cp * sy, dy = sp, dz = cp * cy;       // 相机方向(往原点看)

  // right = normalize(cross(worldUp, dir)),worldUp = (0,1,0)
  let rx = dz, rz = -dx;
  const rl = Math.hypot(rx, rz) || 1;
  rx /= rl; rz /= rl;

  // up = cross(dir, right)
  const ux = dy * rz;
  const uy = dz * rx - dx * rz;
  const uz = -dy * rx;

  const scale = Math.min(W, H) * fit / n * dist;
  const e = (ax, ay, az) => ({
    x: (ax * rx + az * rz) * scale,
    y: -(ax * ux + ay * uy + az * uz) * scale,
  });

  const c = (n - 1) / 2;
  const ox = W / 2 - (c * rx + c * rz) * scale;
  const oy = H / 2 + (c * ux + c * uz) * scale - lift * uy * scale;

  return {
    scale,
    ex: e(1, 0, 0), ey: e(0, 1, 0), ez: e(0, 0, 1),
    ox, oy,
    vis: {
      px: dx > 0, nx: dx < 0,
      py: dy > 0, ny: dy < 0,
      pz: dz > 0, nz: dz < 0,
    },
    sx: dx > 0 ? 1 : -1,
    sz: dz > 0 ? 1 : -1,
    dir: { x: dx, y: dy, z: dz },
  };
}

/** 画一段柱子(不含顶面):两个可见侧面,可用不同明度做出体积感 */
export function sideFaces(g, P, x0, z0, x1, z1, yTop, yBot, colorA, colorB, mul){
  if (yTop - yBot < 1e-6) return;

  if (P.vis.px || P.vis.nx){
    const fx = P.sx > 0 ? x1 : x0;
    quad(g, P, [[fx,yTop,z0],[fx,yTop,z1],[fx,yBot,z1],[fx,yBot,z0]],
      shade(colorA, mul * (P.vis.px ? 1 : 0.86)));
  }
  if (P.vis.pz || P.vis.nz){
    const fz = P.sz > 0 ? z1 : z0;
    quad(g, P, [[x0,yTop,fz],[x1,yTop,fz],[x1,yBot,fz],[x0,yBot,fz]],
      shade(colorB, mul * (P.vis.pz ? 1 : 0.72)));
  }
}

export function quad(g, P, pts, color){
  g.beginPath();
  for (let i = 0; i < pts.length; i++){
    const [x, y, z] = pts[i];
    const sx = P.ox + x * P.ex.x + y * P.ey.x + z * P.ez.x;
    const sy = P.oy + x * P.ex.y + y * P.ey.y + z * P.ez.y;
    if (i === 0) g.moveTo(sx, sy); else g.lineTo(sx, sy);
  }
  g.closePath();
  g.fillStyle = color;
  g.fill();
}

/** 明暗缓存 —— 同一个色同一档明度只拼一次字符串,比每帧拼 rgb() 快得多 */
const CACHE = new Map();
export function shade(hex, mul){
  const key = hex + '|' + mul.toFixed(2);
  let c = CACHE.get(key);
  if (c) return c;
  const r = Math.min(255, parseInt(hex.slice(1,3),16) * mul) | 0;
  const g = Math.min(255, parseInt(hex.slice(3,5),16) * mul) | 0;
  const b = Math.min(255, parseInt(hex.slice(5,7),16) * mul) | 0;
  c = 'rgb(' + r + ',' + g + ',' + b + ')';
  CACHE.set(key, c);
  return c;
}

/**
 * 主绘制。
 * @param g        CanvasRenderingContext2D
 * @param island   build.js 的产物
 * @param view     {W,H,yaw,pitch,dist}
 * @param opts     {grow} 0→1 的长出动画进度;按"离岛心的距离"给每列错开
 */
export function drawIsland(g, island, view, palette, opts = {}){
  const n = island.n;
  const P = setupProjection({ ...view, n });
  const D = P.dir;

  // 视锥剔除 + 排好绘制顺序
  const items = [];
  for (const col of island.cols){
    const r = Math.hypot(col.x - (n - 1) / 2, col.z - (n - 1) / 2) / ((n - 1) / 2);

    let grow = 1;
    if (opts.grow !== undefined && opts.grow < 1){
      // 外圈先长、中心后长:错开 55% 的进度,剩下的 45% 各自长完
      grow = clamp01((opts.grow - (1 - r) * 0.55) / 0.45);
      if (grow <= 0.001) continue;
    }

    const h = col.h;
    const bottom = col.bottom - (1 - grow) * 4.5;   // 还没出现时悬在上面
    const cx = col.x + 0.5, cz = col.z + 0.5;
    items.push({
      col, h, bottom, grow,
      key: cx * D.x + cz * D.z + (h + bottom) * 0.5 * D.y + 0.001 * (h - bottom),
    });
  }
  items.sort((a, b) => a.key - b.key);

  for (const it of items) drawColumn(g, P, it, palette);
  return P;
}

function drawColumn(g, P, it, palette){
  const col = it.col, h = it.h, bottom = it.bottom;
  const x0 = col.x, z0 = col.z, x1 = x0 + 1, z1 = z0 + 1;
  const isWater = col.lv === 1;

  // 地幔:地表之下那一整段锥底
  if (bottom < h - 1 - 1e-6){
    const my = h - 1;
    if (P.vis.py && !isWater){
      quad(g, P, [[x0,my,z0],[x0,my,z1],[x1,my,z1],[x1,my,z0]], palette.mantleTop);
    }
    sideFaces(g, P, x0, z0, x1, z1, my, bottom, palette.mantleSide, palette.mantleSide, 0.72);
  }

  // 地表层:侧面 + 顶面
  sideFaces(g, P, x0, z0, x1, z1, h, h - 1,
    palette.side[col.lv], palette.side[col.lv], isWater ? 0.82 : 1);

  if (P.vis.py){
    quad(g, P, [[x0,h,z0],[x0,h,z1],[x1,h,z1],[x1,h,z0]], palette.top[col.lv]);
  }

  if (!isWater) drawDeco(g, P, col, palette);
}

/** 树与房子 —— "叠叠乐"的味道主要来自它们。这里的颜色必须传 #rrggbb,别再套一层 shade() */
function drawDeco(g, P, col, palette){
  if (col.lv === 4){                              // 林地 → 树
    if (col.deco > 0.34) return;
    const t = 0.26, ox = col.x + 0.5 - t / 2, oz = col.z + 0.5 - t / 2;
    sideFaces(g, P, ox, oz, ox + t, oz + t, col.h + 0.95, col.h, '#6b4c33', '#553c28', 1);
    if (P.vis.py){
      quad(g, P, [[ox,col.h+0.95,oz],[ox,col.h+0.95,oz+t],[ox+t,col.h+0.95,oz+t],[ox+t,col.h+0.95,oz]], '#7a583c');
    }
    block(g, P, col.x + 0.5, col.z + 0.5, col.h + 0.85, 0.94, 0.86, '#5d9a55');
    block(g, P, col.x + 0.5, col.z + 0.5, col.h + 1.55, 0.64, 0.62, '#6caa60');
    return;
  }

  if (col.lv === 3 && col.deco > 0.905){          // 草地 → 偶尔一间房
    const w = 0.74;
    const x0 = col.x + 0.5 - w / 2, z0 = col.z + 0.5 - w / 2;
    const y0 = col.h + 1.05;
    sideFaces(g, P, x0, z0, x0 + w, z0 + w, y0, col.h, '#e7e0d2', '#cfc6b4', 1);
    if (P.vis.py) quad(g, P, [[x0,y0,z0],[x0,y0,z0+w],[x0+w,y0,z0+w],[x0+w,y0,z0]], '#f2ece0');
    const rw = w * 1.24, rx0 = col.x + 0.5 - rw / 2, rz0 = col.z + 0.5 - rw / 2;
    sideFaces(g, P, rx0, rz0, rx0 + rw, rz0 + rw, y0 + 0.44, y0, '#b8563f', '#96432f', 1);
    if (P.vis.py) quad(g, P, [[rx0,y0+0.44,rz0],[rx0,y0+0.44,rz0+rw],[rx0+rw,y0+0.44,rz0+rw],[rx0+rw,y0+0.44,rz0]], '#c96147');
  }
}

function block(g, P, cx, cz, yBase, w, hh, hex){
  const x0 = cx - w / 2, z0 = cz - w / 2;
  sideFaces(g, P, x0, z0, x0 + w, z0 + w, yBase + hh, yBase, hex, hex, 0.84);
  if (P.vis.py) quad(g, P, [[x0,yBase+hh,z0],[x0,yBase+hh,z0+w],[x0+w,yBase+hh,z0+w],[x0+w,yBase+hh,z0]], hex);
}

function clamp01(v){ return v < 0 ? 0 : v > 1 ? 1 : v; }