Newer
Older
rpg / v2 / packages / engine / src / voxel / mesher.ts
/**
 * Воксельный мешер — чистая математика (урок v1 №3), three.js тут не знает.
 *
 * Culled-меш: для каждого сплошного вокселя выпускаются только грани к пустоте.
 * На каждую вершину грани — классический воксельный AO (0..3) по трём соседям
 * (side1/side2/corner, схема 0fps) с флипом диагонали квадроугольника против
 * анизотропных артефактов. AO запекается в вершинные цвета.
 *
 * meshRegion мешает область по функции доступа `at(x,y,z)` — соседей за
 * границей области он видит через ту же функцию, поэтому шов чанков
 * бесшовный: culled и AO не рвутся между чанками (world-мешер вызывает
 * meshRegion с world.get).
 */
import type { VoxelPalette } from './grid';
import { VoxelGrid, hexToRgb } from './grid';

export interface VoxelMesh {
    /** xyz по тройкам, в единицах вокселей */
    positions: number[];
    /** нормали граней (для dirLight и теней) */
    normals: number[];
    /** rgb по тройкам — цвет палитры × яркость AO */
    colors: number[];
    indices: number[];
    /** AO 0..3 на вершину (для тестов и отладки) */
    ao: number[];
    /** id тайля на вершину (0 — плоский цвет; 4 вершины грани несут один) */
    tiles: number[];
    /** uv на вершину, 0..1 в пределах грани (сэмплинг атласа тайлей) */
    uvs: number[];
    /** размер партий: вершин и треугольников (статистика) */
    vertexCount: number;
    triangleCount: number;
}

export interface MeshOptions {
    /** яркость по уровню AO (индекс = уровень occlusion 0..3); [1.0 — открыто] */
    aoLevels?: [number, number, number, number];
    /** Доступ к слою тайлей (id тайля вокселя, 0 — без текстуры). */
    texAt?: (x: number, y: number, z: number) => number;
}

/** Функция доступа к цвету вокселя в мировых координатах (0 — пусто). */
export type VoxelAt = (x: number, y: number, z: number) => number;

/** Полуинтервалы области мешинга: x∈[x0,x1), y∈[y0,y1), z∈[z0,z1). */
export interface RegionBounds {
    x0: number; y0: number; z0: number;
    x1: number; y1: number; z1: 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]];

/** Меш сетки целиком (обёртка над meshRegion). */
export function meshVoxels(grid: VoxelGrid, palette: VoxelPalette, opts: MeshOptions = {}): VoxelMesh {
    return meshRegion((x, y, z) => grid.get(x, y, z),
        { x0: 0, y0: 0, z0: 0, x1: grid.sx, y1: grid.sy, z1: grid.sz }, palette, opts);
}

/**
 * Меш области по функции доступа. Сосед вне сетки считается пустым (границы
 * мира видны), поэтому открытые срезы не исчезают.
 */
export function meshRegion(at: VoxelAt, bounds: RegionBounds, 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(`meshRegion: цвет ${color} не задан в палитре`);
            c = hexToRgb(hex);
            rgbCache.set(color, c);
        }
        return c;
    };

    const pos: number[] = [], nor: number[] = [], col: number[] = [], idx: number[] = [], ao: number[] = [];
    const til: number[] = [], uvs: number[] = [];
    const solid = (x: number, y: number, z: number): number => (at(x, y, z) !== 0 ? 1 : 0);

    for (let z = bounds.z0; z < bounds.z1; z++)
        for (let y = bounds.y0; y < bounds.y1; y++)
            for (let x = bounds.x0; x < bounds.x1; x++) {
                const color = at(x, y, z);
                if (color === 0) continue;
                emitVoxelFaces(x, y, z, color, rgb, levels, solid, opts.texAt, pos, nor, col, idx, ao, til, uvs);
            }

    return { positions: pos, normals: nor, colors: col, indices: idx, ao, tiles: til, uvs, 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,
    texAt: ((x: number, y: number, z: number) => number) | undefined,
    pos: number[], nor: number[], col: number[], idx: number[], aoOut: number[],
    tilOut: number[], uvOut: number[],
): void {
    const tile = texAt ? texAt(x, y, z) : 0; // тайль один на воксель (6 суб-граней в атласе)
    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);
            tilOut.push(tile);
            uvOut.push(du, dv);

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