跪拜 Guibai
← Back to the summary

A 3D Corvette C8 Showroom in the Browser, Built with Three.js and Vue


theme: vuepress highlight: a11y-dark

Foreword

I've been researching Three.js lately and immediately thought of giving my buddies a treat.

Kapture 2026-08-27 at 22.29.52.gif

I tested it for my buddies — the car is good, and it drives very smoothly. Of course, don't rush to pick it up; park it in the browser first.

How to drive a sports car before 40?

Did you have a sports car dream as a kid? Especially after watching Fast & Furious, it's even easier to get hooked on American muscle.

image.png

Even if you might not get to drive a sports car in real life, there's a second path: build a 3D sports car with Three.js, at least get it arranged in the virtual world first.

This article is suitable for those just getting started with Three.js. We won't discuss overly complex engine architecture; instead, we'll get a complete 3D car showcase case running and break down these questions clearly:

  1. Where does the 3D model come from;
  2. How to load the model into the browser;
  3. What are the responsibilities of the scene, camera, lights, and renderer;
  4. Why you can't just change a single color for the car paint;
  5. Why you must clean up Three.js resources when a Vue component unmounts.

Model Source

Before using Three.js to render a 3D product, you need a model first. Three.js is not a tool that "generates a car out of thin air"; it's more like an engine that renders 3D models into the browser.

Frontend developers usually don't model themselves; they use .glb / .gltf models provided by designers, 3D modelers, or asset platforms. This case uses:

Animated Chevrolet C8 Model - Sketchfab

This model is licensed under CC Attribution, requiring author attribution when used. The current project has placed the runtime GLB into the models folder of this case, keeping the model and page code together for easier viewing and migration later.

Page Structure

The DOM structure of this case is very simple: the 3D visuals are handed to showcase-canvas, while the title, parameters, and color swatches are still written with regular DOM.

<template>
  <main class="car-showcase-page">
    <div ref="sceneHostRef" class="showcase-canvas"></div>

    <section class="showcase-copy" aria-label="Product Info">
      <p>Animated Sports Car Showcase</p>
      <h2>Chevrolet Corvette C8</h2>
      <dl>
        <div>
          <dt>Engine</dt>
          <dd>LT2 V8</dd>
        </div>
        <div>
          <dt>Power</dt>
          <dd>495hp</dd>
        </div>
        <div>
          <dt>Type</dt>
          <dd>Mid-engine</dd>
        </div>
      </dl>
      <span v-if="modelNote" class="model-note">
        {{ modelNote }}
      </span>
    </section>

    <section class="paint-panel" aria-label="Car Paint Color">
      <button
        v-for="paint in paintOptions"
        :key="paint.name"
        type="button"
        class="paint-swatch"
        :class="{ 'paint-swatch--active': activePaint === paint.name }"
        :style="{ backgroundColor: paint.color }"
        :aria-label="paint.label"
        :aria-pressed="activePaint === paint.name"
        @click="applyPaint(paint)"
      ></button>
    </section>
  </main>
</template>

The benefit of this approach: Three.js is only responsible for drawing the car, while Vue still handles the page structure and interaction state. The code boundaries become much clearer.

Page Height

The current case runs inside the blog's demo preview page, where the outer layer already has a site Header, demo Header, and Footer, so the component root node should not write min-height: 100vh again.

In the latest code, the car page inherits the height of the middle stage, and the component internally hides overflow:

.car-showcase-page {
  position: relative;
  height: 100%;
  min-height: 0;
  overflow: hidden;
  background: #090d12;
  color: #f8fafc;
}

.showcase-canvas {
  position: absolute;
  inset: 0;
}

showcase-canvas covers the entire component, and the canvas generated by Three.js will be mounted into it. The car model copy and color swatches on top are overlaid on the 3D visuals using position.

Model Path

The code uses a model address:

const corvetteModelUrl = new URL('./models/chevrolet-corvette-c8.glb', import.meta.url).href

corvetteModelUrl points to models/chevrolet-corvette-c8.glb next to the current component.

Why place it in the current case directory?

Because this model only serves the car-basic case, not a site-wide public asset. Placing it in the current case directory means Vite will treat it as a module resource, automatically generating the correct asset address during the final build.

The syntax is:

new URL('./models/chevrolet-corvette-c8.glb', import.meta.url)

The benefit of this syntax is that the path follows the file. If you later move the entire car-basic folder elsewhere, the model path is unlikely to break.

