Привет. Расскажу, как устроен мой сайд-проект — пиксельная аркада Прикольня, где у каждой компании друзей своя 3D-квартира с мебелью, аватарами и контентом на стенах. Под капотом — Next.js 16, Three.js через React Three Fiber, WebSocket-мультиплеер и PWA. Без единого .glTF файла — вся мебель процедурная.
Стек
|
Слой |
Технология |
|---|---|
|
Фреймворк |
Next.js 16 (React 19, React Compiler) |
|
3D |
Three.js 0.183 + @react-three/fiber + drei |
|
Мультиплеер |
Socket.io (WebSocket transport) |
|
БД |
PostgreSQL + Drizzle ORM |
|
Деплой |
Standalone build, PWA |
Архитектура сцены
Каждая квартира — это набор зон (комнат) с общими стенами и дверями. Описывается шаблоном:
type RoomDef = {
id: string;
name: string;
x: number; z: number;
width: number; depth: number;
isSecret?: boolean;
};
type WallDef = {
x1: number; z1: number;
x2: number; z2: number;
hasDoor?: boolean;
doorOffset?: number; // 0-1 позиция двери вдоль стены
};
// Пример: Лофт (4 зоны)
const LOFT: ApartmentTemplate = {
id: "loft",
rooms: [
{ id: "lounge", name: "Лаунж", x: 0, z: 0, width: 12, depth: 8 },
{ id: "gaming", name: "Игровая", x: 12, z: 0, width: 10, depth: 8 },
{ id: "bar", name: "Бар", x: 0, z: 8, width: 10, depth: 6 },
{ id: "studio", name: "Студия", x: 10, z: 8, width: 12, depth: 6 },
],
walls: [
{ x1: 0, z1: 0, x2: 22, z2: 0 }, // Север
{ x1: 22, z1: 0, x2: 22, z2: 14 }, // Восток
{ x1: 12, z1: 0, x2: 12, z2: 8, hasDoor: true }, // Перегородка
// ...
],
};
Всего 5 шаблонов: от студии (одна комната 12x10) до пентхауса (6 комнат, 24x22 юнитов). Шаблоны — это просто данные, рендеринг полностью декларативный.
Освещение: 3 слоя + 8 настроений
Для каждой сцены создаётся три слоя света:
-
Ambient — базовый свет, зависит от времени суток
-
Directional (2 шт.) — основной направленный с тенями + заполняющий
-
Point lights — по одному на каждую комнату, на высоте 2.3 юнита
// Интенсивность и цвет ambient по времени суток:
// Утро: #ffd4a0, intensity 1.1
// День: #ffffff, intensity 1.4
// Вечер: #ff6633, intensity 0.8
// Ночь: #1a2244, intensity 0.4
Поверх времени суток можно наложить mood — один из 8 пресетов. Например, "Party" ставит ceiling #ff2288 с intensity 1.8, ambient #110022 с intensity 0.2, и accent #00ffff с intensity 3.0. Визуально — полная трансформация сцены.

