Tutorials · 27 min read

How to Build a High-Quality 3D Website with Three.js Without Killing Performance

A production-minded guide to cinematic 3D, fast loading, smooth scroll animation, mobile fallbacks, and prompt-generated assets that are actually ready for the web.

By AIToolBase Editorial Team · Published · Updated

Abstract purple and black three-dimensional folded surfaces with soft lighting, evoking a rendered WebGL scene

A 3D website does not feel premium because it contains more polygons, heavier models, or a longer loading screen. It feels premium when movement supports the story, the page responds immediately, and the scene remains stable on an ordinary phone.

That balance is the difficult part. A beautiful desktop prototype can become a 12 MB download, render at 18 frames per second on mobile, delay the main heading, and ignore visitors who prefer reduced motion. The same idea can often be rebuilt with one focused scene, a smaller asset, fewer materials, compressed textures, and a render loop that only runs when something is moving.

This Three.js performance optimization guide builds that production mindset from the beginning. It uses Three.js with Vite, but the principles also apply to React Three Fiber, TresJS, Threlte, and other WebGL or WebGPU frameworks. If you have never set up a Three.js scene, start with How to Build a 3D Website with Three.js and come back once the first version works.

The goal is not to make every device hold 60 FPS at any cost. The goal is to preserve the design idea while choosing an appropriate quality level for the visitor's hardware, screen, preferences, and connection.


1. Give the 3D Scene One Clear Job

Before opening Blender or writing a shader, write one sentence that explains why the page needs depth.

Good reasons include:

  • rotating a product so a buyer can inspect its construction;
  • revealing the layers inside a machine, building, or biological object;
  • showing geographic movement on a globe;
  • turning a process into a spatial journey;
  • letting visitors explore a room, installation, or architectural concept;
  • connecting scroll progress to a meaningful transformation.

“It looks futuristic” is not a strong enough reason. Decorative 3D can still be attractive, but it should not dominate the loading budget, interaction design, or reading experience.

For a portfolio, the strongest scene is usually tied to the work itself. A product designer can separate the parts of a device. An architect can move the camera through a compact building model. A data studio can turn a real dataset into a spatial graph. These choices say something about the creator. A random astronaut or sports car usually does not.

Start with a scene brief

Use a short brief before production:

DecisionExample
PurposeShow how three services combine into one delivery system
Hero actionCamera moves through three connected layers as the introduction appears
User controlScroll controls progress; pointer adds only subtle parallax
Primary deviceMid-range mobile phone, then laptop
FallbackOptimized poster image with the same composition
Reduced motionStatic camera with simple opacity transitions
Success measureText is readable immediately; interaction stays responsive

This brief limits expensive experimentation. It also makes it easier to reject an effect that looks impressive in isolation but weakens the page.


2. Set a Performance Budget Before Building

Performance improves when it becomes a design constraint instead of a cleanup task. Set a first-pass budget, test it on real hardware, and change the budget when measurements prove that it is unrealistic.

The numbers below are sensible starting targets for a 3D marketing page, not browser standards:

AreaStarting budgetWhy it matters
Initial 3D download1–3 MB compressedVisitors should not wait for the entire scene before reading
Largest texture2048×2048 on desktop; 1024×1024 mobile when possibleTexture memory grows quickly even when the transfer file looks small
Draw callsAim below 100 on mobile and below 200 on desktopEvery material and separately rendered mesh adds CPU/GPU work
Real-time shadow lights0–1Shadow maps add extra rendering passes
Post-processing passes0–2Full-screen passes multiply pixel work
Device pixel ratioCap around 1.5–2Retina resolution can multiply the number of rendered pixels
Animation target60 FPS on capable devices; stable 30 FPS fallbackConsistency feels better than unstable quality

At 60 FPS, one frame has about 16.7 milliseconds. At 30 FPS, it has about 33.3 milliseconds. That time must cover JavaScript, style and layout work, animation updates, draw-call submission, GPU rendering, and browser compositing.

Texture memory is another common surprise. An uncompressed 4096×4096 RGBA texture requires roughly 64 MB for its base level alone. Mipmaps add more memory. The JPEG or WebP may be only 2 MB on the network, but the GPU still needs a renderable representation after decoding. This is why texture dimensions matter as much as download size.

Do not use a flagship laptop as the only test device. A scene that stays smooth on a powerful GPU can still freeze a mid-range phone, consume too much memory, or trigger aggressive thermal throttling.


3. Build a Small Three.js Foundation

Start with the smallest version of the idea. Add the hero object, one camera, one light setup, and one intentional interaction. Prove that the composition works before adding particles, bloom, fog, animated backgrounds, or multiple models.

Create a Vite project:

npm create vite@latest fast-threejs-site -- --template vanilla
cd fast-threejs-site
npm install
npm install three
npm run dev

Use a semantic HTML shell. The heading, introduction, navigation, and call to action should remain normal HTML—not text drawn inside WebGL.


<main>
  <section class="hero" aria-labelledby="hero-title">
    <div class="hero__copy">
      <p class="eyebrow">Interactive product systems</p>
      <h1 id="hero-title">Complex ideas, made visible.</h1>
      <p>
        We turn technical products and processes into focused digital stories.
      </p>
      <a href="#work">Explore the work</a>
    </div>

    <div class="hero__visual" aria-hidden="true">
      <canvas id="webgl"></canvas>
      <div class="hero__poster"></div>
    </div>
  </section>

  <section id="work" class="content">
    <h2>Selected work</h2>
  </section>
</main>

<script type="module" src="/src/main.js"></script>