The directory structure is roughly:

car-basic/
├─ index.md
├─ index.vue
├─ render-flow.svg
└─ models/
   └─ chevrolet-corvette-c8.glb

Loading the Model

The basic version directly uses GLTFLoader to load the C8 model:

const loadCarModel = async () => {
  try {
    const loader = new GLTFLoader()
    const gltf = await loader.loadAsync(corvetteModelUrl)
    const model = gltf.scene

    if (!scene) {
      disposeObject(model)
      return
    }

    applyInitialAnimationPose(model, gltf.animations)
    prepareCarModel(model)
    fitModelToStage(model)

    carGroup = new THREE.Group()
    carGroup.add(model)
    carGroup.rotation.y = -0.52
    scene.add(carGroup)
  } catch (error) {
    modelNote.value = 'Model loading failed, please check the model file path.'
    console.error('Failed to load car model:', error)
  }
}

After loading successfully, it doesn't immediately throw it into the scene; instead, it does a few processing steps first:

  1. applyInitialAnimationPose: pushes the model's built-in animations to their closed state;
  2. prepareCarModel: hides the model base, enables shadows, replaces the car body material;
  3. fitModelToStage: scales, centers, and grounds it;
  4. carGroup.add(model): wraps the entire car with an outer Group, so later auto-rotation only rotates this Group.

If the model fails to load, a message is written to modelNote, and an error message appears on the page.

Creating the Scene

The three most core objects in Three.js are:

scene = new THREE.Scene()
camera = new THREE.PerspectiveCamera(25, width / height, 0.1, 100)
renderer = new THREE.WebGLRenderer({ antialias: true })

You can understand them like this:

  1. scene is the 3D world;
  2. camera is the eye observing this world;
  3. renderer is the renderer that draws the 3D world onto a canvas.

QQ_1787841429038.png

This diagram can be understood in one sentence: the model, lights, stage, and environment are all placed into scene; camera decides where to look from; renderer.render(scene, camera) is responsible for drawing this moment's 3D world onto the canvas on the page.

After initialization is complete, mount the canvas into Vue's container:

host.appendChild(renderer.domElement)

Color Space and Tone Mapping

There are a few critical lines in the code:

renderer.outputColorSpace = THREE.SRGBColorSpace
renderer.toneMapping = THREE.ACESFilmicToneMapping
renderer.toneMappingExposure = 0.6

outputColorSpace affects how colors appear in the browser. If not handled, model colors might look grayish or inaccurate.

toneMapping can be understood as mapping the highlights and shadows from 3D rendering into more visually pleasing colors on screen. Car showcases rely heavily on highlights, so ACESFilmicToneMapping is used here.

toneMappingExposure is the exposure value. Too high will blow out the car paint highlights to white, too low will make it look flat, so it's set rather conservatively here.

Environment Reflection

Whether car paint looks realistic largely depends on reflections.

The basic version uses RoomEnvironment to generate an environment map:

const pmremGenerator = new THREE.PMREMGenerator(renderer)
environmentMap = pmremGenerator.fromScene(new RoomEnvironment(), 0.04)
scene.environment = environmentMap.texture
pmremGenerator.dispose()

scene.environment can be understood as "the reflection source of the surrounding environment."

Materials like car paint, glass, and metal all get reflection information from the environment map. Without it, the car body would look very flat, like an ordinary colored model.

Lighting

Several different types of lights are used in the basic version:

const keyLight = new THREE.DirectionalLight('#ffffff', 1.45)
const fillLight = new THREE.DirectionalLight('#f8fafc', 0.28)
const rimLight = new THREE.PointLight('#ffffff', 14, 12)
const ambientLight = new THREE.HemisphereLight('#f8fafc', '#020617', 0.5)

scene.add(keyLight, fillLight, rimLight, ambientLight)

You can think of it like a photography studio:

  1. keyLight is the main light, responsible for creating the primary lit surfaces;
  2. fillLight is the fill light, preventing shadows from going completely black;
  3. rimLight is the rim light, separating the car from the dark background;
  4. HemisphereLight is the ambient base light, giving the whole scene some basic brightness.

Additionally, two rectangular soft lights were added:

RectAreaLightUniformsLib.init()

const frontSoftbox = new THREE.RectAreaLight('#ffffff', 0.95, 4.6, 1.5)
const sideSoftbox = new THREE.RectAreaLight('#dbeafe', 0.45, 3.8, 1.4)
scene?.add(frontSoftbox, sideSoftbox)

