Newer
Older
rpg / v2 / packages / engine / src / voxel / __tests__ / mesher.test.ts
import { describe, expect, it } from 'vitest';
import { VoxelGrid, hexToRgb } from '../grid';
import { meshVoxels } from '../mesher';

const PALETTE = { colors: { 1: '#808080', 2: '#00ff00' } };

describe('VoxelGrid', () => {
    it('set/get и границы: вне сетки всегда пусто', () => {
        const g = new VoxelGrid(2, 3, 4);
        g.set(1, 2, 3, 7);
        expect(g.get(1, 2, 3)).toBe(7);
        expect(g.get(2, 2, 3)).toBe(0);
        expect(g.get(-1, 0, 0)).toBe(0);
        g.set(100, 0, 0, 5); // игнорируется
        expect(g.count()).toBe(1);
    });

    it('set валидирует цвет, конструктор — размеры', () => {
        const g = new VoxelGrid(1, 1, 1);
        expect(() => g.set(0, 0, 0, 256)).toThrow();
        expect(() => new VoxelGrid(0, 1, 1)).toThrow();
    });

    it('hexToRgb: короткая и полная формы, ошибка на мусоре', () => {
        expect(hexToRgb('#f00')).toEqual([1, 0, 0]);
        expect(hexToRgb('#0080ff')).toEqual([0, 128 / 255, 1]);
        expect(() => hexToRgb('#zzz')).toThrow();
    });
});

describe('meshVoxels — culled-меш', () => {
    it('один воксель: 6 граней, 24 вершины, 12 треугольников, все нормали по осям', () => {
        const g = new VoxelGrid(1, 1, 1);
        g.set(0, 0, 0, 1);
        const m = meshVoxels(g, PALETTE);
        expect(m.vertexCount).toBe(24);
        expect(m.triangleCount).toBe(12);
        // все 6 осевых направлений присутствуют (нормали не смешанные)
        const dirs = new Set<string>();
        for (let v = 0; v < m.vertexCount; v++) {
            dirs.add(`${m.normals[v * 3]},${m.normals[v * 3 + 1]},${m.normals[v * 3 + 2]}`);
        }
        expect(dirs.size).toBe(6);
    });

    it('winding согласован с нормалью: cross(v1-v0, v2-v0) ≈ normal (для three.js)', () => {
        const g = new VoxelGrid(2, 2, 2);
        for (let x = 0; x < 2; x++) for (let y = 0; y < 2; y++) g.set(x, y, 0, 1), g.set(x, y, 1, 1);
        const m = meshVoxels(g, PALETTE);
        for (let t = 0; t < m.indices.length; t += 3) {
            const i = [m.indices[t], m.indices[t + 1], m.indices[t + 2]];
            const p = i.map((k) => [m.positions[k * 3], m.positions[k * 3 + 1], m.positions[k * 3 + 2]]);
            const e1 = [p[1][0] - p[0][0], p[1][1] - p[0][1], p[1][2] - p[0][2]];
            const e2 = [p[2][0] - p[0][0], p[2][1] - p[0][1], p[2][2] - p[0][2]];
            const cr = [
                e1[1] * e2[2] - e1[2] * e2[1],
                e1[2] * e2[0] - e1[0] * e2[2],
                e1[0] * e2[1] - e1[1] * e2[0],
            ];
            const n = i.map((k) => m.normals[k * 3]);
            void n;
            // осевые компоненты нормали совпадают с компонентами cross
            for (let a = 0; a < 3; a++) {
                const axisVal = m.normals[i[0] * 3 + a];
                if (axisVal !== 0) expect(cr[a]).toBeCloseTo(axisVal, 5);
            }
        }
    });

    it('внутренние грани сплошного блока срезаны: поверхность 6×9 граней', () => {
        const g = new VoxelGrid(3, 3, 3);
        for (let x = 0; x < 3; x++)
            for (let y = 0; y < 3; y++) for (let z = 0; z < 3; z++) g.set(x, y, z, 1);
        const m = meshVoxels(g, PALETTE);
        expect(m.vertexCount).toBe(6 * 9 * 4);
    });

    it('соседние воксели не выпускают общую грань', () => {
        const g = new VoxelGrid(2, 1, 1);
        g.set(0, 0, 0, 1);
        g.set(1, 0, 0, 1);
        const m = meshVoxels(g, PALETTE);
        expect(m.vertexCount).toBe(10 * 4); // 12 граней − 1 скрытая
    });
});