The poster sits behind the canvas. It appears immediately, gives the browser a normal image to display, and becomes the final fallback if WebGL cannot start.

html {
  color-scheme: dark;
  background: #08090d;
  color: #f6f7fb;
  font-family: Inter, system-ui, sans-serif;
}

body {
  margin: 0;
}

.hero {
  min-height: 100svh;
  display: grid;
  grid-template-columns: minmax(0, 0.9fr) minmax(320px, 1.1fr);
  align-items: center;
  gap: clamp(2rem, 7vw, 8rem);
  padding: clamp(1.25rem, 5vw, 5rem);
}

.hero__copy {
  position: relative;
  z-index: 2;
  max-width: 42rem;
}

.hero__copy h1 {
  margin: 0 0 1.25rem;
  font-size: clamp(3rem, 7vw, 7.5rem);
  line-height: 0.92;
  letter-spacing: -0.06em;
}

.hero__visual {
  position: relative;
  min-height: min(72svh, 760px);
  border-radius: 2rem;
  overflow: hidden;
}

#webgl,
.hero__poster {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}

.hero__poster {
  background:
    radial-gradient(circle at 58% 42%, #6f65ff55, transparent 28%),
    linear-gradient(145deg, #171929, #090a10 65%);
}

#webgl {
  z-index: 1;
  opacity: 0;
  transition: opacity 500ms ease;
}

.is-webgl-ready #webgl {
  opacity: 1;
}

@media (max-width: 760px) {
  .hero {
    grid-template-columns: 1fr;
    align-content: center;
  }

  .hero__visual {
    min-height: 45svh;
  }
}

@media (prefers-reduced-motion: reduce) {
  #webgl {
    transition: none;
  }
}

Now add a lightweight scene. It caps pixel density, uses one geometry and one material, keeps resize work outside the animation loop, and pauses when the tab is hidden.

// src/main.js
import * as THREE from 'three';

const canvas = document.querySelector('#webgl');
const visual = document.querySelector('.hero__visual');
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');

const scene = new THREE.Scene();

const camera = new THREE.PerspectiveCamera(35, 1, 0.1, 50);
camera.position.set(0, 0, 6);

const renderer = new THREE.WebGLRenderer({
  canvas,
  antialias: true,
  alpha: true,
  powerPreference: 'high-performance',
});

renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.05;

const geometry = new THREE.IcosahedronGeometry(1.45, 4);
const material = new THREE.MeshStandardMaterial({
  color: 0x7770ff,
  roughness: 0.32,
  metalness: 0.18,
});
const object = new THREE.Mesh(geometry, material);
scene.add(object);

const key = new THREE.DirectionalLight(0xffffff, 3.2);
key.position.set(3, 4, 5);
scene.add(key);

const rim = new THREE.DirectionalLight(0x6d5cff, 4.5);
rim.position.set(-4, 1, -2);
scene.add(rim);

scene.add(new THREE.HemisphereLight(0xaab8ff, 0x11131d, 1.1));

function resize() {
  const { width, height } = visual.getBoundingClientRect();
  const pixelRatio = Math.min(window.devicePixelRatio, 1.5);

  renderer.setPixelRatio(pixelRatio);
  renderer.setSize(width, height, false);

  camera.aspect = width / height;
  camera.updateProjectionMatrix();
}

const resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(visual);

let frameId = 0;
let running = false;
let elapsed = 0;
let previousFrameTime = performance.now();

function render(now = performance.now()) {
  if (!running) return;

  const delta = Math.min((now - previousFrameTime) / 1000, 0.05);
  previousFrameTime = now;
  elapsed += delta;

  if (!reduceMotion.matches) {
    object.rotation.y = elapsed * 0.18;
    object.rotation.x = Math.sin(elapsed * 0.35) * 0.08;
  }

  renderer.render(scene, camera);
  frameId = requestAnimationFrame(render);
}

function start() {
  if (running) return;
  running = true;
  previousFrameTime = performance.now();
  frameId = requestAnimationFrame(render);
}

function stop() {
  running = false;
  cancelAnimationFrame(frameId);
}

document.addEventListener('visibilitychange', () => {
  document.hidden || reduceMotion.matches ? stop() : start();
});

resize();
renderer.render(scene, camera);
document.documentElement.classList.add('is-webgl-ready');
if (!reduceMotion.matches) start();

This is deliberately simple. A clean silhouette, controlled light, good composition, and smooth behavior will carry more visual weight than five unrelated effects competing for attention.

Watch two complete portfolio builds

Seeing a whole project come together helps before you start budgeting one. These two tutorials each build a full 3D portfolio — one with plain Three.js, one with React Three Fiber. Treat them as structure references: portfolio tutorials aim for a striking result, so apply the budgets, pixel-ratio limits, and loading order from this guide on top.

Build a Mindblowing 3D Portfolio Website // Three.js Beginner’s Tutorial — Fireship
Build and Deploy an Amazing 3D Web Developer Portfolio in React JS — JavaScript Mastery

4. Control Resolution Before Removing Detail

The fastest way to overwhelm a GPU is often not geometry—it is pixels. A full-screen canvas on a high-density phone can render several times more pixels than its CSS dimensions suggest. Add bloom, depth of field, ambient occlusion, or multiple render targets and that pixel cost multiplies again.

renderer.setPixelRatio(window.devicePixelRatio) looks sharp, but using the raw device value can be wasteful. A ratio of 3 means roughly nine times the pixel count of a ratio of 1 at the same CSS size.

A fixed cap is the easiest safe choice:

const dpr = Math.min(window.devicePixelRatio, 1.5);
renderer.setPixelRatio(dpr);

A better production system can change quality after measuring frame time:

let quality = 1.5;
let slowFrames = 0;

function adaptQuality(deltaMs) {
  slowFrames = deltaMs > 24 ? slowFrames + 1 : Math.max(0, slowFrames - 1);

  if (slowFrames > 45 && quality > 1) {
    quality = Math.max(1, quality - 0.25);
    renderer.setPixelRatio(Math.min(window.devicePixelRatio, quality));
    resize();
    slowFrames = 0;
  }
}

Do not change the pixel ratio every frame. That reallocates render targets and can cause visible instability. Adjust in coarse steps after a sustained slowdown, then keep the chosen level steady.

A practical quality ladder

FeatureHighBalancedReduced
Pixel ratio cap21.51
Shadow map20481024Off or baked
BloomHalf resolutionQuarter resolutionOff
Particle count30,00012,0003,000 or static
Model LODFullMediumLow
Reflection updatesOccasionalOn demandBaked environment

Hardware detection can help choose an initial tier, but measurements should make the final decision. Device labels are unreliable; a recent phone can outperform an old laptop, and battery or thermal state can change during a visit.


5. Reduce Draw Calls, Materials, and Repeated Objects

Polygon count matters, but it is not the only useful number. A model with 80,000 triangles and four materials may run better than a collection of small objects with 20,000 triangles and 250 materials. The second scene asks the browser to submit far more separate draw operations.

Use renderer.info while developing:

renderer.render(scene, camera);

console.table({
  calls: renderer.info.render.calls,
  triangles: renderer.info.render.triangles,
  geometries: renderer.info.memory.geometries,
  textures: renderer.info.memory.textures,
});

Keep this out of the production animation loop. Logging every frame can create its own performance problem.

Use instancing for repeated geometry

If a scene contains hundreds of identical markers, cards, bolts, lights, or particles, an InstancedMesh can draw them with far fewer calls.

const count = 500;
const markerGeometry = new THREE.IcosahedronGeometry(0.025, 1);
const markerMaterial = new THREE.MeshBasicMaterial({ color: 0x8c83ff });
const markers = new THREE.InstancedMesh(
  markerGeometry,
  markerMaterial,
  count
);

const matrix = new THREE.Matrix4();
const position = new THREE.Vector3();

for (let i = 0; i < count; i += 1) {
  const radius = 1.8 + Math.random() * 0.5;
  const angle = Math.random() * Math.PI * 2;
  const height = (Math.random() - 0.5) * 2.5;

  position.set(Math.cos(angle) * radius, height, Math.sin(angle) * radius);
  matrix.setPosition(position);
  markers.setMatrixAt(i, matrix);
}

markers.instanceMatrix.needsUpdate = true;
scene.add(markers);

Also look for these wins:

  • reuse one geometry or material instead of cloning it for every mesh;
  • join static meshes that share a material;
  • remove hidden faces and unseen interior geometry;
  • replace tiny modeled detail with a normal map;
  • use simple collision or raycast meshes instead of testing the visible model;
  • avoid transparent materials when an opaque design works;
  • turn off frustumCulled only when there is a proven reason;
  • use levels of detail for objects that move far from the camera.

Transparency is particularly expensive and visually fragile because objects must be sorted. Overlapping transparent particles can consume fill rate even when each particle contains only two triangles.

Use Level of Detail and keep frustum culling working

Three.js already skips objects whose bounding sphere falls outside the camera's view: object.frustumCulled is true by default. Keep it on, and recompute bounds after you move vertices in code. A stale bounding sphere can hide an object that is actually visible, or keep drawing one that is not.

For large objects seen at different distances, LOD swaps a detailed mesh for simpler versions as the camera moves away:

import { LOD } from 'three';

const lod = new LOD();
lod.addLevel(highDetailMesh, 0);   // used up close
lod.addLevel(mediumDetailMesh, 15); // from 15 units away
lod.addLevel(lowDetailMesh, 40);    // from 40 units away
scene.add(lod);

With the default lod.autoUpdate = true, the renderer picks the right level every frame. Generate the simpler meshes in your modelling tool or with glTF Transform's simplification, and check the switch distances on a real screen so the change is not noticeable.

Watch: optimization techniques in practice

This walkthrough applies many of the same levers — draw calls, geometry, textures, shadows, and rendering cost — to a real scene.

Three.js Optimization - Best Practices and Techniques — Valentin's coding book

6. Treat glTF as a Delivery Format, Not a Blender Backup

glTF is designed for efficient runtime delivery of 3D scenes and models. For the web, prefer .glb when a single binary file simplifies loading. Use separate .gltf, texture, and buffer files when independent caching or an existing asset pipeline makes that structure useful.

Before export from Blender:

  1. Delete unused meshes, cameras, materials, and animation tracks.
  2. Apply transforms when the intended workflow allows it.
  3. Check real-world scale and the object origin.
  4. Merge materials that are visually identical.
  5. Reduce texture dimensions based on actual screen coverage.
  6. Bake complex lighting or surface detail where possible.
  7. Remove animation keys that do not change.
  8. Confirm that normals, tangents, and UVs are correct.

After export, optimize the asset with glTF Transform. Its command-line tools can inspect, deduplicate, prune, resize textures, simplify meshes, and apply Meshopt or Draco compression.

npx @gltf-transform/cli inspect public/models/product.glb

npx @gltf-transform/cli optimize \
  public/models/product.glb \
  public/models/product.optimized.glb \
  --texture-compress webp