RectAreaLight is more like a softbox in a photo studio, suitable for producing large, soft highlights on the car paint. RectAreaLightUniformsLib.init() is needed to initialize the shader support it requires.

Stage and Initial Camera

The basic version now only keeps grid reference lines:

const grid = new THREE.GridHelper(18, 36, '#475569', '#1f2937')
grid.position.y = 0.018
scene?.add(grid)

GridHelper draws lines, not a solid floor. This preserves spatial orientation without creating a large black plane under the car.

Previously, there was a white ring outside, essentially a circle drawn with TorusGeometry:

new THREE.TorusGeometry(3.45, 0.018, 12, 128)

This ring had a strong visual presence and would steal attention from the car itself, so both the basic and interactive versions have removed it, keeping only the grid.

The initial camera has also been moved slightly closer:

camera.position.set(5.15, 2.55, 4.85)
controls.target.set(0, 0.82, 0)
controls.maxPolarAngle = Math.PI / 2.05

camera.position.set(x, y, z) can be understood as placing the camera at a specific position in 3D space.

This set of values means: the camera is to the front-right and above the car, looking near the center of the car body. Compared to a farther camera, the car's initial state appears larger, more like a product showcase page.

controls.maxPolarAngle is used to limit the maximum downward angle the camera can rotate. Here it's slightly less than Math.PI / 2, aiming to prevent the user from dragging the camera below the ground.

Animation Initial Frame

This C8 model comes with built-in animations, and the final frame is the closed state. To prevent the doors and hood from being open when the page first opens, the basic version pushes all animations directly to their final frame:

const applyInitialAnimationPose = (model: THREE.Object3D, animations: THREE.AnimationClip[]) => {
  if (!animations.length) {
    return
  }

  const mixer = new THREE.AnimationMixer(model)
  let closeTime = 0

  animations.forEach((clip) => {
    const action = mixer.clipAction(clip)
    closeTime = Math.max(closeTime, clip.duration)
    action.setLoop(THREE.LoopOnce, 1)
    action.clampWhenFinished = true
    action.play()
  })

  mixer.setTime(closeTime)
}

Here, animations comes from gltf.animations, which are the animation clips included when the model was exported. If a model has no animations, this is an empty array.

Model Adaptation to the Stage

3D models from different sources vary greatly in size; some are modeled in meters, some in centimeters, and the origin point isn't necessarily at the center of the car body.

So after loading the model, three things need to be done uniformly:

const box = new THREE.Box3().setFromObject(model)
const size = box.getSize(new THREE.Vector3())
const maxSize = Math.max(size.x, size.y, size.z)

model.scale.setScalar(5.4 / maxSize)

Step one: use Box3 to get the model's bounding box.

Step two: scale the model to an appropriate size based on its longest side.

Step three: center it and ground it:

const centeredBox = new THREE.Box3().setFromObject(model)
const center = centeredBox.getCenter(new THREE.Vector3())
model.position.sub(center)

const finalBox = new THREE.Box3().setFromObject(model)
model.position.y -= finalBox.min.y

This way, regardless of the original model's size, it can be placed relatively stably into the current stage.

Car Paint Configuration

The current car paint data has been extracted to a shared directory:

import { paintOptions, type PaintOption } from '../../../content-data/animation.ts'

paintOptions contains not only color values but also parameters controlling PBR texture:

export const paintOptions: PaintOption[] = [
  {
    name: 'corvette-red',
    label: 'Corvette Red',
    color: '#8f1418',
    metalness: 0.16,
    roughness: 0.22,
    clearcoatRoughness: 0.03,
    reflectivity: 0.68,
    envMapIntensity: 1.36,
    pearl: 0.04,
  },
]

Red is deliberately placed first here, and activePaint defaults to corvette-red:

const activePaint = ref('corvette-red')

This way, when the page opens, the default car paint corresponds to the first color swatch.

Car Paint Material

Car paint cannot simply be covered with a solid color.

If you write it directly like this:

new THREE.MeshPhysicalMaterial({
  color: '#9b171b',
})

The car body easily ends up looking like a solid block of plastic.

A better approach is to create a new material based on the original model material and try to preserve the original textures:

