Newer
Older
rpg / v2 / packages / engine / src / render / voxelRenderer.ts
/**
 * VoxelRenderer — three.js как низкоуровневый рендер-бэкенд (решение v2).
 *
 * Граница рендера: мир живёт в воксельных единицах, проекция происходит
 * только здесь (урок v1 №1). Низкое разрешение бэкинг-стора + CSS-целый
 * масштаб с image-rendering: pixelated даёт пиксель-арт взгляд (продолжение
 * стиля v1). AO запечён в вершинные цвета мешером; свет — dirLight с
 * shadow map + мягкий ambient, чтобы тени не проваливались в чёрное.
 */
import * as THREE from 'three';
import type { VoxelMesh } from '../voxel/mesher';
import type { PosedVoxel } from '../animation/rig';

/** Облако деформированных вокселей (позы рига — не выровнены по сетке). */
export interface VoxelCloud {
    /** Заменить воксели облака (позиции, цвета, количество). */
    update(voxels: readonly (Pick<PosedVoxel, 'pos' | 'color'>)[]): void;
    dispose(): void;
    mesh: THREE.InstancedMesh;
}

export interface VoxelRendererOptions {
    /** низкое разрешение рендера (пиксель-арт), по умолчанию 480×270 */
    width?: number;
    height?: number;
    /** центр ортокамеры в мировых единицах */
    target?: [number, number, number];
    /** половина высоты орто-фрустума (зум), в мировых единицах */
    viewSize?: number;
}

/** Изометрия демо: диметрия 2:1 — yaw 45°, тангенс угла возвышения 1/2. */
export const ISO_YAW = Math.PI / 4;
const ISO_TAN = 0.5;

export class VoxelRenderer {
    readonly renderer: THREE.WebGLRenderer;
    readonly scene: THREE.Scene;
    readonly camera: THREE.OrthographicCamera;
    private readonly sun: THREE.DirectionalLight;
    private readonly ambient: THREE.HemisphereLight;
    private readonly target: THREE.Vector3;
    private readonly viewSize: number;
    private sunK = 0.5;

    constructor(canvas: HTMLCanvasElement, opts: VoxelRendererOptions = {}) {
        const width = opts.width ?? 480;
        const height = opts.height ?? 270;
        this.target = new THREE.Vector3(...(opts.target ?? [0, 0, 0]));
        this.viewSize = opts.viewSize ?? 16;

        this.renderer = new THREE.WebGLRenderer({ canvas, antialias: false });
        this.renderer.setSize(width, height, false); // CSS-размер задаёт страница
        this.renderer.shadowMap.enabled = true;
        this.renderer.shadowMap.type = THREE.PCFShadowMap;

        this.scene = new THREE.Scene();

        this.camera = this.makeCamera(width, height);

        this.ambient = new THREE.HemisphereLight(0x94949e, 0x343b2c, 0.55); // P5 / G0
        this.scene.add(this.ambient);

        this.sun = new THREE.DirectionalLight(0xffffff, 1.4);
        this.sun.castShadow = true;
        this.sun.shadow.mapSize.set(1024, 1024);
        const r = this.viewSize * 1.2;
        this.sun.shadow.camera.left = -r;
        this.sun.shadow.camera.right = r;
        this.sun.shadow.camera.top = r;
        this.sun.shadow.camera.bottom = -r;
        this.sun.shadow.bias = -0.0005;
        this.scene.add(this.sun);
        this.scene.add(this.sun.target);
        this.setSun(0.5);
        this.repositionCamera();
    }

    /** Перенести цель камеры (и солнце за ней) — слежение камеры за героем. */
    setTarget(t: readonly [number, number, number]): void {
        this.target.set(t[0], t[1], t[2]);
        this.repositionCamera();
        this.setSun(this.sunK);
    }

    /** Текущая цель камеры (для моста/гейта). */
    currentTarget(): [number, number, number] {
        return [this.target.x, this.target.y, this.target.z];
    }

    /** Материал вокселей: вершинные цвета (палитра × AO) + ламберт от солнца. */
    static material(): THREE.MeshLambertMaterial {
        return new THREE.MeshLambertMaterial({ vertexColors: true });
    }