For production, compare the output visually. Automated optimization can expose UV problems, damage thin geometry, or remove details that matter to the design.

Load compressed assets correctly

GLTFLoader supports modern glTF extensions. If the file uses Draco geometry or KTX2 textures, configure the matching decoder/transcoder before loading.

import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js';

const gltfLoader = new GLTFLoader();

const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath('/draco/');
gltfLoader.setDRACOLoader(dracoLoader);

const ktx2Loader = new KTX2Loader();
ktx2Loader.setTranscoderPath('/basis/');
ktx2Loader.detectSupport(renderer);
gltfLoader.setKTX2Loader(ktx2Loader);

gltfLoader.load(
  '/models/product.optimized.glb',
  ({ scene: model }) => {
    model.traverse((child) => {
      if (!child.isMesh) return;
      child.castShadow = false;
      child.receiveShadow = false;
    });

    scene.add(model);
    renderer.compileAsync(scene, camera).catch(() => {});
    document.documentElement.classList.add('is-webgl-ready');
  },
  undefined,
  (error) => {
    console.error('The 3D model could not be loaded.', error);
    canvas.hidden = true;
  }
);

Compression changes where the cost appears. It reduces network transfer, but the browser must decode the data. Measure total loading and startup time instead of celebrating file size alone.

Meshopt and Draco both reduce geometry transfer size. Test both with your model and target devices. Meshopt often fits progressive, runtime-focused workflows well; Draco can produce very small geometry files but requires its decoder. There is no single winner for every asset.

Watch: from a Blender scene to the browser

If your models come from Blender, this course follows the whole path: modelling, baking, exporting, and loading a room scene in Three.js. Pair it with the export checklist above so the GLB that reaches the browser stays small.

Create a Cute Room Portfolio with Blender, Three.js, JavaScript – Beginner Course — freeCodeCamp.org

7. Make Textures and Lighting Earn Their Cost

A convincing material does not always require five 4K maps. Ask what the camera can actually see.

For a medium-sized hero object, a 2K base-color texture may be enough. A roughness map can often be smaller. Tiny background objects may need only a flat color or a shared atlas. If the object never fills the screen, a 4K texture may add memory without adding visible detail.

Use the right color space:

const colorTexture = textureLoader.load('/textures/base-color.webp');
colorTexture.colorSpace = THREE.SRGBColorSpace;

const roughnessTexture = textureLoader.load('/textures/roughness.webp');
// Data textures remain in the default non-color space.

Choose KTX2 compressed textures for heavier production scenes. GPU-ready compressed formats can reduce memory pressure and upload cost, with KTX2Loader selecting a supported format for the visitor's device.

Prefer designed lighting over many lights

A useful web lighting setup is often:

  • one key light for shape;
  • one rim or fill light for separation;
  • one environment map or hemisphere light for broader illumination;
  • baked shadows or ambient occlusion for static detail.

Real-time shadows are not automatically better. A soft contact shadow baked into the asset or placed beneath a product can look cleaner than a noisy, high-cost dynamic shadow map.

If dynamic shadows are essential:

renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFShadowMap; // PCFSoftShadowMap was removed in recent releases

key.castShadow = true;
key.shadow.mapSize.set(1024, 1024);
key.shadow.camera.near = 0.5;
key.shadow.camera.far = 12;

object.castShadow = true;
floor.receiveShadow = true;

Keep the shadow camera tight around the visible subject. A huge shadow frustum spreads the available detail over an unnecessary area.

For free, production-friendly environments and textures, Poly Haven publishes HDRIs, textures, and models under CC0. ambientCG is another CC0 source for PBR materials and HDRIs. Always confirm the license on the exact asset page before publishing.

Abstract black and blue curved surfaces that suggest depth and motion

Reference image by Pawel Czerwinski on Unsplash. Use reference images to define composition, material, color, and lighting—not to justify adding unrelated objects.


8. Build Scroll Animation Around Progress

Scroll-controlled 3D feels good when the same scroll position always produces the same visual state. Sudden jumps usually come from mixing time-based animation, scroll events, pinning, layout changes, and multiple systems that all write to the camera.

Use a normalized progress value from 0 to 1, then derive the camera and object state from it.

let targetProgress = 0;
let currentProgress = 0;

function updateScrollTarget() {
  const maxScroll = document.documentElement.scrollHeight - window.innerHeight;
  targetProgress = maxScroll > 0 ? window.scrollY / maxScroll : 0;
}

window.addEventListener('scroll', updateScrollTarget, { passive: true });
updateScrollTarget();

function render() {
  if (!running) return;

  currentProgress = THREE.MathUtils.lerp(
    currentProgress,
    targetProgress,
    0.075
  );

  object.rotation.y = currentProgress * Math.PI * 2;
  camera.position.z = THREE.MathUtils.lerp(6, 4.2, currentProgress);
  camera.position.y = THREE.MathUtils.lerp(0, -0.6, currentProgress);
  camera.lookAt(0, 0, 0);

  renderer.render(scene, camera);
  frameId = requestAnimationFrame(render);
}

This simple damping approach avoids heavy work inside the raw scroll handler. The handler records a number; the render loop performs the visual update.

For larger narratives, GSAP ScrollTrigger provides pinning, scrubbed timelines, labels, and lifecycle controls. Keep one timeline responsible for the camera. Animate a camera rig or target group rather than letting several sections modify camera.position independently.

import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';

gsap.registerPlugin(ScrollTrigger);

const cameraRig = new THREE.Group();
cameraRig.add(camera);
scene.add(cameraRig);

