/**
* 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';
import { lerpColor } from './daynight';
/** Облако деформированных вокселей (позы рига — не выровнены по сетке). */
export interface VoxelCloud {
/** Заменить воксели облака (позиции, цвета, количество). */
update(voxels: readonly (Pick<PosedVoxel, 'pos' | 'color'>)[]): void;
dispose(): void;
mesh: THREE.InstancedMesh;
}
/** Параметры точечного источника света (окна, очаг, лампа героя). */
export interface PointLightOptions {
/** Позиция в мировых единицах. */
pos: [number, number, number];
color: number;
/** Яркость 0..~1.5 (множитель движка). */
intensity: number;
/** Радиус действия в мировых единицах (дальше — гаснет). */
radius: number;
}
/** Ручка точечного источника: игра меняет яркость (мерцание) сама. */
export interface PointLightHandle {
setIntensity(intensity: number): void;
setPos(pos: [number, number, number]): void;
dispose(): void;
}
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;
private nightK = 0;
/** Дневной базовый ambient области (интерьеры темнее улиц — как v1 setAmbient). */
private dayHemisphere = 0x94949e; // P5
/** Дневной множитель солнца (тёмный интерьер гасит и прямое солнце). */
private daySunScale = 1;
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];
}
/**
* Дневной базовый тон области: цвет неба hemisphere днём (улицы — P5,
* тёмные интерьеры — их ambient) + дневной множитель солнца по яркости
* (аналог multiply-ambient v1: тёмная лавка гасит и прямое солнце).
*/
setDayAmbient(color: number): void {
this.dayHemisphere = color & 0xffffff;
const r = (color >> 16) & 0xff;
const g = (color >> 8) & 0xff;
const b = color & 0xff;
const lum = (r * 0.3 + g * 0.6 + b * 0.1) / 255;
this.daySunScale = Math.min(1, Math.max(0, lum * 1.6));
}
/**
* Ночь: k — фактор ночи 0..1 (кривая суток игры), tint — целевой
* ambient-цвет области (микс день/закат/ночь, кривая dayNightAmbient).
* Гасит солнце и сдвигает дневное небо hemisphere к tint; фон сцены — tint.
*/
setNight(k: number, tint: number): void {
this.nightK = Math.min(1, Math.max(0, k));
this.ambient.color.set(lerpColor(this.dayHemisphere, tint & 0xffffff, this.nightK));
this.setSun(this.sunK); // солнце пересчитает себя с учётом nightK
if (!this.scene.background) this.scene.background = new THREE.Color();
(this.scene.background as THREE.Color).set(tint & 0xffffff);
}
/** Точечный источник (не бросает тень): мерцание — через setIntensity. */
addPointLight(opts: PointLightOptions): PointLightHandle {
const light = new THREE.PointLight(opts.color, opts.intensity, opts.radius * 2, 1.6);
light.position.set(...opts.pos);
this.scene.add(light);
return {
setIntensity: (i) => {
light.intensity = i;
},
setPos: (p) => {
light.position.set(p[0], p[1], p[2]);
},
dispose: () => {
this.scene.remove(light);
light.dispose();
},
};
}
/** Материал вокселей: вершинные цвета (палитра × 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)) * (1 - 0.92 * this.nightK) * this.daySunScale;
// тёплый свет у горизонта (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);
}
}