我用 Three.js 打造可漫遊的 3D 酒店履歷,無需 3D 模型
I built my résumé as a 3D hotel you can walk through (Three.js, no 3D models)
作者以 Three.js 建造 Hotel Komaiha,將履歷設計成可漫遊的 1930 年代裝飾藝術風格酒店,並以酒店不同樓層呈現個人介紹、職涯、項目和技能。場景不使用 3D 模型、圖片紋理或音訊檔,幾何、材質與音樂均由程式生成;作者亦分享合併靜態網格、着色器動畫交通及自適應解像度等效能做法。酒店支援五種語言,並提供純 HTML 履歷及搜尋引擎和 AI 助理可讀的內容。
Most portfolios are a page you scroll. I wanted mine to be a place you visit.
So I built Hotel Komaiha: a 1930s art-deco grand hotel at night where every floor is part of my résumé, and you play the bellhop.
👉 Try it: https://hotel-komaiha.vercel.app
The tour
- The Grand Lobby: reception (about me), the concierge desk (contact), world clocks, a split-flap departures board and a mirror-marble floor. The bellhop can also sit down and play the grand piano.
- The Ballroom: my career timeline as hologram pedestals, with couples dancing between them.
- The Suites: one room per project.
- The Stack Café: every skill is a labelled jar or tea tin on the shelves.
- The Sky Lounge: an infinity pool, a giant neon sign and a fireworks plunger.
- Outside: a drivable 1930s town car, traffic, traffic lights and a city square.
- And a basement you'll have to find five golden keys to open.
IMAGE: 02-grand-lobby.jpg
If you'd rather not walk, there's a guided tour, a directory that jumps anywhere, and a plain paper résumé one click away.
IMAGE: 10-ui-concierge-panel.jpg
Everything is generated in code
There are no 3D models, image textures or audio files. Geometry is built from Three.js primitives, the marble, wood, carpets, wallpaper and city windows are painted on canvases at load time, and the lounge music is synthesized live with the Web Audio API. The whole hotel downloads as JavaScript.
That kept the download small, but it made performance the real work.
Performance: what actually mattered
1. Merging static meshes by material. A procedural chair is a dozen meshes, and a lobby full of furniture was close to 2,000 draw calls. I wrote a small mergeStatic() that walks a subtree and joins every static mesh sharing a material, shadow flags, render order and layers into one:
// Meshes can only be joined if their geometries carry the same attributes, stored the same way.
function signature(geo) {
if (Object.keys(geo.morphAttributes).length || geo.drawRange.start !== 0 || geo.drawRange.count !== Infinity) return null;
const parts = [geo.index ? 'i' : 'n'];
for (const name of Object.keys(geo.attributes).sort()) {
const a = geo.attributes[name];
if (a.isInterleavedBufferAttribute) return null;
parts.push(`${name}:${a.itemSize}:${a.array.constructor.name}:${a.normalized}`);
}
return parts.join('|');
}
Parts that move, toggle or get raycast on their own (doors, wheels, the café jars) are left out. The lobby went from 1,906 to 1,045 draw calls, and the rooftop from 673 to 303.
2. Animating traffic in the vertex shader. Moving instanced cars by re-uploading instance matrices every frame stalled some GPUs. Instead, the instances never change: each frame only a small uniform array (x, z, heading, wheel spin per car) is updated, and the shader places every body and wheel:
uniform vec4 uCars[N];
attribute vec2 aCar;
vec3 carXform(vec3 p, bool isPos) {
vec4 car = uCars[int(aCar.x + 0.5)];
float ch = cos(car.z), sh = sin(car.z);
p = vec3(p.x * ch + p.z * sh, p.y, -p.x * sh + p.z * ch);
if (isPos) p += vec3(car.x, 0.0, car.y);
return p;
}
It's patched into the standard materials with onBeforeCompile, so the cars keep full PBR lighting. The traffic lights and the marquee bulbs work the same way.
3. One light layout for every floor. Every floor uses the same set of lights: 8 point lights, 6 spots and 1 shadow-casting spot. So shaders compile once, and switching floors never hitches. Each floor also gets its own PMREM reflection probe, captured at load.
4. Adaptive resolution. The scene is fill-rate bound, so render resolution adapts to hold the frame rate. One lesson: if lowering the resolution doesn't raise the frame rate, the display is capped (iOS Low Power Mode runs at 30 Hz) or the CPU is the bottleneck. In that case the hotel undoes the step instead of blurring the picture for nothing.
5. No bundler. The site is plain ES modules and an import map. The build writes every module into the map with a content hash (/js/props.js?v=e1f1014e1d), so they're cached for a year and a deploy only re-downloads what changed. Every module also gets a modulepreload link, so the browser fetches the whole graph in parallel instead of one import level at a time.
IMAGE: 07-rooftop-fireworks.jpg
Five languages, including Arabic
The hotel is in English, French, Arabic, Spanish and German, and each language has its own pre-rendered URL. Arabic was the interesting one: the signs are painted on canvases, and canvas text doesn't know about right-to-left. A small patch on CanvasRenderingContext2D handles any Arabic string: it sets the direction to RTL, removes the letter spacing that would break joined letters apart, and drops italics, which Arabic type doesn't have.
proto[name] = function (text, ...rest) {
if (!ARABIC.test(text)) return original.call(this, text, ...rest);
const dir = this.direction, spacing = this.letterSpacing, font = this.font;
this.direction = 'rtl';
if (spacing !== undefined) this.letterSpacing = '0px';
if (/\bitalic\b/.test(font)) this.font = font.replace(/\bitalic\s+/, '');
try { return original.call(this, text, ...rest); } finally { /* restore */ }
};
A WebGL page is invisible to search engines
A 3D homepage has almost no text for crawlers or AI assistants, so the same facts also live in plain HTML: a services page and a printable résumé in every language, both with JSON-LD, plus Markdown twins and an llms.txt for AI assistants. They're all generated from the same data as the hotel, so they can't drift apart.
Testing a 3D site
CI boots the hotel in English, French and Arabic in headless Chromium with software WebGL, checks in, opens panels and fails on any console error. After each deploy, a second job checks that every live file matches the commit byte for byte.
IMAGE: 01-street-in-the-rain.jpg
What I'd tell anyone building something like this
- Count draw calls early:
renderer.infois your friend. - Upload less to the GPU per frame: uniforms beat buffers.
- Give crawlers and impatient visitors a plain-text path from day one.
- Small details like the rain, a cat called Null and a Konami code are what people remember and share.
I'd love feedback, especially on how it runs on your machine. And if you need a full-stack developer for a web or mobile project (Flutter, React/Next.js, NestJS), the concierge desk is in the lobby. 🛎️
來源:DEV Community · dev.to