const story = gsap.timeline({
  defaults: { ease: 'none' },
  scrollTrigger: {
    trigger: '.story',
    start: 'top top',
    end: 'bottom bottom',
    scrub: 0.7,
  },
});

story
  .to(cameraRig.position, { z: -1.5, duration: 1 }, 0)
  .to(object.rotation, { y: Math.PI, duration: 1 }, 0)
  .to(cameraRig.position, { y: -1.2, duration: 1 }, 1)
  .to(object.scale, { x: 0.7, y: 0.7, z: 0.7, duration: 1 }, 1);

Avoid animating layout properties such as top, left, width, and height during scroll. Prefer transform and opacity for DOM elements. Keep text animation quieter than the main 3D movement so visitors can still read.

Prevent the common loading-to-hero jump

If a loading screen transitions into the hero:

  1. reserve the hero's final height before loading;
  2. set the initial camera pose before revealing the canvas;
  3. compile important materials before the reveal when practical;
  4. keep the loading text in a fixed wrapper so shrinking it does not move the page;
  5. reveal the canvas and content with opacity and transforms;
  6. create or refresh ScrollTrigger only after fonts and important layout assets settle;
  7. never combine an automatic camera intro with scroll control unless ownership changes at a precise, tested point.

One system should own the camera at a time.


9. Render Only When Rendering Is Useful

An always-running animation loop consumes power even when the scene is static, below the fold, or hidden behind another tab. A product viewer that moves only during drag can render on demand.

let renderQueued = false;

function invalidate() {
  if (renderQueued) return;
  renderQueued = true;

  requestAnimationFrame(() => {
    renderer.render(scene, camera);
    renderQueued = false;
  });
}

controls.addEventListener('change', invalidate);
window.addEventListener('resize', () => {
  resize();
  invalidate();
});

For a scene with continuous motion, pause it when it leaves the viewport:

const observer = new IntersectionObserver(
  ([entry]) => (entry.isIntersecting ? start() : stop()),
  { threshold: 0.05 }
);

observer.observe(visual);

React Three Fiber offers the same idea through frameloop="demand" and invalidate(). Its performance scaling guide also covers object reuse, instancing, on-demand rendering, and adaptive quality.


10. Protect Core Web Vitals and the Reading Experience

A 3D canvas should not block the content people came to see. Google's Core Web Vitals focus on loading, responsiveness, and visual stability through Largest Contentful Paint (LCP), Interaction to Next Paint (INP), and Cumulative Layout Shift (CLS).

The current “good” thresholds are:

MetricGood experienceWhat usually hurts it on a 3D page
LCP2.5 seconds or lessLarge hero downloads, blocked main thread, late fonts or poster image
INP200 milliseconds or lessHeavy pointer handlers, long JavaScript tasks, shader compilation during interaction
CLS0.1 or lessCanvas or hero changing size after load, late UI injection, missing media dimensions

Google evaluates these thresholds at the 75th percentile of page visits, separated across mobile and desktop. Lab tests are useful, but real-user data tells you what visitors actually experience.

Load the page in layers

A reliable order is:

  1. semantic HTML and critical CSS;
  2. hero copy, navigation, and a small poster image;
  3. the Three.js application code;
  4. the first visible model and essential textures;
  5. secondary scenes and optional effects after interaction or idle time.

Do not hide the entire page behind a percentage counter. A visitor should be able to understand the page while the enhanced layer prepares itself.

Dynamic import can keep Three.js out of routes that do not use it:

const hero = document.querySelector('.hero__visual');

const loadScene = async () => {
  const { mountHeroScene } = await import('./hero-scene.js');
  await mountHeroScene(hero);
};

const sceneObserver = new IntersectionObserver(
  ([entry], observer) => {
    if (!entry.isIntersecting) return;
    observer.disconnect();
    loadScene().catch(console.error);
  },
  { rootMargin: '400px' }
);

sceneObserver.observe(hero);

The rootMargin begins loading before the element becomes visible, without forcing every visitor to download the scene during the first network burst.

Keep the JavaScript bundle small

Three.js is modular. Import the classes you use — import { WebGLRenderer, PerspectiveCamera } from 'three' — so the bundler can drop the rest, and import add-ons such as GLTFLoader from three/addons/ only in the module that needs them. Check the output with a bundle analyser. A shared file that imports a post-processing library, for example, quietly adds weight to every page, including pages that never show a scene.

Keep 3D pages indexable (Three.js website SEO)

Search engines index the HTML of a page, not the pixels in a <canvas>. A WebGL-heavy page ranks on the same signals as any other page, so:

  • put the page's real heading, copy, and links in HTML, outside the canvas;
  • give the canvas a short accessible label, and show a poster image with descriptive alt text until WebGL is ready;
  • never hide the whole page behind a loader — content that appears only after a scene downloads may never be seen by visitors or crawlers;
  • make a text heading or poster image the Largest Contentful Paint, not a scene that arrives late;
  • write descriptive titles, meta descriptions, and structured data exactly as you would for a flat page.

The 3D layer then becomes an enhancement on top of a page that already works, which is also what keeps Core Web Vitals healthy.


11. Design for Mobile, Touch, and Reduced Motion

Responsive 3D is not the desktop composition squeezed into a smaller rectangle. Mobile may need a different camera, fewer particles, a closer crop, a simpler model, and a shorter narrative.

Test these cases:

  • portrait phones with browser bars expanding and collapsing;
  • landscape phones with very little vertical space;
  • touch dragging near page scroll areas;
  • slow connections and a cold cache;
  • older integrated GPUs;
  • device rotation while the model is loading;
  • backgrounding and reopening the tab;
  • reduced-motion settings;
  • keyboard navigation and visible focus states;
  • WebGL failure or disabled hardware acceleration.

