WebGL and Three.js: Building 3D Web Experiences That Actually Perform
We build 3D web experiences at Simplified. Not demos. Not tech showcases. Production applications that need to load fast, render smoothly, and work on the same mid-range devices our users actually own. This guide is everything we have learned about making WebGL and Three.js perform in the real world.
If you have ever watched a beautifully lit 3D scene grind to 15 FPS on a MacBook Air, this post is for you. We are going to cover the full pipeline from scene setup to deployment, with specific optimization techniques we use in our own projects — including work that feeds into our Adellion Forge asset pipeline.
Why WebGL and Three.js in 2026
WebGL remains the only way to ship hardware-accelerated 3D graphics to every browser without a plugin. Three.js is the abstraction layer that makes it practical. Together they power everything from product configurators to interactive data visualization to the kind of immersive web experiences we build for clients.
The ecosystem has matured significantly. WebGPU is emerging as the successor, but WebGL 2.0 still has near-universal browser support while WebGPU coverage remains incomplete. For production work shipping today, Three.js on WebGL 2.0 is the pragmatic choice.
60 FPS
Target Frame Rate
<100
Draw Calls Per Frame
<50MB
Texture Memory Budget
<3s
Time to Interactive
Scene Setup and the Render Loop
Every Three.js application starts with three objects: a Scene, a Camera, and a WebGLRenderer. The scene is your object graph. The camera defines the viewpoint. The renderer translates it all into GPU draw calls. Getting these right from the start saves you from refactoring later.
The render loop is where performance lives or dies. A naive requestAnimationFrame loop that re-renders the entire scene every frame wastes GPU cycles when nothing has changed. We use a dirty-flag pattern — the scene only re-renders when something actually moves, loads, or changes state.
Create your renderer, scene, and camera once. Set renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) to cap pixel density — retina displays at 3x will murder your frame rate for zero perceptible quality gain. Reuse geometries and materials across meshes instead of creating duplicates.
Do not blindly call renderer.render(scene, camera) every frame. Use a dirty flag or a state-driven approach. Static scenes should render once and stop. Interactive scenes should only re-render when input changes occur. This alone can cut GPU usage by 80 percent on scenes with intermittent interaction.
Three.js does not garbage-collect GPU resources automatically. When you remove a mesh from the scene, you must call .dispose() on its geometry, material, and textures. Skip this and you leak VRAM until the tab crashes. We wrap all disposals in a utility function that walks the object tree recursively.
Geometry and Materials: Less Is More
Every mesh in your scene generates at least one draw call. Every unique material generates a separate draw call even on the same geometry. The single most impactful optimization in any Three.js project is reducing the number of unique mesh-material combinations the renderer processes per frame.
Merge static geometry whenever possible. If you have fifty identical decorative objects that never move, merge them into a single BufferGeometry. Use THREE.BufferGeometryUtils.mergeGeometries() to combine them at load time. One draw call instead of fifty.
Lighting Strategies That Scale
Lighting is where most Three.js scenes hemorrhage performance. Each dynamic light that casts shadows requires an additional render pass from the light's perspective. Two shadow-casting lights means the scene renders three times per frame — once from each light, then once from the camera.
Our approach at Simplified: use one directional shadow-casting light maximum. Supplement with ambient light and hemisphere light for fill. Bake complex lighting into lightmaps for static environments. If you need multiple light sources, use non-shadow-casting PointLights and accept the trade-off.
Lighting Performance Budget
Budget: 16.67ms per frame at 60 FPS. Lighting alone can consume your entire budget.
Texture Optimization: The Silent Performance Killer
Textures consume more VRAM than any other asset type in a typical Three.js scene. A single 4096x4096 RGBA texture takes 64MB of GPU memory uncompressed. Load four of those and you have blown your entire texture budget on a mobile device before rendering a single triangle.
Our texture pipeline at Simplified follows strict rules that we also apply in the Adellion Forge pipeline for our Roblox projects. The same principles that keep Auntie Atom running at 60 FPS with 100 players apply to web 3D.
| Format | Compression | GPU Decode | Best For |
|---|---|---|---|
| KTX2 + Basis | GPU-native (transcoded) | Hardware accelerated | Production 3D scenes |
| AVIF | Excellent file size | CPU decode required | UI textures, thumbnails |
| WebP | Good file size | CPU decode required | Fallback, broad support |
| PNG | Lossless, large files | CPU decode required | Source assets only |
Always generate mipmaps. Three.js does this automatically for power-of-two textures, but if your textures are non-power-of-two, enable texture.generateMipmaps = true and set texture.minFilter = THREE.LinearMipmapLinearFilter. Without mipmaps, distant textured surfaces shimmer and aliase, and the GPU reads more data than necessary.
Lazy-load textures that are not immediately visible. If your scene has content below the fold or behind a camera rotation, load those textures asynchronously after the initial render. First paint matters more than complete fidelity.
Draw Call Reduction: The Number One Optimization
Draw calls are the single biggest performance bottleneck in WebGL applications. Each draw call represents a command sent from the CPU to the GPU. The GPU itself is fast — it is the CPU-to-GPU communication overhead that kills frame rates.
Our internal target at Simplified is fewer than 100 draw calls per frame for any production scene. Here is how we achieve that.
Geometry Merging
Combine static meshes sharing the same material into one BufferGeometry. Fifty identical rocks become one draw call. Use BufferGeometryUtils.mergeGeometries for load-time merging.
Texture Atlasing
Pack multiple textures into a single atlas and adjust UV coordinates. This lets you use one material for objects that would otherwise need separate textures and separate draw calls.
Instanced Rendering
For many identical objects with different transforms, use THREE.InstancedMesh. Render 10,000 trees with one draw call by providing a per-instance transformation matrix. This is the single most powerful optimization for scenes with repeated geometry.
Frustum Culling
Three.js culls objects outside the camera frustum by default, but only at the object level. Large meshes partially in view still render fully. Break large environments into spatial chunks so culling can skip entire sections.
Instanced Rendering Deep Dive
InstancedMesh is the most underused feature in Three.js. If your scene has more than ten copies of the same geometry, you should be using it. The performance difference is not incremental — it is transformative.
A forest scene with 5,000 tree meshes generates 5,000 draw calls. The same scene using InstancedMesh with a matrix buffer generates one draw call. The GPU does not care about rendering 5,000 instances — it was designed for that kind of parallel workload. What it cannot handle efficiently is 5,000 separate commands from the CPU telling it to render one tree at a time.
Level of Detail (LOD)
Three.js includes a built-in LOD system via THREE.LOD. Add multiple detail levels of a model and define distance thresholds. Objects far from the camera render at lower polygon counts. This technique is fundamental to any scene with depth.
We use the same LOD philosophy in our Adellion Forge pipeline — every asset ships with two to three LOD tiers generated automatically during the optimization stage. A 10,000-polygon character drops to 2,500 at distance. The visual difference at that range is imperceptible. The performance difference is not.
Mobile Performance: Where Theory Meets Reality
Mobile GPUs operate under thermal and power constraints that desktop GPUs ignore entirely. A scene that runs at 60 FPS on a desktop can drop to 20 FPS on a phone after 30 seconds of sustained rendering as the GPU thermally throttles.
Our mobile-specific rules at Simplified are non-negotiable for any project targeting phone or tablet browsers.
Many phones report a 3x pixel ratio. Rendering at 3x means nine times the pixel fill compared to 1x. Cap at 2x with Math.min(window.devicePixelRatio, 2). The visual difference between 2x and 3x is negligible. The performance difference is massive.
Shadow map generation requires an additional full-scene render pass. On mobile GPUs with limited bandwidth, this alone can halve your frame rate. Use baked shadows or ambient occlusion instead. Your users will not notice the difference. Your frame counter will.
If desktop uses 2048x2048 textures, mobile gets 1024x1024. Detect device capability at init and load the appropriate texture set. KTX2 with Basis Universal compression makes this practical — the same source texture transcodes to the optimal format for each GPU.
Desktop budgets allow up to 100 draw calls. Mobile budgets should target under 50. Use aggressive geometry merging, instancing, and LOD to hit this target. If your scene cannot be simplified below 50 draw calls, simplify your scene design — not your optimization strategy.
Shader Optimization
Custom shaders unlock visual effects that no built-in material can match. They also unlock entirely new categories of performance problems. Every fragment shader runs once per pixel per frame. On a 1080p display, that is two million shader executions per frame. On a 4K display, eight million.
Keep fragment shaders short and avoid dependent texture reads — texture lookups whose UV coordinates are computed from another texture read. Use lowp and mediump precision qualifiers where full highp precision is unnecessary. Avoid branching in shaders — GPUs execute both branches and discard one, so an if-statement costs the same as running both paths.
Loading Strategies: GLTF and Draco Compression
GLTF is the standard format for loading 3D models in Three.js. It supports meshes, materials, animations, and scene hierarchy in a single file. Use the binary variant (GLB) for production — it is a single file that loads faster than multi-file GLTF with external references.
Draco compression reduces mesh file sizes by 80 to 95 percent. A 10MB GLB file becomes 500KB to 2MB after Draco compression. The trade-off is CPU decode time at load — typically 50 to 200 milliseconds depending on mesh complexity. For any model over 1MB, Draco compression is non-negotiable.
Asset Loading Pipeline
Three.js vs Babylon.js vs Raw WebGL
We get asked this constantly. Here is our honest assessment based on shipping production projects with all three.
| Factor | Three.js | Babylon.js | Raw WebGL |
|---|---|---|---|
| Learning Curve | Gentle | Moderate | Steep |
| Bundle Size | ~150KB gzipped | ~300KB gzipped | 0KB (native) |
| Ecosystem | Largest | Strong | DIY |
| Physics Built-In | No (add-on) | Yes (Havok, Ammo) | No |
| React Integration | R3F (excellent) | Limited | Manual |
| Best For | Creative web, product viz | Game engines, CAD | Maximum control |
We default to Three.js because the React Three Fiber ecosystem gives us declarative 3D rendering inside Next.js — which is our standard web framework. The same asset pipeline that feeds our Roblox projects can export optimized GLTF for web delivery with minimal pipeline changes.
Real-World Lessons From Simplified's Projects
We have shipped 3D web experiences for product visualization, interactive storytelling, and game-adjacent marketing pages. The recurring lesson is always the same: optimize for the worst device your users actually have, not the best device you test on.
Our Adellion Forge pipeline was originally built for Roblox asset optimization, but the principles transfer directly to web 3D. LOD generation, texture compression, mesh optimization — these are universal concerns. When we need web-ready 3D assets, the Forge exports GLTF alongside Roblox-native formats. One source of truth, multiple delivery targets.
Atomic Answer Blocks
What is the biggest performance bottleneck in Three.js?
Draw calls. Each unique mesh-material combination generates a separate GPU command from the CPU. Reducing draw calls through geometry merging, instanced rendering, and texture atlasing has more impact than any other single optimization. Target under 100 draw calls for desktop, under 50 for mobile.
Should I use Three.js or Babylon.js for web 3D?
Three.js if you want the largest ecosystem, React integration via R3F, and the gentlest learning curve. Babylon.js if you need built-in physics, a visual editor, or are building something closer to a full game engine. Raw WebGL only if you need absolute maximum control and have the team to maintain it.
How do I make Three.js run well on mobile?
Cap pixel ratio at 2x, disable shadow maps, halve texture resolutions, keep draw calls under 50, and use KTX2 compressed textures. Mobile GPUs thermally throttle under sustained load, so what runs at 60 FPS initially may drop to 20 FPS after 30 seconds without these optimizations.
What texture format should I use for WebGL?
KTX2 with Basis Universal compression for production 3D scenes. It transcodes to the optimal GPU-native format at load time — BC7 on desktop, ASTC on iOS, ETC2 on Android. File sizes are smaller than PNG, and the GPU can sample directly from the compressed data without full decompression.
What is Draco compression and should I use it?
Draco is a mesh compression library from Google that reduces 3D model file sizes by 80 to 95 percent. A 10MB model becomes 500KB to 2MB. The trade-off is 50 to 200 milliseconds of CPU decode time at load. For any model over 1MB in production, Draco compression is essential for acceptable load times.
How does instanced rendering work in Three.js?
InstancedMesh renders thousands of copies of the same geometry with a single draw call. You provide a per-instance transformation matrix that positions each copy. Five thousand trees as separate meshes is 5,000 draw calls. Five thousand trees as instances is one draw call. The GPU handles parallel instance rendering natively.
Frequently Asked Questions
Is WebGPU ready to replace WebGL? +
Not yet. WebGPU is available in Chrome and Edge, but Safari and Firefox support remains incomplete as of early 2026. For production applications that need to work everywhere, WebGL 2.0 via Three.js is still the practical choice. WebGPU will eventually replace WebGL, but the transition will take years. Build for WebGL today and architect your code so the renderer can be swapped later.
Can I use React Three Fiber in production? +
Absolutely. R3F is our default for any Three.js project that lives inside a React or Next.js application. The declarative API integrates cleanly with component lifecycles, state management, and server-side rendering. Performance is identical to vanilla Three.js — R3F is a thin React reconciler, not an abstraction layer that adds overhead.
How do I debug WebGL performance issues? +
Start with renderer.info in Three.js — it exposes draw call count, triangle count, and texture count per frame. Use Chrome DevTools Performance tab to identify long frame times. For GPU-specific profiling, use Spector.js to capture and inspect individual WebGL calls. Most issues trace back to draw call count, texture memory, or shadow map generation.
What polygon count should I target for web 3D models? +
For hero objects that are the focal point of the scene, 50,000 to 100,000 triangles is reasonable. For environment props, aim for 500 to 5,000 triangles each. Total scene budget should stay under 500,000 triangles for broad device compatibility. Use LOD to serve lower counts to weaker devices and distant cameras.
Should I use post-processing effects like bloom or SSAO? +
Use them sparingly and gate them behind device capability detection. Bloom and tone mapping are relatively cheap. SSAO, depth of field, and motion blur require additional full-screen render passes that can double your frame time. On mobile, disable all post-processing by default. On desktop, offer quality tiers that users can adjust.
Building 3D web experiences that actually perform is not about finding one magic optimization. It is about systematic discipline across every layer — geometry budgets, material consolidation, texture compression, lighting constraints, and device-aware rendering. The same principles we apply in the Adellion Forge pipeline for Roblox translate directly to WebGL. Optimize for the worst device your users have. Measure everything. Ship fast, render faster.