Процедурная мебель: 0 загрузок, 35+ предметов
лавное архитектурное решение — никаких 3D-моделей. Вся мебель — это комбинации boxGeometry и cylinderGeometry в коде.
Почему:
-
Нет HTTP-запросов за моделями
-
Нет парсинга glTF
-
Мгновенное появление при покупке
-
Легко генерировать AI (об этом ниже)
Пример — аркадный автомат:
function ArcadeModel({ color, accent }: { color: string; accent: string }) {
return (
<group>
{/* Корпус */}
<Block args={[0.7, 1.6, 0.7]} position={[0, 0.8, 0]} color={color} />
{/* Экран */}
<Block args={[0.5, 0.35, 0.05]} position={[0, 1.2, 0.33]}
color="#0a0a2a" emissive={accent} emissiveIntensity={0.3} />
{/* Панель управления */}
<Block args={[0.5, 0.05, 0.25]} position={[0, 0.85, 0.25]}
color="#1a1a2e" rotation={[-0.3, 0, 0]} />
{/* Джойстик */}
<mesh position={[-0.1, 0.92, 0.25]}>
<cylinderGeometry args={[0.02, 0.02, 0.12]} />
<meshStandardMaterial color="#ff2e63" />
</mesh>
{/* Кнопки */}
{[0.05, 0.15].map((x) => (
<mesh key={x} position={[x, 0.9, 0.2]}>
<cylinderGeometry args={[0.025, 0.025, 0.02]} />
<meshStandardMaterial color={accent} emissive={accent}
emissiveIntensity={0.5} />
</mesh>
))}
</group>
);
}
Каждый предмет — 4-15 мешей. Типичная сцена с 4 комнатами и мебелью — 300-800 Three.js-объектов. Для современных GPU это копейки.
AI-генерируемая мебель
Для бренд-витрин (когда компания-спонсор хочет свою комнату) мебель генерируется через Claude API и описывается JSON:
type ProceduralPart = {
type: "box" | "cylinder" | "sphere" | "pointLight";
args: number[];
position: [number, number, number];
color?: string; // "accent" заменяется на runtime-цвет
emissive?: string;
emissiveIntensity?: number;
};
AI описывает мебель как массив примитивов — рендерер собирает из них Three.js-группу. Никаких моделей, никакого пайплайна.
Контент на стенах: Canvas → Texture → Mesh
Шутки и мемы на стенах — это contentItems из базы, которые рендерятся как 3D-постеры.
Процесс:
-
Создаём Canvas 256x170
-
Рисуем фон, рамку цветом автора, текст с word wrap
-
Из Canvas делаем
THREE.CanvasTexture -
Накладываем на
meshStandardMaterialс лёгкимemissiveIntensity: 0.05
const canvas = document.createElement("canvas");
canvas.width = 256;
canvas.height = 170;
const ctx = canvas.getContext("2d")!;
ctx.fillStyle = "#1a1a2e"; // Тёмный фон
ctx.strokeStyle = authorColor; // Рамка цветом автора
// ... word wrap, отрисовка текста
const texture = new THREE.CanvasTexture(canvas);
texture.minFilter = THREE.NearestFilter; // Пиксельная эстетика
Размещение на стенах: 3 слота на стену (0.25, 0.5, 0.75 от длины), на высоте 1.3 юнита. Стены с дверями пропускают центральный слот. Максимум 12 постеров на комнату.
Кроме текста поддерживаются изображения (TextureLoader), видео (VideoTexture) и аудио (иконка с эмиссией).
Пиксельные аватары: слоёная отрисовка
Аватар — это не 3D-модель, а Canvas-текстура на Billboard-спрайте (всегда смотрит в камеру).
const SPRITE_W = 16; // 16 пикселей ширина
const SPRITE_H = 24; // 24 пикселя высота
const SCALE = 8; // Масштаб → 128x192 canvas
// Слои рисуются поверх друг друга:
const layers = [
bodyData, // Тело (5 тонов кожи)
EYES, // Глаза (фиксированные)
pantsData, // Штаны (6 вариантов)
outfitData, // Одежда (10 вариантов)
hairData, // Причёска (12 вариантов)
accessoryData, // Аксессуар (10 вариантов)
];
Каждый слой — двумерный массив пикселей. Слои мержатся в один Canvas, из которого создаётся текстура с NearestFilter (без интерполяции — чёткие пиксели).
Питомцы (шпиц, кот, хомяк, попугай, рыбка) рендерятся аналогично, но с отставанием от игрока: интерполяция 0.05 против 0.15 у удалённых игроков. Визуально — питомец "догоняет" хозяина.
Мультиплеер: Socket.io + интерполяция
Архитектура простая:
-
Игрок двигается → координаты отправляются через Socket.io
-
Сервер бродкастит координаты остальным участникам комнаты
-
Клиенты плавно интерполируют позиции удалённых игроков
Отправка позиции (~20 FPS)
useFrame(() => {
const now = Date.now();
if (now - lastRef.current.t < 50) return; // Throttle 50ms
const dx = Math.abs(pos.x - lastRef.current.x);
const dz = Math.abs(pos.z - lastRef.current.z);
if (dx < 0.05 && dz < 0.05) return; // Порог движения
socketRef.current?.volatile.emit("position", { x: pos.x, z: pos.z });
// volatile — дропается если буфер полон (идеально для позиций)
});
Ключевое: volatile.emit. Позиции не критичны — если пакет потерялся, через 50мс придёт следующий. Это снижает нагрузку на сеть и не создаёт очередей.
Интерполяция на клиенте
useFrame(() => {
mesh.position.x += (target.x - mesh.position.x) * 0.15;
mesh.position.z += (target.z - mesh.position.z) * 0.15;
});
15% за кадр — визуально плавное движение при 60 FPS. Компенсирует задержки сети без экстраполяции (которая выглядит дёргано при потерях пакетов).
Погода: партикл-система на BufferGeometry
Три эффекта: дождь (600 частиц), снег (400), звёзды (200). Все — на BufferGeometry с Float32Array.
// Инициализация
const positions = new Float32Array(count * 3);
const velocities = new Float32Array(count);
// Обновление каждый кадр
useFrame(() => {
for (let i = 0; i < count; i++) {
positions[i * 3 + 1] -= velocities[i]; // Y вниз
// Снег: горизонтальный снос
positions[i * 3] += Math.sin(Date.now() * 0.0008 + i * 0.7) * 0.004;
// Респаун при выходе за пределы
if (positions[i * 3 + 1] < -0.5) {
positions[i * 3 + 1] = 7 + Math.random() * 2;
}
}
posAttr.needsUpdate = true; // Сигнал GPU обновить буфер
});
Дождь рендерится lineSegments (пары вершин — верх и низ капли), снег и звёзды — points. Звёзды не падают, только подрагивают по Y для эффекта мерцания.
Двери: анимация + определение стороны игрока
Двери открываются при приближении игрока (2 юнита) и поворачиваются на 81 градус. Направление открытия определяется через dot product с нормалью стены:
const DOOR_OPEN_ANGLE = Math.PI * 0.45; // 81°
// Определяем, с какой стороны стены игрок
const wallNormal = { x: -(z2 - z1), z: x2 - x1 }; // Перпендикуляр
const toPlayer = { x: playerX - doorX, z: playerZ - doorZ };
const dot = wallNormal.x * toPlayer.x + wallNormal.z * toPlayer.z;
const direction = dot > 0 ? 1 : -1;
// Плавная анимация
currentAngle += (targetAngle * direction - currentAngle) * 0.1;
Дверь всегда открывается "от игрока" — как в реальности.
Оптимизация: почему 300-800 объектов не тормозят
|
|
|
Решение |
Эффект |
|---|---|
|
|
Three.js не попадает в основной бандл. ~150 KB gzip загружаются лениво |
|
|
Экономия ~15-20% fillrate. Для пиксельной эстетики AA не нужен |
|
|
На 3x-4x экранах рендерим в 2x — экономия в 2-4 раза |
|
|
Нет trilinear-фильтрации, чёткие пиксели |
|
|
Не копим пакеты, дропаем устаревшие |
|
|
Двери, полы, стены считаются один раз |
|
|
Тени дорогие — не тратим на пол и постеры |
|
|
Один shadow receiver вместо десятков |
Бандл 3D-чанка: ~600 KB (raw), ~150 KB (gzip). Three.js tree-shaken — импортируем только нужное.
Геймификация
Чтобы пространство не пустовало, добавил несколько слоёв мотивации:
-
Экономика: 5-15 монет за пост, дневной лимит 100. Тратятся на мебель и скины.
-
Прокачка: 10 уровней (0-3500 XP), каждый с наградой.
-
Растущие здания: на карте каждая комната — здание. Чем больше контента, тем больше этажей (1-6). Визуальная конкуренция.
-
Сезонные ивенты: 8 в год. Меняют погоду, освещение, множители опыта. На 1 апреля — x3.
-
Секретные комнаты: 10 штук, открываются по кодам. Коды спрятаны в интернете.
Что дальше
-
Больше бренд-витрин с AI-генерируемой мебелью
-
Расширение системы трофеев
-
Оптимизация рендеринга для слабых устройств (instancing, LOD)
Потыкать: mybrocade.ru
Готов ответить на вопросы по архитектуре — спрашивайте в комментариях.
Автор: mishkaky