Use 100svh or an intentional layout strategy instead of assuming 100vh always matches the visible phone viewport. Ensure touch controls do not trap vertical scrolling. If drag interaction is optional, keep normal page scroll as the default gesture.

Respect reduced motion in both CSS and JavaScript:

const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');

function applyMotionPreference(event) {
  if (event.matches) {
    object.rotation.set(0.08, 0.4, 0);
    stop();
    renderer.render(scene, camera);
  } else {
    start();
  }
}

motionQuery.addEventListener('change', applyMotionPreference);
applyMotionPreference(motionQuery);

Do not place essential copy only inside a texture or 3D mesh. Canvas content is not a replacement for semantic headings, links, form labels, and buttons.


12. Use Prompts to Create 3D Assets—Then Optimize Them

Prompt-based 3D tools are useful for concepting, background props, rough product forms, and early composition tests. Their output should be treated as source material, not automatically as a finished web asset.

A generated model may contain excessive triangles, scattered materials, oversized textures, broken normals, unclear licensing, or topology that is difficult to animate. Put it through the same Blender and glTF optimization pipeline as any other asset. Our comparison of free AI 3D model generators lists each tool's export formats and free-tier licence terms.

Useful prompt-to-3D tools

ToolBest use in this workflowLink
MeshyQuick text-to-3D and image-to-3D concepts with common export formatsmeshy.ai
TripoFast object prototypes from a prompt or reference imagetripo3d.ai
SplinePrompt-assisted 3D creation, scene editing, interaction, and web publishingSpline documentation
BlenderCleanup, retopology, UV editing, baking, animation, and glTF exportblender.org
glTF TransformInspect and optimize the exported .glb filegltf-transform.dev
Three.js EditorQuickly inspect composition, lights, cameras, and supported modelsthreejs.org/editor

Features, free allowances, output ownership, and commercial terms can change. Read the current terms for the selected service and keep a record of the model's source.

A prompt formula that produces more useful web assets

Describe the object, construction, style, materials, topology, texture approach, pose, and exclusions.

[single object] + [distinctive construction] + [visual style] +
[two or three materials] + [web-ready geometry constraints] +
[neutral pose and view] + [what to exclude]

Weak prompt:

A futuristic robot

Stronger prompt:

Single compact inspection robot with a rounded triangular body, three short
folding legs, one recessed optical sensor, industrial product-design style,
brushed dark aluminum with small violet glass accents, clean medium-poly mesh,
separate logical parts, symmetrical neutral pose, centered object, no floor,
no environment, no text, no logo, no floating particles, no extra accessories.

For a portfolio hero object:

Abstract modular system made from three interlocking rings that clearly represent
strategy, design, and engineering. Precise machined geometry, matte graphite,
translucent violet glass only on the connection points, strong recognizable
silhouette, medium-poly real-time asset, clean bevels, one material per surface
type, centered at world origin, no background, no lettering, no decorative orbit,
no random cables, no tiny surface noise.

For a scroll transformation:

Three-stage mechanical seed designed for an exploded-view animation: closed shell,
inner lattice, and luminous central core. Every stage must be a separate named part.
Smooth product-visualization style, efficient topology, no loose pieces, no ground,
no baked camera, no text, no environment. Intended for a dark Three.js website.

Clean the result before it reaches the website

  1. Import the model into Blender.
  2. Confirm scale, origin, transforms, and orientation.
  3. Delete hidden or duplicate geometry.
  4. Repair normals and remove non-manifold mistakes when necessary.
  5. Decimate or retopologize only as far as the silhouette permits.
  6. Merge duplicate materials and pack textures logically.
  7. Resize textures according to visible screen size.
  8. Name animation-ready parts clearly.
  9. Export a .glb with only the selected objects.
  10. Inspect and optimize the export.
  11. Test it on a real phone before building the final animation around it.

Prompting saves concept time. It does not remove technical art work.


13. Profile the Scene Instead of Guessing

If the page stutters, determine whether the limit comes from CPU work, GPU work, memory, network loading, or layout. Each problem needs a different fix.

Browser tools

  • Chrome DevTools Performance: find long tasks, forced layout, scripting spikes, and frame drops.
  • Chrome DevTools Network: inspect transferred bytes, cache behavior, priority, and request waterfalls.
  • Chrome DevTools Memory: look for detached objects and memory that grows after route changes.
  • Lighthouse: run repeatable lab checks for loading, accessibility, and common delivery issues.
  • Performance Monitor: watch CPU use, DOM size, layouts, and frame rate while interacting.

WebGL tools

  • renderer.info exposes draw calls, triangles, textures, geometries, and shader programs.
  • Spector.js captures WebGL frames so you can inspect commands, states, textures, and render targets.
  • stats.js provides a small development overlay for frames, frame time, and memory where supported.

Diagnose by symptom

SymptomLikely causesFirst checks
Fast loading, low FPSHigh DPR, heavy fragments, too many draw calls, shadows, post-processingLower DPR; inspect calls; disable passes one at a time
Slow first viewLarge model or texture transfer, blocked JavaScript, late posterNetwork waterfall; bundle analysis; image priority
First interaction freezesShader compilation, model parsing, synchronous workPerformance trace; precompile or move work earlier
Performance degrades over timeUndisposed textures, geometries, render targets, event listenersMemory timeline; repeated route navigation
Only scroll feels badLayout reads/writes, several scroll listeners, competing timelinesPerformance trace; one progress controller; passive listener
Only mobile failsDPR, memory, thermal limits, touch conflictDevice test; reduced tier; simpler mobile composition

