/**
* Воксельный мешер — чистая математика (урок v1 №3), three.js тут не знает.
*
* Culled-меш: для каждого сплошного вокселя выпускаются только грани к пустоте.
* На каждую вершину грани — классический воксельный AO (0..3) по трём соседям
* (side1/side2/corner, схема 0fps) с флипом диагонали квадроугольника против
* анизотропных артефактов. AO запекается в вершинные цвета.
*/
import { VoxelGrid, hexToRgb } from './grid';
import type { VoxelPalette } from './grid';
export interface VoxelMesh {
/** xyz по тройкам, в единицах вокселей (углы 0..1 локально) */
positions: number[];
/** нормали граней (для dirLight и теней) */
normals: number[];
/** rgb по тройкам — цвет палитры × яркость AO */
colors: number[];
indices: number[];
/** AO 0..3 на вершину (для тестов и отладки) */
ao: number[];
/** размер партий: вершин и треугольников (статистика) */
vertexCount: number;
triangleCount: number;
}
export interface MeshOptions {
/** яркость по уровню AO (индекс = уровень occlusion 0..3); [1.0 — открыто] */
aoLevels?: [number, number, number, number];
}
const AO_LEVELS_DEFAULT: [number, number, number, number] = [0.45, 0.65, 0.85, 1.0];
/** Классический AO по трём соседям (1 — занято, 0 — пусто). */
function cornerAo(side1: number, side2: number, corner: number): number {
if (side1 && side2) return 0; // щель закрыта с двух сторон — самый тёмный
return 3 - (side1 + side2 + corner);
}
/** Одна грань: ось a (0/1/2), знак s, порядок вершин зависит от знака. */
const FACES: Array<{ axis: 0 | 1 | 2; sign: 1 | -1 }> = [
{ axis: 0, sign: 1 }, { axis: 0, sign: -1 },
{ axis: 1, sign: 1 }, { axis: 1, sign: -1 },
{ axis: 2, sign: 1 }, { axis: 2, sign: -1 },
];
/** Вершины квадроугольника в (u,v) на касательных осях, CCW снаружи. */
const ORDER_POS: Array<[number, number]> = [[0, 0], [1, 0], [1, 1], [0, 1]];
const ORDER_NEG: Array<[number, number]> = [[0, 0], [0, 1], [1, 1], [1, 0]];
/**
* Строит culled-меш сетки. Сосед вне сетки считается пустым (границы мира
* видны), поэтому открытые срезы не исчезают.
*/
export function meshVoxels(grid: VoxelGrid, palette: VoxelPalette, opts: MeshOptions = {}): VoxelMesh {
const levels = opts.aoLevels ?? AO_LEVELS_DEFAULT;
const rgbCache = new Map<number, [number, number, number]>();
const rgb = (color: number): [number, number, number] => {
let c = rgbCache.get(color);
if (!c) {
const hex = palette.colors[color];
if (!hex) throw new Error(`meshVoxels: цвет ${color} не задан в палитре`);
c = hexToRgb(hex);
rgbCache.set(color, c);
}
return c;
};
const pos: number[] = [], nor: number[] = [], col: number[] = [], idx: number[] = [], ao: number[] = [];
const solid = (x: number, y: number, z: number): number => (grid.get(x, y, z) !== 0 ? 1 : 0);
for (let z = 0; z < grid.sz; z++)
for (let y = 0; y < grid.sy; y++)
for (let x = 0; x < grid.sx; x++) {
const color = grid.get(x, y, z);
if (color === 0) continue;
emitVoxelFaces(x, y, z, color, rgb, levels, solid, pos, nor, col, idx, ao);
}
return { positions: pos, normals: nor, colors: col, indices: idx, ao, vertexCount: ao.length, triangleCount: idx.length / 3 };
}
type RgbFn = (color: number) => [number, number, number];
type SolidFn = (x: number, y: number, z: number) => number;
/** Выпускает все видимые грани одного вокселя (хелпер ≤70 строк). */
function emitVoxelFaces(
x: number, y: number, z: number, color: number,
rgb: RgbFn, levels: [number, number, number, number], solid: SolidFn,
pos: number[], nor: number[], col: number[], idx: number[], aoOut: number[],
): void {
for (const { axis, sign } of FACES) {
const nx = axis === 0 ? x + sign : x;
const ny = axis === 1 ? y + sign : y;
const nz = axis === 2 ? z + sign : z;
if (solid(nx, ny, nz)) continue; // culled: сосед занят
const u = (axis + 1) % 3, v = (axis + 2) % 3;
const base = [x, y, z];
const order = sign > 0 ? ORDER_POS : ORDER_NEG;
const start = pos.length / 3;
const vAo: number[] = [];
for (const [du, dv] of order) {
const local = [0, 0, 0];
local[axis] = sign > 0 ? 1 : 0;
local[u] = du;
local[v] = dv;
pos.push(base[0] + local[0], base[1] + local[1], base[2] + local[2]);
nor.push(axis === 0 ? sign : 0, axis === 1 ? sign : 0, axis === 2 ? sign : 0);
// AO: два боковых соседа + диагональный, в плоскости соседней клетки
const offU = du === 1 ? 1 : -1;
const offV = dv === 1 ? 1 : -1;
const s1 = [nx, ny, nz], s2 = [nx, ny, nz], cr = [nx, ny, nz];
s1[u] += offU; s2[v] += offV; cr[u] += offU; cr[v] += offV;
const aoV = cornerAo(solid(s1[0], s1[1], s1[2]), solid(s2[0], s2[1], s2[2]), solid(cr[0], cr[1], cr[2]));
vAo.push(aoV);
aoOut.push(aoV);
const [r, g, b] = rgb(color);
const k = levels[aoV];
col.push(r * k, g * k, b * k);
}
// Флип диагонали против анизотропии AO: диагональ — через более тёмную пару
if (vAo[0] + vAo[2] > vAo[1] + vAo[3]) {
idx.push(start + 1, start + 2, start + 3, start + 1, start + 3, start);
} else {
idx.push(start, start + 1, start + 2, start, start + 2, start + 3);
}
}
}