const createBodyMaterial = (sourceMaterial: THREE.Material, paint: PaintOption) => {
  const source = sourceMaterial instanceof THREE.MeshStandardMaterial ? sourceMaterial : null
  const material = new THREE.MeshPhysicalMaterial({
    color: paint.color,
    map: source?.map || null,
    metalnessMap: source?.metalnessMap || null,
    roughnessMap: source?.roughnessMap || null,
    normalMap: source?.normalMap || null,
    aoMap: source?.aoMap || null,
    transparent: false,
    opacity: 1,
  })

  material.name = 'c8-showcase-paint'
  applyCarPaintToMaterial(material, paint)
  return material
}

These parameters can be briefly understood as:

  1. metalness: metallic feel;
  2. roughness: roughness;
  3. clearcoat: clear coat layer;
  4. clearcoatRoughness: clear coat layer roughness;
  5. envMapIntensity: environment reflection intensity;
  6. iridescence: slight pearlescent/iridescent effect.

Automotive paint is neither pure metal nor pure plastic, but a base color covered with a layer of clear coat. Therefore, clearcoat is very important for the texture of car paint.

How to Identify the Car Body?

After printing mesh.name and material?.name, you can see that the material names in this C8 model that truly need paint changes are mainly Body_Color and Painted_Black.

So there's no need to guess using keywords like paint/body/exterior here; when traversing Meshes, just look at the current Mesh's material name:

const isPaintMesh = (mesh: THREE.Mesh) => {
  const material = getFirstMaterial(mesh.material)
  const materialName = (material?.name || '').toLowerCase().replace(/\.\d+$/, '')
  return materialName === 'body_color' || materialName === 'painted_black'
}

This way, nodes like glass, tires, and brake discs naturally won't match, making the judgment more stable than keyword exclusion.

Switching Car Paint

After clicking a color swatch, the model is not reloaded; only the currently shared body material is updated:

const applyPaint = (paint: PaintOption) => {
  activePaint.value = paint.name

  if (bodyMaterial) {
    applyCarPaintToMaterial(bodyMaterial, paint)
  }
}

Here, the model is not reloaded, nor is the material recreated.

Because all body Meshes share the same bodyMaterial, changing just this one material synchronously changes the entire car's body.

Note that what's updated here is not just color, but also synchronously updates:

  1. metalness;
  2. roughness;
  3. clearcoatRoughness;
  4. reflectivity;
  5. envMapIntensity;
  6. iridescence.

This way, different colors can have different textures. For example, red is brighter with more clear coat reflection; white is more restrained to avoid looking overexposed.

Render Loop

Three.js animation relies on requestAnimationFrame:

const renderScene = () => {
  if (!renderer || !scene || !camera) {
    return
  }

  carGroup?.rotateY(0.002)
  controls?.update()
  renderer.render(scene, camera)
  animationFrameId = window.requestAnimationFrame(renderScene)
}

Three things are done each frame:

  1. Slightly auto-rotate the entire car;
  2. Update the damping of OrbitControls;
  3. Render the current frame.

After controls.enableDamping = true, controls.update() must be called every frame, otherwise the drag easing won't take effect.

Size Changes

After the window or container size changes, the camera and renderer must be updated synchronously:

const handleResize = () => {
  if (!camera || !renderer) {
    return
  }

  const { width, height } = getHostSize()
  camera.aspect = width / height
  camera.updateProjectionMatrix()
  renderer.setSize(width, height)
}

If only the canvas width and height are updated without updating camera.aspect and the projection matrix, the image may be stretched.

Resource Cleanup

Three.js resources are not automatically released when a Vue component is destroyed.

So cleanup is needed on unmount:

const disposeScene = () => {
  window.cancelAnimationFrame(animationFrameId)
  window.removeEventListener('resize', handleResize)
  controls?.dispose()

  if (scene) {
    disposeObject(scene)
  }

  environmentMap?.dispose()
  renderer?.dispose()
  renderer?.domElement.remove()
}

Where disposeObject traverses the Meshes in the scene, releasing geometries and materials:

object.traverse((child) => {
  if (!(child instanceof THREE.Mesh)) {
    return
  }

  child.geometry.dispose()
  disposeMaterial(child.material, disposedMaterials)
})

In SPA projects, this step is very important. Otherwise, after switching routes back and forth, WebGL resources might remain in memory.

Final Words

With the basic version done to this point, a Corvette C8 that can rotate, switch car paint, and has lighting and environment reflections is right here.

GitHub Source: car-interactive