    /** Добавляет меш из MeshData мешера в сцену (тени: cast + receive). */
    addMesh(mesh: VoxelMesh): THREE.Mesh {
        const geo = new THREE.BufferGeometry();
        geo.setAttribute('position', new THREE.Float32BufferAttribute(mesh.positions, 3));
        geo.setAttribute('normal', new THREE.Float32BufferAttribute(mesh.normals, 3));
        geo.setAttribute('color', new THREE.Float32BufferAttribute(mesh.colors, 3));
        geo.setIndex(mesh.indices);
        const meshObj = new THREE.Mesh(geo, VoxelRenderer.material());
        meshObj.castShadow = true;
        meshObj.receiveShadow = true;
        this.scene.add(meshObj);
        return meshObj;
    }

    /**
     * Облако вокселей для поз анимации: instanced кубы 1×1×1 (ось-выровнены),
     * позиция и цвет — на инстанс. Обновление позы — без пересборки геометрии.
     */
    addVoxelCloud(voxels: readonly (Pick<PosedVoxel, 'pos' | 'color'>)[]): VoxelCloud {
        const geo = new THREE.BoxGeometry(1, 1, 1);
        const mat = new THREE.MeshLambertMaterial();
        const mesh = new THREE.InstancedMesh(geo, mat, Math.max(1, voxels.length));
        mesh.castShadow = true;
        mesh.receiveShadow = true;
        mesh.frustumCulled = false; // bbox у InstancedMesh не считается по инстансам
        this.scene.add(mesh);
        const cloud: VoxelCloud = {
            mesh,
            update: (list) => {
                if (list.length > mesh.instanceMatrix.count) {
                    this.scene.remove(mesh);
                    geo.dispose();
                    mat.dispose();
                    const fresh = this.addVoxelCloud(list);
                    cloud.update = fresh.update;
                    cloud.dispose = fresh.dispose;
                    cloud.mesh = fresh.mesh;
                    return;
                }
                const m = new THREE.Matrix4();
                const c = new THREE.Color();
                list.forEach((v, i) => {
                    mesh.setMatrixAt(i, m.setPosition(v.pos[0], v.pos[1], v.pos[2]));
                    mesh.setColorAt(i, c.set(v.color));
                });
                mesh.count = list.length;
                mesh.instanceMatrix.needsUpdate = true;
                if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
            },
            dispose: () => {
                this.scene.remove(mesh);
                geo.dispose();
                mat.dispose();
            },
        };
        cloud.update(voxels);
        return cloud;
    }

    /**
     * Солнце дня: k — 0 рассвет → 0.5 полдень → 1 закат. Азимут едет по
     * мировой горизонтали (восток→запад), высота — по синусу дня; цвет теплеет
     * у горизонта. Тени на земле ползут от k — это ловит скриншот-гейт.
     */
    setSun(k: number): void {
        this.sunK = k;
        const kk = Math.min(1, Math.max(0, k));
        const az = Math.PI * (1 - kk); // 180°→0°: восток → юг → запад
        const elev = Math.sin(kk * Math.PI) * (Math.PI / 3) + 0.12; // не ниже ~7°
        const dist = this.viewSize * 3;
        this.sun.position.set(
            this.target.x + dist * Math.cos(az) * Math.cos(elev),
            this.target.y + dist * Math.sin(elev),
            this.target.z + dist * Math.sin(az) * Math.cos(elev),
        );
        this.sun.target.position.copy(this.target);
        this.sun.intensity = 0.4 + 1.1 * Math.sin(kk * Math.PI);
        // тёплый свет у горизонта (F1), нейтральный в полдень
        const warm = 1 - Math.sin(kk * Math.PI);
        this.sun.color.setRGB(1, 1 - warm * 0.25, 1 - warm * 0.45);
    }

    /** Один кадр. */
    render(): void {
        this.camera.updateMatrixWorld();
        this.renderer.render(this.scene, this.camera);
    }

    /** Ортокамера диметрии 2:1, смотрит в target с изо-направления. */
    private makeCamera(width: number, height: number): THREE.OrthographicCamera {
        const aspect = width / height;
        const half = this.viewSize;
        return new THREE.OrthographicCamera(
            -half * aspect, half * aspect, half, -half, 0.1, this.viewSize * 10,
        );
    }

    /** Поставить камеру в изо-направление от текущего target. */
    private repositionCamera(): void {
        const dir = new THREE.Vector3(
            Math.cos(ISO_YAW) * ISO_TAN,
            1, // камера НАД сценой (минус давал вид из-под земли: верхние грани без света)
            Math.sin(ISO_YAW) * ISO_TAN,
        ).normalize();
        this.camera.position.copy(this.target).addScaledVector(dir, this.viewSize * 4);
        this.camera.lookAt(this.target);
    }
}