---
name: 3d-model-viewer
description: Use when you want "Premium, commercial, immersive" - Displays interactive 3D objects in the browser.
---

# 3D Model Viewer

> **Category:** Advanced WebGL / Canvas  -  **Personality:** Premium, commercial, immersive
>
> **Best use:** E-commerce, product demos
>
> **Ratings:** Professional 5/5 - Casual 3/5 - Exotic 4/5 - Visual impact 5/5 - Difficulty 5/5 - Performance cost 5/5

## What it is

A real-time 3D object the visitor can orbit, zoom and re-configure right in the page — the workhorse behind "view in 3D" product pages. This demo renders a **procedural** brilliant-cut gemstone (a lathed profile) and a torus-knot, shaded with a physically-based `MeshPhysicalMaterial` (transmission for gems, metalness for the gold finish), lit by a procedurally-generated studio environment so reflections and refraction look real **without shipping any `.glb`, `.hdr` or texture file**. Reach for it when a still photo can't sell the object: jewellery, watches, sneakers, hardware, packaging.

## Dependencies / CDN

[three.js](https://threejs.org/) r160, loaded as ES modules via an **import map** so the core and the `examples/jsm` add-ons (OrbitControls, RoomEnvironment) resolve to one pinned copy. SRI is supplied through the import-map `integrity` field (enforced in modern Chromium; safely ignored elsewhere). No build step.

```html
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>

<script type="importmap">
{
  "imports": {
    "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.min.js",
    "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
  },
  "integrity": {
    "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.min.js": "sha384-8/hf5tabFfIo0BPku31TKUvGIKpWGYaMwNtlSNqNyZzXgZCMd4lnOGWbJElML9dv",
    "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/controls/OrbitControls.js": "sha384-qlO/ZugKPxAQUAvTlQoo0QECzxJIJySZmCF/DHdb2Xn/hHndFwX/vfUAC9Hbk6LP",
    "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/environments/RoomEnvironment.js": "sha384-ZGqh+dJjt6z9NHsWYYt+TPDFj8y+2If7naI6a6AkkyNZyH51CVS3HTWFh0njp4k6"
  }
}
</script>
```

Fonts (optional): Cormorant Garamond (display) + Space Mono (UI/spec readouts).

## HTML

A framed stage: a single `<canvas>`, a loading overlay, a CSS-only fallback for no-WebGL, and the product controls beside it.

```html
<div class="stage">
  <div class="viewer">
    <canvas id="mv-canvas" aria-label="Interactive 3D gemstone — drag to rotate"></canvas>
    <div class="overlay loading" id="mv-loading">Loading 3D viewer…</div>
    <div class="overlay fallback" id="mv-fallback" hidden>
      <div class="fallgem" aria-hidden="true"></div>      <!-- pure-CSS gem -->
      <p>3D preview unavailable — your browser/GPU can’t render WebGL.</p>
    </div>
  </div>

  <aside class="panel">
    <h2>The <em>Aurora</em> Brilliant</h2>

    <!-- material/colour control -->
    <div class="swatches" id="mv-swatches" role="radiogroup" aria-label="Stone &amp; finish">
      <button class="sw is-on" role="radio" aria-checked="true" aria-label="Sapphire"
        data-color="#2f56e6" data-ior="1.77" data-rough="0.05" data-metal="0"
        data-atten="#16329e" data-attend="0.55" data-thickness="0.9"></button>
      <button class="sw" role="radio" aria-checked="false" aria-label="Champagne gold"
        data-color="#e7c578" data-ior="1.45" data-rough="0.22" data-metal="1"
        data-atten="#ffffff" data-attend="1" data-thickness="0.9"></button>
      <!-- …diamond, emerald, ruby, amethyst… -->
    </div>

    <!-- geometry control -->
    <div class="seg" id="mv-cut" role="radiogroup" aria-label="Cut">
      <button data-cut="gem"  class="is-on" role="radio" aria-checked="true">Brilliant</button>
      <button data-cut="knot"           role="radio" aria-checked="false">Knot</button>
    </div>

    <!-- auto-rotate control -->
    <button id="mv-rotate" role="switch" aria-checked="true" aria-label="Auto-rotate"></button>
  </aside>
</div>
```

## CSS

The effect itself needs almost no CSS — just a clipped, sized stage and a canvas that owns its gestures. The rest is theming.

```css
.stage  { border-radius:26px; overflow:hidden; isolation:isolate; background:#0c0b10; }
.viewer { position:relative; height:540px; }            /* the canvas reads this size */
.canvas { position:absolute; inset:0; width:100%; height:100%;
          display:block; touch-action:none; cursor:grab; }   /* touch-action:none => drag rotates, not scrolls */
.canvas:active { cursor:grabbing; }

/* overlays sit above the canvas; [hidden] must beat the flex display */
.overlay { position:absolute; inset:0; display:flex; align-items:center; justify-content:center; }
.overlay[hidden] { display:none; }

/* no-WebGL fallback: a faceted gem drawn with gradients + clip-path */
.fallgem {
  width:120px; height:120px;
  background:conic-gradient(from 28deg,#eaf2ff,#9cc0ff,#3f6ad8,#1b2d77,#3f6ad8,#9cc0ff,#eaf2ff);
  clip-path:polygon(50% 0,84% 30%,100% 42%,78% 100%,22% 100%,0 42%,16% 30%);
}

@media (prefers-reduced-motion: reduce){ .spinner{ animation:none; } }
```

## JavaScript

The whole viewer, trimmed to its essentials. Dynamic `import()` inside `try/catch` means a blocked CDN or failed integrity check degrades to the CSS fallback instead of a blank box.

```js
const fail = () => document.getElementById('mv-fallback').hidden = false;

let THREE, OrbitControls, RoomEnvironment;
try {
  THREE = await import('three');
  ({ OrbitControls }   = await import('three/addons/controls/OrbitControls.js'));
  ({ RoomEnvironment } = await import('three/addons/environments/RoomEnvironment.js'));
} catch (e) { fail(); throw e; }

const canvas = document.getElementById('mv-canvas');
const viewer = canvas.parentElement;
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;

const renderer = new THREE.WebGLRenderer({ canvas, antialias:true, alpha:true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(viewer.clientWidth, viewer.clientHeight, false);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.15;

const scene = new THREE.Scene();

// procedural studio lighting -> realistic reflections + transmission (no .hdr file)
const pmrem = new THREE.PMREMGenerator(renderer);
scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;

const camera = new THREE.PerspectiveCamera(32, viewer.clientWidth/viewer.clientHeight, 0.1, 100);
camera.position.set(0.2, 0.5, 5.4);

// extra direct lights for crisp facet sparkle on top of the soft IBL
const key = new THREE.DirectionalLight(0xffffff, 2.4); key.position.set(4, 6, 5); scene.add(key);

// one PBR material — transmission for gems, metalness for metal
const material = new THREE.MeshPhysicalMaterial({
  color:0x2f56e6, metalness:0, roughness:0.05,
  transmission:1, ior:1.77, thickness:0.9,
  attenuationColor:new THREE.Color(0x16329e), attenuationDistance:0.55,
  envMapIntensity:1.25, flatShading:true
});

// procedural geometry: a lathed brilliant-cut gem (12 segments + flatShading = facets)
function fit(g){ g.center(); g.computeBoundingSphere(); const s = 1.18 / g.boundingSphere.radius; g.scale(s, s, s); return g; }
function gemGeometry(){
  const profile = [[0.001,0.62],[0.52,0.62],[1.0,0.20],[0.96,0.10],[0.0,-0.95]]
    .map(p => new THREE.Vector2(p[0], p[1]));
  return fit(new THREE.LatheGeometry(profile, 12));
}
const mesh = new THREE.Mesh(gemGeometry(), material);
scene.add(mesh);

const controls = new OrbitControls(camera, canvas);
controls.enableDamping = true;
controls.enablePan = false;
controls.minDistance = 3.6; controls.maxDistance = 9;
controls.autoRotate = !reduce;          // reduced-motion: no auto-spin (still draggable)

// on-demand loop: render only when the camera actually moved (or a control changed)
let dirty = true;
(function loop(){
  requestAnimationFrame(loop);
  if (controls.update() || dirty){ renderer.render(scene, camera); dirty = false; }
})();

// material/colour swatches drive the PBR material
document.getElementById('mv-swatches').addEventListener('click', e => {
  const b = e.target.closest('.sw'); if(!b) return;
  const d = b.dataset, metal = d.metal === '1';
  material.color.set(d.color);
  material.metalness = metal ? 1 : 0;
  material.transmission = metal ? 0 : 1;
  material.ior = +d.ior; material.roughness = +d.rough; material.thickness = +d.thickness;
  material.attenuationColor.set(d.atten);
  material.attenuationDistance = metal ? Infinity : +d.attend;
  material.needsUpdate = true;          // toggling transmission/flatShading recompiles the shader
  dirty = true;
});
```

## How it works

- **OrbitControls** maps pointer/touch drag to spherical camera rotation about a target, with momentum (`enableDamping`) and an optional `autoRotate`. It owns the gestures, so the canvas sets `touch-action:none`.
- **`MeshPhysicalMaterial` + `transmission`** is what makes the gem read as glass: light refracts *through* the body (bent by `ior`), tinted by Beer–Lambert absorption (`attenuationColor` over `thickness`/`attenuationDistance`). Switch `transmission:0; metalness:1` and the same mesh becomes polished gold.
- **`RoomEnvironment` → `PMREMGenerator`** renders a small lit room to a pre-filtered cube map used as `scene.environment`. That single texture supplies both the image-based lighting and the reflections/refraction the PBR material samples — no HDR download, no lighting rig to hand-place.
- **Procedural geometry** keeps the page asset-free: the gem is a `LatheGeometry` revolved from a 5-point silhouette; `flatShading` turns each lathe segment into a crisp facet. The knot is a `TorusKnotGeometry`.
- **On-demand rendering** — `controls.update()` returns `true` only when the camera changed, so the GPU sleeps when the object is idle and nobody is dragging.

## Customization

- **Swap the model:** any `BufferGeometry` works — `IcosahedronGeometry(r,0)` (low-poly crystal), `TorusKnotGeometry`, or a loaded `GLTFLoader` model. Re-center/normalise scale with the `fit()` helper.
- **Re-tint instantly:** drive `material.color`, `ior`, `roughness`, `attenuationColor` from your own swatches (as above) — one material, many products.
- **Mood:** raise `toneMappingExposure` for brighter sparkle; change `RoomEnvironment` for `new THREE.PMREMGenerator(r).fromScene(myScene)` or an equirect HDR if you want a branded reflection.
- **Framing:** `camera.fov`, `camera.position`, and `controls.min/maxDistance` set how tight and how zoom-able the shot is. `controls.autoRotateSpeed` tunes the idle spin.

## Accessibility & performance

- **Reduced motion:** read `prefers-reduced-motion` and default `autoRotate` off — the object is then still fully draggable, just not self-spinning.
- **Graceful degradation:** feature-detect WebGL first; wrap the dynamic `import()`s in `try/catch`; and run a watchdog timer so a blocked/*integrity-rejected* CDN reveals the static CSS-gem fallback instead of a blank canvas.
- **Keyboard:** orbit is pointer-only, so expose every meaningful state (stone, cut, rotation) as real `<button>`/`role="switch"` controls that are focusable and operable without the 3D scene.
- **GPU budget:** cap `pixelRatio` at 2, render on demand, keep transmission to one or two objects (it forces an extra scene pass), and `dispose()` old geometries when you swap models.

## Gotchas

- **r160 add-ons are ESM-only.** There is no UMD `examples/js/OrbitControls.js` anymore — you must use the import map (or a bundler). A plain `<script src>` of `examples/jsm/...` will throw on its bare `import … from 'three'`.
- **SRI for modules ≠ `<script integrity>`.** ES modules pulled in by `import` can't carry an `integrity` attribute; use the import map's `integrity` map (Chromium 127+). Where unsupported it's ignored, never fatal — so still gate everything behind the fallback.
- **Transmission needs an environment.** With no `scene.environment`/background to refract, a transmissive material looks flat or black. It also costs an extra render pass — don't scatter dozens of them.
- **`touch-action:none` on the canvas** is required or mobile drags scroll the page instead of rotating the model; conversely, leaving zoom enabled can swallow page scroll over the canvas (here `enablePan` is off and zoom is clamped).
- **Toggling `transmission` (0↔1) or `flatShading` recompiles the shader** — set `material.needsUpdate = true` or the change won't show.
- **Size from a styled parent.** Drive `renderer.setSize` from the container's `clientWidth/Height` (via `ResizeObserver`), not `window` — the viewer is one cell of a responsive grid.
</content>
</invoke>
