Three.jsPerformanceWebPOptimizationJavaScript

WebP Textures in Three.js: 78% Smaller with Zero Quality Loss

How migrating 17 portfolio textures from S3-hosted PNG/JPG to locally-served WebP cut total image weight from 4.35 MB to 962 KB — and eliminated a GPU stutter that plagued the original implementation.

Peter Csipkay · · 4 min read

When I built the 3D portfolio gallery on this site, images were loaded as textures from AWS S3. It worked — but it had two problems: CORS complexity and a visible stutter every time a new texture uploaded to the GPU. Fixing both led to a straightforward optimization pattern worth documenting.

The Setup

The gallery renders 120 THREE.Mesh planes, each using a project screenshot as a WebGL texture. There are 17 unique projects; the 120 slots cycle through them in sequence. All images were hosted on petercsipkay.s3.eu-north-1.amazonaws.com.

Original image breakdown:

  • Mix of PNG and JPG files
  • Sizes ranging from 57 KB to 1.1 MB per image
  • Total: 4.35 MB
  • Loaded via new THREE.TextureLoader().load(awsUrl) inside a forEach

Problem 1: CORS

S3 buckets require explicit CORS configuration. A misconfiguration breaks THREE.TextureLoader silently — the texture loads as a black square. Over the development history of the project, there’s a console.log("CORS Fixed!") that appeared and never left. That’s a reliable indicator the fix was fragile.

Serving images from the same origin eliminates CORS entirely.

Problem 2: The GPU Upload Stutter

THREE.TextureLoader().load() is asynchronous. Called 120 times in a forEach, it fires 120 network requests and 120 GPU uploads — in whatever order the network delivers them. Each GPU upload causes a frame drop because the GPU stalls while the driver processes the new texture object.

With 17 unique images cycling, the same images load multiple times (once per mesh instance), making it worse.

The Fix: Local WebP + Texture Cache

Step 1: Download and convert with Sharp

await sharp(srcPath)
  .resize({ width: 1280, withoutEnlargement: true })
  .webp({ quality: 82, effort: 6 })
  .toFile(outPath)

Results across 17 images:

ImageBeforeAfterReduction
ladeguenstig1,174 KB63 KB94.6%
tinyhouse365 KB52 KB85.8%
ksg377 KB41 KB89.2%
drberg338 KB83 KB75.5%
Total4,351 KB962 KB77.9%

WebP achieves this because its compression algorithm handles photographic content far better than PNG and is comparable to JPEG for photos while supporting lossless compression for graphics. Three.js TextureLoader uses the browser’s native image decoder, so WebP support is universal in modern browsers.

Step 2: Pre-load and cache unique textures

const textureCache = new Map()
const uniqueImages = [...new Set(projects.map(p => p.image))]
let loaded = 0

uniqueImages.forEach(url => {
  const texture = textureLoader.load(url, () => {
    if (++loaded === uniqueImages.length) buildMeshes()
  })
  texture.minFilter = THREE.LinearFilter
  texture.magFilter = THREE.LinearFilter
  textureCache.set(url, texture)
})

THREE.LinearFilter on both minFilter and magFilter disables mipmapping. For UI-like flat planes where you always see approximately the full texture size, mipmapping adds GPU memory without visible benefit. Disabling it also avoids the mipmap generation cost on upload.

buildMeshes() only runs once all 17 textures are fully decoded and uploaded. The scroll-in animation is triggered from this callback, so the first frame the user sees has all images resident in GPU memory — no mid-render uploads, no stutter.

Step 3: Reuse textures across mesh instances

Since 120 meshes share 17 unique textures, each mesh material is created with a reference to the cached texture:

const material = new THREE.ShaderMaterial({
  uniforms: { map: { value: textureCache.get(project.image) } },
  ...
})

17 texture objects instead of 120. The GPU holds each image once regardless of how many meshes reference it.

Impact

  • Network weight: 4.35 MB → 962 KB (-78%)
  • GPU texture objects: 120 → 17
  • Stutter on scroll-in: eliminated
  • CORS configuration: no longer needed
  • S3 dependency: removed entirely

The images are now served from the same CDN edge as the rest of the site (Netlify/Vercel), which for most users is faster than a Stockholm-region S3 bucket anyway.

When to Apply This Pattern

This optimization is most impactful when:

  1. You have many Three.js meshes that reuse a small set of textures
  2. Textures are loaded asynchronously while the render loop is already running
  3. Images are larger than necessary for their displayed size in the scene

The texture cache pattern applies beyond portfolios — product galleries, real estate visualizers, data dashboards with image overlays all benefit from the same approach.


Questions or a different approach? Get in touch.

Written by Peter Csipkay — Creative Frontend Developer, Munich-Starnberg. Get in touch →

← Back to Blog

© 2015 - 2026 All rights reserved.