Frontend Development 18 min read

Creating a Winter Olympics 3D Scene with Three.js and React

This article demonstrates how to build an interactive 3D Winter Olympics themed webpage using Three.js and React, covering resource import, scene setup, lighting, model loading, custom materials, particle effects, camera controls, and animation techniques.

Sohu Tech Products
Sohu Tech Products
Sohu Tech Products
Creating a Winter Olympics 3D Scene with Three.js and React

Background

The article introduces a festive 3D page for the 2022 Winter Olympics, built with Three.js + React . It showcases the use of various Three.js components such as TorusGeometry , MeshLambertMaterial , MeshDepthMaterial , custom materials, Points , and PointsMaterial to create rings, the mascot Bing Dwen Dwen, flags, trees, and snowfall.

Effect

The final effect includes the Olympic mascot, the five rings, waving flags, trees, and falling snow. Users can hold the left mouse button to rotate the camera and view the scene from different angles.

Implementation

Importing Resources

import React from 'react';
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
import { TWEEN } from "three/examples/jsm/libs/tween.module.min.js";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader";
import bingdundunModel from './models/bingdundun.glb';
// ...

Page DOM Structure

<div>
  <div id="container"></div>
  {this.state.loadingProcess === 100 ? '' : (
    <div className="olympic_loading">
      <div className="box">{this.state.loadingProcess} %</div>
    </div>
  )}
</div>

Scene Initialization

container = document.getElementById('container');
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
container.appendChild(renderer.domElement);
scene = new THREE.Scene();
scene.background = new THREE.TextureLoader().load(skyTexture);
camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 30, 100);
camera.lookAt(new THREE.Vector3(0, 0, 0));

Adding Lights

const light = new THREE.DirectionalLight(0xffffff, 1);
light.intensity = 1;
light.position.set(16, 16, 8);
light.castShadow = true;
light.shadow.mapSize.width = 512 * 12;
light.shadow.mapSize.height = 512 * 12;
light.shadow.camera.top = 40;
light.shadow.camera.bottom = -40;
light.shadow.camera.left = -40;
light.shadow.camera.right = 40;
scene.add(light);

const ambientLight = new THREE.AmbientLight(0xcfffff);
ambientLight.intensity = 1;
scene.add(ambientLight);

Loading Progress Management

const manager = new THREE.LoadingManager();
manager.onStart = (url, loaded, total) => {};
manager.onLoad = () => { console.log('Loading complete!') };
manager.onProgress = (url, loaded, total) => {
  if (Math.floor(loaded / total * 100) === 100) {
    this.setState({ loadingProcess: Math.floor(loaded / total * 100) });
    // camera tween animation
    Animations.animateCamera(camera, controls, { x: 0, y: -1, z: 20 }, { x: 0, y: 0, z: 0 }, 3600, () => {});
  } else {
    this.setState({ loadingProcess: Math.floor(loaded / total * 100) });
  }
};

Creating the Ground

var loader = new THREE.GLTFLoader(manager);
loader.load(landModel, function (mesh) {
  mesh.scene.traverse(function (child) {
    if (child.isMesh) {
      child.material.metalness = .1;
      child.material.roughness = .8;
      if (child.name === 'Mesh_2') {
        child.material.metalness = .5;
        child.receiveShadow = true;
      }
    }
  });
  mesh.scene.rotation.y = Math.PI / 4;
  mesh.scene.position.set(15, -20, 0);
  mesh.scene.scale.set(.9, .9, .9);
  land = mesh.scene;
  scene.add(land);
});

Creating the Mascot Bing Dwen Dwen

loader.load(bingdundunModel, mesh => {
  mesh.scene.traverse(child => {
    if (child.isMesh) {
      if (child.name === 'oldtiger001') {
        child.material.metalness = .5;
        child.material.roughness = .8;
      }
      if (child.name === 'oldtiger002') {
        child.material.transparent = true;
        child.material.opacity = .5;
        child.material.metalness = .2;
        child.material.roughness = 0;
        child.material.refractionRatio = 1;
        child.castShadow = true;
      }
    }
  });
  mesh.scene.rotation.y = Math.PI / 24;
  mesh.scene.position.set(-8, -12, 0);
  mesh.scene.scale.set(24, 24, 24);
  scene.add(mesh.scene);
});

Creating the Olympic Rings

const fiveCycles = [
  { key: 'cycle_0', color: 0x0885c2, position: { x: -250, y: 0, z: 0 } },
  { key: 'cycle_1', color: 0x000000, position: { x: -10, y: 0, z: 5 } },
  { key: 'cycle_2', color: 0xed334e, position: { x: 230, y: 0, z: 0 } },
  { key: 'cycle_3', color: 0xfbb132, position: { x: -125, y: -100, z: -5 } },
  { key: 'cycle_4', color: 0x1c8b3c, position: { x: 115, y: -100, z: 10 } }
];
fiveCycles.map(item => {
  let cycleMesh = new THREE.Mesh(new THREE.TorusGeometry(100, 10, 10, 50), new THREE.MeshLambertMaterial({
    color: new THREE.Color(item.color),
    side: THREE.DoubleSide
  }));
  cycleMesh.castShadow = true;
  cycleMesh.position.set(item.position.x, item.position.y, item.position.z);
  meshes.push(cycleMesh);
  fiveCyclesGroup.add(cycleMesh);
});
fiveCyclesGroup.scale.set(.036, .036, .036);
fiveCyclesGroup.position.set(0, 10, -8);
scene.add(fiveCyclesGroup);

