Procedural 3D Apartment Rendering in the Browser: Next.js, Three.js, and WebSocket Multiplayer
In the Prikolnya project, pixelated 3D apartments are rendered for multiplayer arcades without external models. It uses Next.js 16 with React Three Fiber, procedural furniture generation from primitives, Canvas textures for content, and Socket.io for position synchronization. Scenes with 300–800 objects run smoothly on modern devices.
Scene Architecture and Apartment Templates
Apartments are built from zones (rooms) with shared walls and doors. Templates are defined by data without hardcoded logic:
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;
};
const LOFT: ApartmentTemplate = {
id: "loft",
rooms: [
{ id: "lounge", name: "Lounge", x: 0, z: 0, width: 12, depth: 8 },
{ id: "gaming", name: "Gaming Room", x: 12, z: 0, width: 10, depth: 8 },
{ id: "bar", name: "Bar", x: 0, z: 8, width: 10, depth: 6 },
{ id: "studio", name: "Studio", 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 },
],
};
Templates range from a studio (12x10) to a penthouse (24x22). Rendering is declarative: data is transformed into Three.js objects.
Lighting: Layers and Mood Presets
The scene is lit with three layers:
- Ambient — base layer, depends on time of day (morning: #ffd4a0, intensity 1.1; night: #1a2244, 0.4).
- Directional — main light with shadows + fill light.
- Point lights — one per room at a height of 2.3 units.
Eight mood presets are added. Party: ceiling #ff2288 (1.8), ambient #110022 (0.2), accent #00ffff (3.0). Complete transformation without changing geometry.
Procedural Furniture from Primitives
Furniture is assembled from boxGeometry and cylinderGeometry. Advantages: no downloads, instant generation, simplicity for AI descriptions.
Example arcade machine:
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>
);
}
Scene: 300–800 objects. AI generates JSON with primitives for brand showcases.
Wall Content via CanvasTexture
Posters are rendered dynamically:
- Canvas 256x170.
- Background #1a1a2e, frame in author's color, word-wrapped text.
THREE.CanvasTexturewithNearestFilter.meshStandardMaterialwithemissiveIntensity: 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, drawing
const texture = new THREE.CanvasTexture(canvas);
texture.minFilter = THREE.NearestFilter;
3 slots per wall (0.25, 0.5, 0.75), height 1.3 units. Supports images, video, audio.
Pixel Avatars and Pets
Avatars are Canvas textures on a billboard. Layers: body (5 tones), eyes, pants (6), clothing (10), hair (12), accessories (10). Scale 16x24 → 128x192, NearestFilter.
Pets are interpolated with lerp 0.05 (close) / 0.15 (far).
Multiplayer with Interpolation
Socket.io broadcasts positions (~20 FPS):
useFrame(() => {
const now = Date.now();
if (now - lastRef.current.t < 50) return;
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 });
});
Interpolation: position.x += (target.x - position.x) * 0.15 (15% per frame).
volatile.emit drops outdated packets.
Weather and Effects
Particle systems on BufferGeometry:
- Rain: 600 particles,
lineSegments. - Snow: 400 particles,
pointswith drift. - Stars: 200 particles, flickering on Y.
useFrame(() => {
for (let i = 0; i < count; i++) {
positions[i * 3 + 1] -= velocities[i];
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;
});
Doors and Animations
Opening to 81° (π*0.45) based on dot product of wall normal:
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;
Performance Optimizations
| Solution | Effect |
|---------|--------|
| dynamic(() => import("./Apartment3D"), { ssr: false }) | Three.js lazy-loaded, ~150 KB gzip |
| antialias: false | -15-20% fillrate |
| dpr: Math.min(devicePixelRatio, 2) | Saves 2-4x on HiDPI |
| NearestFilter | No trilinear filtering |
| castShadow only on furniture | Shadows selectively |
| receiveShadow only on floor | One receiver |
3D bundle: ~600 KB raw, tree-shaken Three.js.
Gamification and Plans
- Economy: coins for posts, limit 100/day.
- Levels: 10, up to 3500 XP.
- Growing buildings: 1-6 floors based on content.
- Events: 8/year, x3 XP.
- Secret rooms: 10 via codes.
Next: AI furniture, trophies, instancing/LOD.
Key Takeaways
- Procedural furniture from 4-15 primitives eliminates glTF downloads.
- Socket.io with
volatile.emitand 15% lerp ensures smooth multiplayer. - CanvasTexture + NearestFilter for pixel-style content and avatars.
- Optimizations reduce bundle to 150 KB gzip with 300-800 objects.
- Declarative templates allow 5 apartment variants without code.
— Editorial Team
No comments yet.