javascript

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

  • пятница, 3 апреля 2026 г. в 00:00:05
https://habr.com/ru/articles/1018620/
Главная комната
Главная комната

Привет, Хабр. Расскажу, как устроен мой сайд-проект — пиксельная аркада Прикольня, где у каждой компании друзей своя 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 настроений

Для каждой сцены создаётся три слоя света:

  1. Ambient — базовый свет, зависит от времени суток

  2. Directional (2 шт.) — основной направленный с тенями + заполняющий

  3. 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-постеры.

Процесс:

  1. Создаём Canvas 256x170

  2. Рисуем фон, рамку цветом автора, текст с word wrap

  3. Из Canvas делаем THREE.CanvasTexture

  4. Накладываем на 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 + интерполяция

Архитектура простая:

  1. Игрок двигается → координаты отправляются через Socket.io

  2. Сервер бродкастит координаты остальным участникам комнаты

  3. Клиенты плавно интерполируют позиции удалённых игроков

Отправка позиции (~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 объектов не тормозят

Решение

Эффект

dynamic(() => import("./Apartment3D"), { ssr: false })

Three.js не попадает в основной бандл. ~150 KB gzip загружаются лениво

antialias: false

Экономия ~15-20% fillrate. Для пиксельной эстетики AA не нужен

dpr: Math.min(devicePixelRatio, 2)

На 3x-4x экранах рендерим в 2x — экономия в 2-4 раза

NearestFilter на текстурах

Нет trilinear-фильтрации, чёткие пиксели

volatile.emit для позиций

Не копим пакеты, дропаем устаревшие

useMemo для геометрий

Двери, полы, стены считаются один раз

castShadow только на мебели

Тени дорогие — не тратим на пол и постеры

receiveShadow только на полу

Один 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

    Готов ответить на вопросы по архитектуре — спрашивайте в комментариях.