Newer
Older
rpg / v2 / packages / engine / src / render / __tests__ / particles.test.ts
import { describe, expect, it } from 'vitest';
import { particleWindow, polyAABB } from '../polyWindow';

/** Базис изо-камеры демо (yaw 45°, возвышение как в voxelRenderer) —
 *  числа совпадают с численной проверкой three.js. */
const ISO = (() => {
    const d: [number, number, number] = [Math.cos(Math.PI / 4) * 0.5, 1, Math.sin(Math.PI / 4) * 0.5];
    const n = Math.hypot(...d);
    const dir = d.map((v) => v / n) as [number, number, number]; // взгляд → камера
    const f = [-dir[0]!, -dir[1]!, -dir[2]!] as const; // взгляд камеры
    const rx = dir[2]!, rz = -dir[0]!; // r = norm(f × (0,1,0))
    const rl = Math.hypot(rx, rz);
    const r = [rx / rl, 0, rz / rl] as const;
    const u = [
        r[1]! * f[2]! - r[2]! * f[1]!, r[2]! * f[0]! - r[0]! * f[2]!, r[0]! * f[1]! - r[1]! * f[0]!,
    ] as const; // u = r × f
    return { r, u, f };
})();

/** Параметры окна как в игре: viewSize 80, кадр 16:9, камера в 4·viewSize. */
const PARAMS = {
    r: ISO.r, u: ISO.u, f: ISO.f,
    a: 80 * (16 / 9), h: 80,
    k0: 0.1 - 320, k1: 800 - 320,
    dy0: 0, dy1: 0,
};

/** Точка δ видна ⇔ |r·δ|≤a, |u·δ|≤h, k0 ≤ f·δ ≤ k1 (слою в тесте равна). */
const onScreen = (d: readonly number[]): boolean =>
    Math.abs(d[0]! * ISO.r[0]! + d[1]! * ISO.r[1]! + d[2]! * ISO.r[2]!) <= PARAMS.a &&
    Math.abs(d[0]! * ISO.u[0]! + d[1]! * ISO.u[1]! + d[2]! * ISO.u[2]!) <= PARAMS.h &&
    d[0]! * ISO.f[0]! + d[1]! * ISO.f[1]! + d[2]! * ISO.f[2]! >= PARAMS.k0 &&
    d[0]! * ISO.f[0]! + d[1]! * ISO.f[1]! + d[2]! * ISO.f[2]! <= PARAMS.k1;

describe('polyAABB — окно видимости частиц', () => {
    it('наземный слой: след кадра симметричен и совпадает с геометрией', () => {
        const box = particleWindow(PARAMS);
        // след кадра на земле: a·|r.x| + h·|u.x| + вклад глубины вдоль взгляда
        const half = PARAMS.a * Math.abs(ISO.r[0]!) + PARAMS.h * Math.abs(ISO.u[0]!) + 40 * Math.abs(ISO.f[0]!);
        expect(box.maxX).toBeCloseTo(half, 1);
        expect(box.minX).toBeCloseTo(-half, 1);
        expect(box.maxZ).toBeCloseTo(half, 1);
        expect(box.minZ).toBeCloseTo(-half, 1);
    });

    it('всё, что видно на экране в слое, лежит в окне (свойство по выборке)', () => {
        for (const dy1 of [0, 10, 42]) {
            const box = particleWindow({ ...PARAMS, dy0: -2, dy1 });
            // псевдослучайная выборка вокруг кадра (детерминированная)
            let s = 12345;
            const rnd = () => (s = (s * 1103515245 + 12345) % 2147483648) / 2147483648;
            for (let i = 0; i < 4000; i++) {
                const d = [(rnd() - 0.5) * 700, dy1 * rnd() - 2, (rnd() - 0.5) * 700] as const;
                if (d[1]! > dy1) continue;
                if (!onScreen(d)) continue;
                expect(d[0]!).toBeGreaterThanOrEqual(box.minX - 1e-6);
                expect(d[0]!).toBeLessThanOrEqual(box.maxX + 1e-6);
                expect(d[2]!).toBeGreaterThanOrEqual(box.minZ - 1e-6);
                expect(d[2]!).toBeLessThanOrEqual(box.maxZ + 1e-6);
            }
        }
    });

    it('высокий слой сдвигает и расширяет окно: верх экрана видит дальше', () => {
        const low = particleWindow(PARAMS);
        const high = particleWindow({ ...PARAMS, dy0: -2, dy1: 42 });
        expect(high.maxX).toBeGreaterThan(low.maxX);
        expect(high.maxZ).toBeGreaterThan(low.maxZ);
        // сдвиг от высоты: Δx = |f.x|·(dy1/|f.y|)·... верхняя точка слоя глубже
        expect(high.maxX - low.maxX).toBeCloseTo(42 * (ISO.f[0]! / -ISO.f[1]!) * -1, 1);
    });

    it('полиэдр: окно пустого набора ограничений вырождается в точку', () => {
        const box = polyAABB([[[1, 0, 0], 2], [[1, 0, 0], 2], [[0, 1, 0], 3], [[0, 1, 0], 3], [[0, 0, 1], 4], [[0, 0, 1], 4]]);
        expect(box.minX).toBeCloseTo(2);
        expect(box.maxX).toBeCloseTo(2);
    });
});