Creating the Flag

loader.load(flagModel, mesh => {
  mesh.scene.traverse(child => {
    if (child.isMesh) {
      child.castShadow = true;
      if (child.name === 'mesh_0001') {
        child.material.metalness = .1;
        child.material.roughness = .1;
        child.material.map = new THREE.TextureLoader().load(flagTexture);
      }
      if (child.name === '柱体') {
        child.material.metalness = .6;
        child.material.roughness = 0;
        child.material.refractionRatio = 1;
        child.material.color = new THREE.Color(0xeeeeee);
      }
    }
  });
  mesh.scene.rotation.y = Math.PI / 24;
  mesh.scene.position.set(2, -7, -1);
  mesh.scene.scale.set(4, 4, 4);
  // animation
  let meshAnimation = mesh.animations[0];
  mixer = new THREE.AnimationMixer(mesh.scene);
  let animationClip = meshAnimation;
  let clipAction = mixer.clipAction(animationClip).play();
  animationClip = clipAction.getClip();
  scene.add(mesh.scene);
});

Creating Trees

let treeMaterial = new THREE.MeshPhysicalMaterial({
  map: new THREE.TextureLoader().load(treeTexture),
  transparent: true,
  side: THREE.DoubleSide,
  metalness: .2,
  roughness: .8,
  depthTest: true,
  depthWrite: false,
  skinning: false,
  fog: false,
  reflectivity: 0.1,
  refractionRatio: 0
});
let treeCustomDepthMaterial = new THREE.MeshDepthMaterial({
  depthPacking: THREE.RGBADepthPacking,
  map: new THREE.TextureLoader().load(treeTexture),
  alphaTest: 0.5
});
loader.load(treeModel, mesh => {
  mesh.scene.traverse(child => {
    if (child.isMesh) {
      child.material = treeMaterial;
      child.custromMaterial = treeCustomDepthMaterial;
    }
  });
  mesh.scene.position.set(14, -9, 0);
  mesh.scene.scale.set(16, 16, 16);
  scene.add(mesh.scene);
  // clone two more trees
  let tree2 = mesh.scene.clone();
  tree2.position.set(10, -8, -15);
  tree2.scale.set(18, 18, 18);
  scene.add(tree2);
});

Creating Snow Particles

let texture = new THREE.TextureLoader().load(snowTexture);
let geometry = new THREE.Geometry();
let range = 100;
let pointsMaterial = new THREE.PointsMaterial({
  size: 1,
  transparent: true,
  opacity: 0.8,
  map: texture,
  blending: THREE.AdditiveBlending,
  sizeAttenuation: true,
  depthTest: false
});
for (let i = 0; i < 1500; i++) {
  let vertice = new THREE.Vector3(Math.random() * range - range / 2, Math.random() * range * 1.5, Math.random() * range - range / 2);
  vertice.velocityY = 0.1 + Math.random() / 3;
  vertice.velocityX = (Math.random() - 0.5) / 3;
  geometry.vertices.push(vertice);
}
geometry.center();
points = new THREE.Points(geometry, pointsMaterial);
points.position.y = -30;
scene.add(points);

Camera Controls, Resize Adaptation, and Animation

controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 0, 0);
controls.enableDamping = true;
controls.enablePan = false;
controls.enableZoom = false;
controls.minPolarAngle = 1.4;
controls.maxPolarAngle = 1.8;
controls.minAzimuthAngle = -.6;
controls.maxAzimuthAngle = .6;

window.addEventListener('resize', () => {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
}, false);

function animate() {
  requestAnimationFrame(animate);
  renderer.render(scene, camera);
  controls && controls.update();
  // flag animation
  mixer && mixer.update(new THREE.Clock().getDelta());
  // camera tween
  TWEEN && TWEEN.update();
  // rings rotation
  fiveCyclesGroup && (fiveCyclesGroup.rotation.y += .01);
  // update particles
  points.geometry.verticesNeedUpdate = true;
  // snow animation
  let vertices = points.geometry.vertices;
  vertices.forEach(function (v) {
    v.y = v.y - v.velocityY;
    v.x = v.x - v.velocityX;
    if (v.y <= 0) v.y = 60;
    if (v.x <= -20 || v.x >= 20) v.velocityX = v.velocityX * -1;
  });
}

Summary

The tutorial covers the complete workflow of building an interactive 3D Winter Olympics webpage, from setting up the React component and loading external assets, to configuring the Three.js scene, lights, custom shaders, particle systems, and user interaction. It provides reusable code snippets and explains key concepts such as TorusGeometry , MeshLambertMaterial , MeshDepthMaterial , custom materials, Points particles, and camera controls, making it a valuable reference for frontend developers interested in WebGL and 3D graphics.

frontend developmentreactThree.jsWebGLWinter Olympics3D graphics
Sohu Tech Products
Written by

Sohu Tech Products

A knowledge-sharing platform for Sohu's technology products. As a leading Chinese internet brand with media, video, search, and gaming services and over 700 million users, Sohu continuously drives tech innovation and practice. We’ll share practical insights and tech news here.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.