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.
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 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:
new THREE.TextureLoader().load(awsUrl) inside a forEachS3 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.
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.
await sharp(srcPath)
.resize({ width: 1280, withoutEnlargement: true })
.webp({ quality: 82, effort: 6 })
.toFile(outPath)
Results across 17 images:
| Image | Before | After | Reduction |
|---|---|---|---|
| ladeguenstig | 1,174 KB | 63 KB | 94.6% |
| tinyhouse | 365 KB | 52 KB | 85.8% |
| ksg | 377 KB | 41 KB | 89.2% |
| drberg | 338 KB | 83 KB | 75.5% |
| Total | 4,351 KB | 962 KB | 77.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.
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.
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.
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.
This optimization is most impactful when:
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