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; }