describe('meshVoxels — AO', () => {
    it('вершина, зажатая двумя боковыми соседями, максимально темная (ao=0)', () => {
        const g = new VoxelGrid(3, 3, 3);
        g.set(1, 1, 1, 1); // исследуемый воксель
        g.set(2, 2, 1, 1); // сосед сбоку по y+1
        g.set(2, 1, 0, 1); // сосед сбоку по z-1
        const m = meshVoxels(g, PALETTE);
        const i0 = m.ao.indexOf(0);
        expect(i0).toBeGreaterThanOrEqual(0);
        // вершина с ao=0 лежит в углу (2,2,1)-грани +X: позиция (2,2,1)
        expect(m.positions[i0 * 3]).toBeCloseTo(2);
        expect(m.positions[i0 * 3 + 1]).toBeCloseTo(2);
        expect(m.positions[i0 * 3 + 2]).toBeCloseTo(1);
    });

    it('AO затемняет землю рядом со стоящим блоком (ao=2 у стыка)', () => {
        const g = new VoxelGrid(3, 2, 1);
        g.set(0, 0, 0, 1);
        g.set(2, 0, 0, 1); // земля
        g.set(1, 1, 0, 1); // блок на земле
        const m = meshVoxels(g, PALETTE);
        const dark = m.ao.filter((a) => a === 2).length;
        expect(dark).toBeGreaterThan(0);
        // вся AO в допустимом диапазоне
        for (const a of m.ao) expect(a).toBeGreaterThanOrEqual(0), expect(a).toBeLessThanOrEqual(3);
    });

    it('цвет запекается с яркостью AO: тёмная вершина темнее цвета палитры', () => {
        const g = new VoxelGrid(3, 3, 3);
        g.set(1, 1, 1, 1);
        g.set(2, 2, 1, 1);
        g.set(2, 1, 0, 1);
        const m = meshVoxels(g, PALETTE);
        const i0 = m.ao.indexOf(0);
        expect(m.colors[i0 * 3]).toBeCloseTo(0x80 / 255 * 0.45, 3);
        const bright = m.ao.indexOf(3);
        expect(m.colors[bright * 3]).toBeCloseTo(0x80 / 255, 3);
    });

    it('флип диагонали: у стыка с тёмными углами треугольники идут через тёмную пару', () => {
        // Сетка, где у грани +X вокселя (1,1,1) сумма AO порядка (0,3) > (3,2) — флип включается
        const g = new VoxelGrid(4, 4, 4);
        g.set(1, 1, 1, 1);
        g.set(2, 2, 1, 1);
        g.set(2, 1, 0, 1);
        const m = meshVoxels(g, PALETTE);
        // ищем квадроугольник грани +X: вершины (2,1,1),(2,2,1),(2,2,2),(2,1,2)
        const want = [[2, 1, 1], [2, 2, 1], [2, 2, 2], [2, 1, 2]];
        const s = findQuadStart(m, want);
        expect(s).toBeGreaterThanOrEqual(0);
        expect(findQuadIndices(m, s)).toEqual([s + 1, s + 2, s + 3, s + 1, s + 3, s]);
    });

    it('без стыка диагональ дефолтная (v0-v2)', () => {
        const g = new VoxelGrid(2, 1, 1);
        g.set(0, 0, 0, 1);
        const m = meshVoxels(g, PALETTE);
        // грань +X вокселя (0,0,0): вершины (1,0,0),(1,1,0),(1,1,1),(1,0,1)
        const s = findQuadStart(m, [[1, 0, 0], [1, 1, 0], [1, 1, 1], [1, 0, 1]]);
        expect(s).toBeGreaterThanOrEqual(0);
        expect(findQuadIndices(m, s)).toEqual([s, s + 1, s + 2, s, s + 2, s + 3]);
    });
});

/** Ищет стартовый индекс вершины квадроугольника с заданными позициями. */
function findQuadStart(m: ReturnType<typeof meshVoxels>, want: number[][]): number {
    for (let v = 0; v + 3 < m.vertexCount; v += 4) {
        let ok = true;
        for (let k = 0; k < 4 && ok; k++) {
            for (let a = 0; a < 3; a++) {
                if (Math.abs(m.positions[(v + k) * 3 + a] - want[k][a]) > 1e-6) ok = false;
            }
        }
        if (ok) return v;
    }
    return -1;
}

/** Ищет в массиве индексов пару треугольников квадроугольника с вершинами s..s+3. */
function findQuadIndices(m: ReturnType<typeof meshVoxels>, s: number): number[] {
    const def = [s, s + 1, s + 2, s, s + 2, s + 3];
    const flip = [s + 1, s + 2, s + 3, s + 1, s + 3, s];
    for (let i = 0; i + 5 < m.indices.length; i++) {
        const seg = m.indices.slice(i, i + 6);
        if (seg.join(',') === def.join(',')) return def;
        if (seg.join(',') === flip.join(',')) return flip;
    }
    return [];
}
describe('текстурный слой мешера (texAt → tiles/uvs)', () => {
    it('без texAt: tiles нулевые, uvs в пределах грани', () => {
        const g = new VoxelGrid(1, 1, 1);
        g.set(0, 0, 0, 1);
        const m = meshVoxels(g, PALETTE);
        expect(m.tiles).toEqual(new Array(m.vertexCount).fill(0));
        expect(m.uvs.length).toBe(m.vertexCount * 2);
        for (let i = 0; i < m.uvs.length; i++) expect(m.uvs[i]).toBeGreaterThanOrEqual(0);
    });

    it('с texAt: 4 вершины грани несут один id тайля, uvs образуют квадрат 0..1', () => {
        const g = new VoxelGrid(2, 1, 1);
        g.set(0, 0, 0, 1);
        g.set(1, 0, 0, 1);
        const m = meshVoxels(g, PALETTE, { texAt: (x) => (x === 0 ? 5 : 0) });
        // у вокселя x=0 все 6 граней с тайлем 5, у x=1 — нули
        let fives = 0, zeros = 0;
        for (const t of m.tiles) (t === 5 ? fives++ : zeros++);
        expect(fives).toBe(20); // 5 видимых граней × 4 вершины (общая срезана)
        expect(zeros).toBe(20);
        // uv-квадрат: (0,0),(1,0),(1,1),(0,1) — 4 вершины первой грани
        const q = m.uvs.slice(0, 8);
        expect(q).toEqual([0, 0, 0, 1, 1, 1, 1, 0]); // первая грань −x (ORDER_NEG)
    });
});