Как я рендерю 3D-квартиры в браузере: Next.js + Three.js, процедурная мебель и мультиплеер на WebSo…
- пятница, 3 апреля 2026 г. в 00:00:05

Привет, Хабр. Расскажу, как устроен мой сайд-проект — пиксельная аркада Прикольня, где у каждой компании друзей своя 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 юнитов). Шаблоны — это просто данные, рендеринг полностью декларативный.
Для каждой сцены создаётся три слоя света:
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. Визуально — полная трансформация сцены.

лавное архитектурное решение — никаких 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 это копейки.
Для бренд-витрин (когда компания-спонсор хочет свою комнату) мебель генерируется через 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-группу. Никаких моделей, никакого пайплайна.
Шутки и мемы на стенах — это 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
Сервер бродкастит координаты остальным участникам комнаты
Клиенты плавно интерполируют позиции удалённых игроков
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. Компенсирует задержки сети без экстраполяции (которая выглядит дёргано при потерях пакетов).
Три эффекта: дождь (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;
Дверь всегда открывается "от игрока" — как в реальности.
Решение | Эффект |
|---|---|
| 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
Готов ответить на вопросы по архитектуре — спрашивайте в комментариях.