项目坍缩浮岛点选与编辑

点选与编辑

鼠标指到哪一格、点了哪一格,全靠解析几何算出来 —— 不需要"拾取缓冲",也不需要深度缓冲。附一个只有编辑器才会撞上的"悬空柱子"。

已完成

职责

把屏幕上的一个点,翻译成“哪一根柱子”,然后让玩家把那一格的地形换掉。

这一层是后加的(一开始只能看不能改),但它反过来替算法层抓出了一个潜伏的 bug —— 见 island-mesh 那页的「这里躺着一个只有编辑器才能发现的 bug」。

为什么不用“拾取缓冲”

通用做法是:把每一格用纯色再画一遍到一个离屏缓冲,鼠标点哪儿就读回那个像素的颜色, 按颜色编号反查格子。这叫 pick buffer,是 3D 引擎在几何不可解析时的兜底手段。

我们的场景完全解析,所以有更省的办法:

拾取缓冲 解析命中(本项目)
代价 每帧多画一整遍,或每次点击多画一遍 0 次额外绘制
精度 边缘像素被抗锯齿串色 → 解出第三个数 精确,无误差
补丁 要靠“回避边缘 N 像素”这类经验规则 不需要
深度 需要一个深度缓冲(或再画一遍深度) 一个点积

三个面 + 一个点积

每根柱子最多只有 3 个面朝相机:顶面,和两个侧面。而“哪几个面朝相机”在正交投影下 只跟相机方向有关,是渲染器早就一帧算一次的那张 vis 表:

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

于是点选就是:把这 3 个面投影成屏幕上的四边形,各自做一次射线交叉判断, 多个面同时命中时取相机深度最大的那个:

depth = x·dir.x + y·dir.y + z·dir.z      // 正交投影下深度就是一个点积

这样“前面高柱子挡住后面矮柱子的顶面”会自动得到正确答案,不需要深度缓冲。

侧面那一段把“地表层”和“地幔”合并成一个四边形,是因为它们本来就共面 —— 中间那条颜色分界线只是上色时画的,不是几何上的折。

成本:250 根柱子 × 3 个面 = 750 次测试,鼠标每动一下算一次也毫无压力 (悬停高亮就是这么来的)。

鼠标事件链

事件 做什么
pointermove(按住) 累计位移 > 4px 就转视角;顺手把悬停框清掉
pointermove(松开) 编辑态下更新 hover → 画面给那一格描个白边
pointerup 拖动过就不算点击(moved > 4 才判定为拖拽),否则 paintAt
pointercancel / pointerleave 清悬停框
keydown 1–5 换地形、0 / E 凿空、Esc 退出编辑、R 重新生成

进编辑态的几条约定

约定 为什么
进入编辑自动关掉自转 岛还在转的话没法瞄准
岛没成型时不给进 那会儿画面是 2D 网格,没有“格子”可以点
重新生成时强制退出编辑 整张地形都被换掉了,编辑态留着没有意义
色板出现时收起图例 色板上已经写了地形名,图例是同一份信息说第二遍
paintAt 命中同一格同一种地形时不动 避免无意义的整岛重算(也避免把“未改动”误报成“改动了”)

已知的坑

坑 症状 原因 / 修法
用 hidden 属性藏色板 藏不住 这里的 display 是 flex,会盖掉 UA 样式表里的 [hidden]{display:none}。改用 .on 类控制
在 const edit 之前直接执行色板构建 整页白屏 暂时性死区(TDZ)。把构建调用挪到文件末尾的启动段
转视角时不清悬停框 会残留一个指错格子的白边 pointermove 里 edit.hover = -1
拖拽结束也算点击 想转视角,结果顺手把地形改了 moved > 4 才判为拖拽;探针有专门一条断言盯这个(拖 80px → 改动 0 格)
只更新被点的那一根柱子 相邻柱子的底面出现台阶 底面由 3×3 平滑后的深度决定,改一格要整岛重挤
不加“同值跳过” 每次移动都整岛重算 cells[key] === brush 直接返回 false

源码

pick.js

                /**
 * 点选与编辑 —— 鼠标指到哪一格,怎么算出来。
 * ---------------------------------------------------------------------------
 * 为什么不用"拾取缓冲"(把每格用纯色再画一遍,然后读回那个像素的颜色来反查格子):
 *   那是通用 3D 引擎在几何不可解析时的兜底手段。代价是要多画一整遍,
 *   还得跟抗锯齿的串色较劲 —— 边缘上那个像素是两个格子混出来的颜色,
 *   解出来是第三个数,于是只能靠"回避边缘"之类的经验规则打补丁。
 *
 * 而这里的场景是**完全解析**的:每根柱子的每个可见面就是一个已知的四边形,
 * 投影到屏幕上做一次二维多边形命中测试就行。精确、无误差、不需要额外一遍绘制。
 *
 * 每根柱子最多试 3 个面:
 *   顶面 + 两个侧面(侧面把"地表层"和"地幔"合并成一段 —— 它们本来就共面,
 *   中间那条线只是颜色分界,不是几何分界)。
 *
 * 多个面同时命中时取**相机深度最大**的那个,也就是最靠前的面。
 * 这样"前面高柱子挡住后面矮柱子的顶面"会自动得到正确答案 ——
 * 不需要任何深度缓冲,因为正交投影下深度就是一个点积。
 */

