Building an Immersive Web 3D Exhibition Hall with Three.js and Blender
The article shows how the vivo front‑end team built a lightweight, mobile‑friendly 3D exhibition hall for the Game Festival by modeling in Blender, exporting GLB files, and using Three.js to set up the scene, lighting, shadows, joystick navigation, collision detection, and performance optimizations such as texture baking and model compression.
The article, authored by the vivo Internet Front‑end Team (Wei Xing), explains how to create an immersive web‑based 3D exhibition hall using Three.js and Blender.
Why a 3D exhibition? The project was driven by the annual vivo Game Festival and the popularity of the metaverse, aiming to provide a novel, lightweight, and quickly deliverable interactive experience on mobile devices.
Technology stack: Three.js (a lightweight 3D JavaScript framework) + Blender (open‑source 3D modeling tool). Three.js was chosen over Babylon.js because of its smaller bundle size, abundant examples, and faster onboarding.
Understanding GLTF/GLB: GLTF is a JSON‑based 3D model format; GLB is its binary counterpart, offering smaller file size and faster loading. Blender can export both formats, with .glb recommended for production.
Basic scene setup (code):
import * as THREE from 'three'
// 1. Create scene
const scene = new THREE.Scene();
// 2. Create camera
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
// 3. Create renderer
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);Loading a GLTF/GLB model:
import GLTFLoader from 'GLTFLoader'
const loader = new GLTFLoader();
loader.load('path/to/gallery.glb', gltf => {
scene.add(gltf.scene); // add model to scene
});Adding lighting: An ambient light is first added to illuminate the scene, followed by a set of point and directional lights defined in an array.
const ambientLight = new THREE.AmbientLight(0xffffff, 1);
scene.add(ambientLight);
const lightOptions = [
{
type: 'point',
color: 0xfff0bf,
intensity: 0.4,
distance: 30,
decay: 2,
position: { x: 2, y: 6, z: 0 }
},
// ... other lights
];
function createLights() {
lightOptions.forEach(option => {
const light = option.type === 'point'
? new THREE.PointLight(option.color, option.intensity, option.distance, option.decay)
: new THREE.DirectionalLight(option.color, option.intensity);
light.position.set(option.position.x, option.position.y, option.position.z);
scene.add(light);
});
}
createLights();Material and environment map adjustments: Specific objects (e.g., the logo) are located by name, their material roughness set to 0 and metalness to 1, and an environment map is applied to achieve realistic reflections.
loader.load('path/to/gallery.glb', gltf => {
gltf.scene.traverse(child => {
if (isLogo(child)) {
initLogo(child); // set roughness & metalness
setEnvMap(child); // apply env map
}
});
scene.add(gltf.scene);
});
const isLogo = object => object.name === 'logo';
function initLogo(object) {
object.material.roughness = 0;
object.material.metalness = 1;
}
let envMap;
const envmaploader = new THREE.PMREMGenerator(renderer);
function setEnvMap(object) {
if (envMap) {
object.material.envMap = envMap.texture;
} else {
textureLoader.load('path/to/envMap.jpg', texture => {
texture.encoding = THREE.sRGBEncoding;
envMap = envmaploader.fromCubemap(texture);
object.material.envMap = envMap.texture;
});
}
}Shadow implementation: Enable shadow mapping on the renderer, set castShadow on lights and meshes, and receiveShadow on the floor.
const renderer = new THREE.WebGLRenderer();
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
const light = new THREE.DirectionalLight();
light.castShadow = true;
gltf.scene.traverse(child => {
if (child.isMesh) child.castShadow = true;
});
floor.receiveShadow = true;Virtual joystick control: A virtual joystick (handler) updates a move object containing turn and forward values. A transparent player box represents the avatar for collision detection.
const speed = 8;
const turnSpeed = 3;
const move = { turn: 0, forward: 0 };
const handler = new Handler();
handler.onTouchMove = () => { /* update move.turn and move.forward */ };
function createPlayer() {
const box = new THREE.BoxGeometry(1.2, 2, 1);
const mat = new THREE.MeshBasicMaterial({ color: 0x000000, wireframe: true });
const mesh = new THREE.Mesh(box, mat);
box.translate(0, 1, 0);
return mesh;
}
const player = createPlayer();
player.position.set(4.5, 2, 12);Update loop: Each frame calculates delta time, updates the player position based on joystick input, synchronizes the camera, and renders the scene.
const clock = THREE.clock();
function render() {
const dt = clock.delta();
updatePlayer(dt);
updateCamera(dt);
renderer.render(scene, camera);
requestAnimationFrame(render);
}Collision detection: Uses THREE.Raycaster to detect obstacles (colliders) collected from the loaded model. Movement is blocked when the ray distance is less than 2.5 units.
const colliders = [];
loader.load('path/to/gallery.glb', gltf => {
gltf.scene.traverse(child => {
if (isMesh(child)) colliders.push(child);
});
});
function updatePlayer(dt) {
const pos = player.position.clone();
pos.y -= 1.5;
const dir = new THREE.Vector3();
player.getWorldDirection(dir);
dir.negate();
if (move.forward < 0) dir.negate();
const raycaster = new THREE.Raycaster(pos, dir);
let blocked = false;
if (colliders.length) {
const intersect = raycaster.intersectObjects(colliders);
if (intersect.length && intersect[0].distance < 2.5) blocked = true;
}
if (!blocked && move.forward !== 0) {
player.translateZ(move.forward > 0 ? -dt * speed : dt * speed * 0.5);
}
if (move.turn !== 0) player.rotateY(move.turn * 1.2 * dt);
}Performance optimizations:
Texture baking: Pre‑render lighting and shadows into textures using Blender, then replace many real‑time lights with a few static ones.
Model size reduction: Prefer .glb over .gltf, separate textures from geometry, compress models with Draco/gltfpack, and compress textures. The original 23 MB model was reduced to ~1.2 MB, cutting load time from ~9 s to under 3 s.
Additional recommendations: Keep naming consistent in Blender, avoid shared material side‑effects, add loading progress UI, watch for Three.js API changes, limit emissive materials, consider Babylon.js for smoother camera movement, and be aware of browser support for video textures.
References to various tutorials, articles, and GitHub repositories are provided at the end of the article.
vivo Internet Technology
Sharing practical vivo Internet technology insights and salon events, plus the latest industry news and hot conferences.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.