Change one major variable at a time. If you disable shadows, bloom, and half the scene together, you learn only that “something expensive was removed.” A controlled test tells you which feature deserves redesign.


14. Dispose Resources in Single-Page Applications

Removing a mesh from the scene does not automatically release its GPU resources. This matters when a visitor changes routes, opens several product viewers, or returns to the same page.

function disposeMaterial(material) {
  for (const value of Object.values(material)) {
    if (value?.isTexture) value.dispose();
  }
  material.dispose();
}

function disposeObject(root) {
  root.traverse((child) => {
    if (!child.isMesh) return;

    child.geometry?.dispose();

    if (Array.isArray(child.material)) {
      child.material.forEach(disposeMaterial);
    } else if (child.material) {
      disposeMaterial(child.material);
    }
  });
}

function destroyScene() {
  stop();
  resizeObserver.disconnect();
  observer.disconnect();
  disposeObject(scene);
  renderer.dispose();
}

Shared resources need ownership rules. Do not dispose a texture while another live scene still uses it. A small asset manager with reference counting is safer for larger applications.

Also remove window events, media-query listeners, controls, GSAP timelines, ScrollTriggers, observers, and workers during teardown.

Find memory leaks and estimate GPU memory

renderer.info.memory reports how many geometries and textures the renderer is holding. Record it, leave the 3D page and come back a few times, then record it again. If the counts keep climbing, something is not being disposed.

function logGpuResources(label) {
  const { geometries, textures } = renderer.info.memory;
  console.log(label, { geometries, textures, programs: renderer.info.programs?.length });
}

Textures are usually the largest share of GPU memory. An uncompressed RGBA texture costs width × height × 4 bytes, plus roughly a third more for mipmaps. A single 2048 × 2048 texture therefore uses about 16 MB, or about 21 MB with mipmaps — however small the JPEG or PNG file was. GPU-compressed formats delivered through KTX2 stay compressed in video memory and typically need a quarter to an eighth of that, which is why they matter so much on phones.


15. Common Three.js Performance Mistakes

Most slow 3D pages fail in a handful of predictable ways:

MistakeWhat it costsFix
Shipping the Blender export as-isMulti-megabyte downloads, uncompressed textures, unused nodesPrune, resize, and compress with glTF Transform; use Draco or Meshopt and KTX2
Rendering objects nobody can seeDraw calls and fill rate spent off-screen or behind other objectsKeep frustum culling on, use LOD, remove objects that do not serve the story
Real-time shadows everywhereAn extra render pass for every shadow-casting lightBake shadows into textures; limit castShadow to objects that need it
An animation loop that never stopsBattery drain, heat, and main-thread time while nothing changesRender on demand; pause when the canvas is off-screen or the tab is hidden
Treating a phone like a desktopLow FPS, thermal throttling, crashes on low-memory devicesCap the pixel ratio, use a lighter quality tier, test on real devices
Never disposing GPU resourcesMemory that grows with every route changeDispose geometries, materials, textures, and render targets on teardown

Each fix is covered in its own section above; the table is a quick way to audit an existing project.


16. A Production Checklist for 3D Websites

Story and design

  • [ ] The 3D scene communicates a specific idea.
  • [ ] The first frame has a strong silhouette and readable composition.
  • [ ] Text remains normal, selectable HTML.
  • [ ] The main call to action works before the model finishes loading.
  • [ ] Every large effect has a reason to exist.

Models and textures

  • [ ] Unused geometry, materials, animation tracks, and hidden objects are removed.
  • [ ] Repeated objects use instancing where appropriate.
  • [ ] Static meshes sharing a material are merged when helpful.
  • [ ] Texture dimensions match their maximum screen coverage.
  • [ ] The model is exported as glTF/GLB and inspected after optimization.
  • [ ] Compressed geometry and KTX2 textures are tested where they provide a real benefit.

Rendering

  • [ ] Device pixel ratio is capped or adapted.
  • [ ] Real-time shadows are limited and tightly framed.
  • [ ] Post-processing works at reduced resolution when possible.
  • [ ] The render loop pauses offscreen and in hidden tabs.
  • [ ] Static scenes render on demand.
  • [ ] GPU resources are disposed during teardown.

Loading and usability

  • [ ] The hero reserves its final dimensions to avoid layout shift.
  • [ ] A poster or still fallback is available.
  • [ ] Secondary models load after the critical page content.
  • [ ] Loading failures do not hide the page.
  • [ ] Touch gestures do not trap normal scrolling.
  • [ ] Reduced motion produces a complete, understandable page.

Testing

  • [ ] The page is tested on a mid-range phone, not only desktop emulation.
  • [ ] A cold-cache visit is tested on a throttled connection.
  • [ ] LCP, INP, and CLS are measured.
  • [ ] Draw calls, texture count, triangles, and frame time are recorded.
  • [ ] Route changes do not increase memory indefinitely.
  • [ ] WebGL failure leaves a usable experience.

17. Frequently Asked Questions

Is Three.js good for building a 3D website?

Yes. Three.js provides a mature scene graph, cameras, materials, loaders, animation tools, render targets, WebXR support, and WebGL/WebGPU rendering paths. It is a strong choice when the project needs custom rendering or interaction. If the site is built with React, React Three Fiber can make scene composition fit the component model while still using Three.js underneath.

Should I use Three.js or React Three Fiber?