/** 世界点 → 相机深度(正交投影下就是沿视线方向的点积,越大越靠前) */
function depth3(P, x, y, z){ return x * P.dir.x + y * P.dir.y + z * P.dir.z; }

/** 射线交叉法判断点在不在四边形内。qx/qy 是已经投影好的四个角,顺序绕一圈 */
export function inQuad(qx, qy, x, y){
  let inside = false;
  for (let i = 0, j = 3; i < 4; j = i++){
    const yi = qy[i], yj = qy[j];
    if ((yi > y) !== (yj > y)){
      const xi = qx[i], xj = qx[j];
      if (x < (xj - xi) * (y - yi) / (yj - yi) + xi) inside = !inside;
    }
  }
  return inside;
}

/**
 * 屏幕点 → 命中的格子键(z*n + x);没命中返回 -1。
 * @param island build.js 的产物(有 cols 和 n)
 * @param P      setupProjection 的产物(有 ox/oy/ex/ey/ez/vis/sx/sz/dir)
 */
export function hitColumn(island, P, mx, my){
  const n = island.n;
  const qx = [0,0,0,0], qy = [0,0,0,0];
  let bestKey = -1, bestD = -Infinity;

  const PX = (x, y, z) => P.ox + x * P.ex.x + y * P.ey.x + z * P.ez.x;
  const PY = (x, y, z) => P.oy + x * P.ex.y + y * P.ey.y + z * P.ez.y;

  for (let i = 0; i < island.cols.length; i++){
    const col = island.cols[i];
    const x0 = col.x, z0 = col.z, x1 = x0 + 1, z1 = z0 + 1;
    const h = col.h, bottom = col.bottom;
    const key = col.z * n + col.x;

    // ---- 顶面 ----
    if (P.vis.py){
      qx[0] = PX(x0,h,z0); qy[0] = PY(x0,h,z0);
      qx[1] = PX(x0,h,z1); qy[1] = PY(x0,h,z1);
      qx[2] = PX(x1,h,z1); qy[2] = PY(x1,h,z1);
      qx[3] = PX(x1,h,z0); qy[3] = PY(x1,h,z0);
      if (inQuad(qx, qy, mx, my)){
        const d = depth3(P, x0 + 0.5, h, z0 + 0.5);
        if (d > bestD){ bestD = d; bestKey = key; }
      }
    }

    // ---- 侧面:±X 里朝相机的那个 ----
    if (P.vis.px || P.vis.nx){
      const fx = P.sx > 0 ? x1 : x0;
      qx[0] = PX(fx,h,z0);      qy[0] = PY(fx,h,z0);
      qx[1] = PX(fx,h,z1);      qy[1] = PY(fx,h,z1);
      qx[2] = PX(fx,bottom,z1); qy[2] = PY(fx,bottom,z1);
      qx[3] = PX(fx,bottom,z0); qy[3] = PY(fx,bottom,z0);
      if (inQuad(qx, qy, mx, my)){
        const d = depth3(P, fx, (h + bottom) * 0.5, z0 + 0.5);
        if (d > bestD){ bestD = d; bestKey = key; }
      }
    }

    // ---- 侧面:±Z 里朝相机的那个 ----
    if (P.vis.pz || P.vis.nz){
      const fz = P.sz > 0 ? z1 : z0;
      qx[0] = PX(x0,h,fz);      qy[0] = PY(x0,h,fz);
      qx[1] = PX(x1,h,fz);      qy[1] = PY(x1,h,fz);
      qx[2] = PX(x1,bottom,fz); qy[2] = PY(x1,bottom,fz);
      qx[3] = PX(x0,bottom,fz); qy[3] = PY(x0,bottom,fz);
      if (inQuad(qx, qy, mx, my)){
        const d = depth3(P, x0 + 0.5, (h + bottom) * 0.5, fz);
        if (d > bestD){ bestD = d; bestKey = key; }
      }
    }
  }
  return bestKey;
}

/**
 * 在屏幕坐标处改一格地形。返回真的改了没有。
 *
 * ⚠️ 改完必须**整岛重新挤一遍**(remesh),而不是只更新这一根柱子。
 * 因为底面由"离岛心距离"的平滑结果决定,动一格会连带影响它周围 3×3 的深度。
 * 全岛也就几百格,重算比写增量更新省心得多,也快得多(实测 < 1 ms)。
 */
export function paintAt(state, mx, my, P){
  const key = hitColumn(state.island, P, mx, my);
  if (key < 0) return false;
  if (state.cells[key] === state.brush) return false;
  state.cells[key] = state.brush;
  state.remesh();                  // 重新 buildIsland({n, cells, seed})
  return true;
}