Use Three.js directly when you want a small dependency surface, imperative control, or integration with a non-React site. Use React Three Fiber when the rest of the application already uses React and the team benefits from declarative components, Suspense-style asset workflows, and its ecosystem. Performance depends more on scene design, render strategy, assets, and updates than on this choice alone.

How many polygons can a Three.js website handle?

There is no universal safe number. Material count, draw calls, skinning, transparency, shadows, overdraw, pixel ratio, fragment shaders, device memory, and screen coverage can matter as much as triangles. Start with a measured budget for the target device and raise detail only when the frame has room.

What is the best 3D model format for Three.js?

glTF or binary GLB is usually the best delivery format for web scenes. It supports physically based materials, animations, cameras, lights, and extension-based compression in an ecosystem designed for runtime use. Keep the original Blender file as the editable source and publish an optimized GLB as the delivery asset.

How do I make a Three.js website load faster?

Make the HTML useful before 3D loads, show a lightweight poster, split noncritical code, compress the model, reduce texture dimensions, use modern texture compression, cache immutable assets, preload only what is truly critical, and load secondary scenes near the viewport or during idle time.

Why does my Three.js website lag on mobile?

The usual causes are excessive pixel ratio, large textures, too many transparent particles, multiple shadow maps, heavy post-processing, high draw-call counts, expensive per-frame JavaScript, or a desktop composition rendered unchanged on a phone. Lower resolution first, then isolate each rendering feature with measurements.

Can a prompt generate a finished model for a website?

It can generate a useful starting point, but the result still needs inspection. Check topology, scale, normals, material count, texture sizes, license, animation structure, and final GLB performance. The cleanup step is what turns a quick concept into a dependable web asset.

Is WebGPU required for a modern 3D site?

No. WebGL remains a practical baseline with broad support. WebGPU can unlock newer rendering and compute workflows, but it should be adopted for a specific benefit and tested with an appropriate fallback. A strong scene concept and disciplined asset pipeline matter more than selecting the newest renderer label.

Does Three.js affect website performance?

Only as much as the scene you build with it. The library is a moderate JavaScript download that can be split out of pages that do not use it. The real cost comes from what you render: model and texture size, draw calls, shadows, post-processing, pixel ratio, and how often the loop runs. A focused scene that loads after the page's main content can coexist with good Core Web Vitals.

How can I improve FPS in Three.js?

Find the bottleneck first with renderer.info and a performance trace. The usual wins, roughly in order: cap the device pixel ratio, cut draw calls by merging or instancing, reduce or bake shadows, remove expensive post-processing passes, simplify materials and fragment shaders, and stop rendering when nothing moves.

Is Three.js suitable for mobile websites?

Yes, when the scene is designed for mobile from the start: a lower pixel ratio, smaller compressed textures, fewer lights and effects, touch-friendly controls, and a static fallback for low-power devices or visitors who prefer reduced motion. Test on mid-range phones, not only flagship devices.

How do I optimize GLB files for web performance?

Clean the model in Blender, then run it through glTF Transform to prune unused data, deduplicate, resize textures, and apply Draco or Meshopt geometry compression with KTX2 or WebP textures. Load the result with GLTFLoader configured with the matching decoders, and compare it visually with the original. See Treat glTF as a Delivery Format.

What is the best way to reduce draw calls in Three.js?

Share materials, merge static meshes that use the same material, draw repeated objects with InstancedMesh, and remove objects that do not contribute to the frame. Check renderer.info.render.calls before and after each change.

Does a 3D website affect Google SEO?

Not directly, but it can hurt indirectly. Search engines read HTML, not the canvas, so content that exists only inside WebGL is invisible to them, and a slow or unstable page can perform poorly on Core Web Vitals. Keep headings and copy in HTML and load the scene progressively, and the 3D layer does not need to cost you rankings.

How can I improve Core Web Vitals on a 3D website?

Make text or a poster image the Largest Contentful Paint, load Three.js and models after the critical content, keep event handlers and per-frame JavaScript short for INP, and reserve the canvas's size in CSS so nothing shifts for CLS. Confirm with real-user field data, not only Lighthouse.

How much GPU memory does a Three.js website use?

It depends mostly on textures, render targets, and geometry. One uncompressed 2048 × 2048 RGBA texture uses about 16 MB of GPU memory, or around 21 MB with mipmaps, whatever its file size. Track resource counts with renderer.info.memory and estimate texture memory from dimensions to stay within a budget for phones.

What tools can I use to measure WebGL performance?

Chrome DevTools (Performance, Memory, and the Performance Monitor), Lighthouse for repeatable lab checks, renderer.info for draw calls and resources, Spector.js for frame captures, and stats.js for a live FPS overlay. Always confirm the results on real devices.


18. Summary and Takeaways

A successful 3D website is a normal, accessible website with a carefully designed spatial layer—not a canvas that holds the rest of the page hostage.

Start with one meaningful visual idea. Set budgets before production. Control rendered pixels, draw calls, material count, texture memory, shadows, and post-processing. Deliver models as optimized glTF or GLB assets. Tie scroll to stable progress, pause work that is not visible, preserve HTML content, respect reduced motion, and measure on real mobile hardware.

Prompt-to-3D tools can accelerate the first draft of an object, but they do not replace cleanup, art direction, licensing checks, optimization, or testing. The final quality comes from the decisions made after generation.

If the page loads quickly, responds immediately, and expresses an idea that flat media could not communicate as clearly, the 3D has earned its place.

Treat these Three.js best practices as defaults rather than late fixes, measure every change, and you can build fast 3D websites that still feel premium on an ordinary phone.


Useful References