Back to Blog
Technicalwebglthreejs3d

3D in the Browser: How WebGL and three.js Render Models

A grounded explainer of how WebGL and three.js render 3D models in the browser, traced through loopaloo's 3D Model Viewer: scene setup, file parsing, normals, GPU rasterization, lighting rigs, orbit controls, and why it all runs locally with no plugin.

LoopalooPublished June 19, 202610 min read

Twenty years ago, viewing a 3D model in a browser meant installing a plugin: a Java applet, a Flash widget, a Unity web player. Each one was a security liability and a maintenance headache, and they all eventually died. Today you pick a .glb file, and a lit, rotatable model appears with nothing installed. The thing that made that possible is WebGL, a browser API that hands JavaScript a direct line to your graphics card.

This post walks through what actually happens between "the user picks a file" and "a lit, rotatable model appears on screen." We will use loopaloo's 3D Model Viewer as the concrete reference, because it is a small, honest implementation of the standard render pipeline: it builds a scene, parses a model file into geometry, attaches materials and lights, and runs a render loop. Every code snippet below maps to real code in src/components/tools/visualization/ThreeDModelViewer.tsx, which is built on three.js version 0.182 (the three dependency in package.json).

The short version: WebGL is the low-level GPU interface, three.js is the library that makes WebGL usable by humans, and the model file is just a structured list of numbers describing points in space and how to color them.

The render pipeline in a handful of objects

Almost every real-time 3D renderer, from a AAA game engine to a 200-line viewer, is organized around the same handful of concepts. three.js names them directly, which makes the structure easy to see.

ObjectWhat it isIn the viewer
SceneThe container; a graph of everything to drawnew THREE.Scene() with a background color
CameraThe viewpoint and projectionPerspectiveCamera(50, aspect, 0.1, 1000)
MeshGeometry plus a materialBuilt by the file loader
MaterialHow a surface responds to lightMeshStandardMaterial
LightWhere illumination comes fromDirectional, ambient, hemisphere, point
RendererThe thing that draws scene + camera to a canvasWebGLRenderer

Here is the initialization, close to the actual code:

const scene = new THREE.Scene();
scene.background = new THREE.Color('#1f2937');

const camera = new THREE.PerspectiveCamera(
  50,                                   // vertical field of view, degrees
  containerWidth / containerHeight,     // aspect ratio
  0.1,                                  // near clip plane
  1000                                  // far clip plane
);
camera.position.set(3, 3, 3);

const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setSize(width, height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));

A few of those numbers carry real meaning. The field of view (50 degrees) controls how much of the world the camera sees; wider values exaggerate perspective, like a wide-angle lens. The near and far planes (0.1 and 1000) define the depth range the GPU will bother to render. Anything closer than 0.1 units or farther than 1000 gets clipped. That range is not free: the depth buffer that decides which surface is in front has finite precision, so a near plane set absurdly small relative to the far plane causes "z-fighting," where two surfaces flicker because the GPU cannot decide which is closer.

The setPixelRatio(Math.min(window.devicePixelRatio, 2)) line is a small but deliberate choice. On a Retina display, devicePixelRatio might be 3, which means rendering at 3x resolution and pushing nine times the pixels of a 1x display. Capping at 2 keeps things sharp while protecting the frame rate on high-density screens.

What a glTF or OBJ file actually contains

A 3D model file is not a picture. It is a description of a surface, and the format determines how much of that description is included.

An OBJ file is plain text. Each line is a small instruction: v 1.0 2.0 3.0 declares a vertex at a point in space, vt declares a texture coordinate, vn declares a normal vector, and f 1 2 3 declares a face connecting three vertices. That is essentially the whole format. It is human-readable and ancient, which is also its weakness: OBJ stores geometry but materials live in a separate .mtl file that is easy to lose, and it has no concept of animation, scene hierarchy, or compression.

This is why the viewer special-cases OBJ. After parsing, it checks every mesh and substitutes a default gray material when none was found:

const defaultMaterial = new THREE.MeshStandardMaterial({
  color: 0x888888, metalness: 0.2, roughness: 0.6,
});
model.traverse((child) => {
  if (child.isMesh) {
    if (!child.material || child.material.type === 'MeshBasicMaterial') {
      child.material = defaultMaterial;
    }
  }
});

STL gets the same treatment for a different reason. An STL file has no material concept at all, so the viewer wraps the parsed geometry in a Mesh with an equivalent default MeshStandardMaterial (the same 0x888888 / 0.2 / 0.6 values). Either way, the model arrives with a sensible PBR surface to light.

A glTF file (and its binary cousin .glb) is the modern answer and is often called "the JPEG of 3D." It is a JSON scene description that references binary buffers of vertex data, and it packs in everything OBJ leaves out: a node hierarchy, PBR (physically based rendering) materials, embedded textures, skeletal animation, and morph targets. The .glb variant bundles the JSON and all binary buffers into one file, which is why it is the format most viewers prefer. The viewer loads it with three.js's GLTFLoader, parsing from an in-memory ArrayBuffer so the file never leaves the browser:

const { GLTFLoader } = await import('three/examples/jsm/loaders/GLTFLoader.js');
const loader = new GLTFLoader();
const gltf = await new Promise((resolve, reject) => {
  loader.parse(arrayBuffer, '', resolve, reject);
});
model = gltf.scene;

OBJ takes a slightly different path: its ArrayBuffer is run through a TextDecoder first, because OBJ is text, and the decoded string is handed to OBJLoader.parse. The viewer also handles STL (geometry only, the standard for 3D printing, no color or units) and FBX (a proprietary Autodesk format common in game and animation pipelines). All five extensions end up as the same thing inside three.js: a tree of meshes, each holding a BufferGeometry (typed arrays of vertex positions, normals, and texture coordinates) and one or more materials.

Normals, and why a model can look flat or wrong

Lighting math needs to know which way each surface faces. That direction is the surface normal, a unit vector perpendicular to the face. STL and some OBJ exports omit normals, and without them every surface receives light identically and the model looks flat or pitch black. The viewer guards against this by computing normals when they are missing:

if (child.geometry && !child.geometry.attributes.normal) {
  child.geometry.computeVertexNormals();
}

computeVertexNormals() walks the faces, averages the geometric normals of every face touching each vertex, and stores the result. It is the difference between a sphere that looks smooth and one that looks like a stack of flat plates.

From triangles to pixels: GPU rasterization

Once geometry and materials exist, the renderer's job is to turn 3D triangles into 2D pixels. This is the part WebGL exposes directly, and it is worth understanding even when a library hides it.

Every frame, for every triangle, the GPU runs two small programs called shaders. The vertex shader runs once per vertex and transforms its position through a chain of matrices: model space (the object's own coordinates) to world space (where it sits in the scene) to view space (relative to the camera) to clip space (the camera's projection). three.js builds those matrices for you; that is what wrapper.updateMatrixWorld(true) and camera.updateProjectionMatrix() are doing under the hood.

After the vertex shader places the three corners of a triangle on screen, rasterization fills in the pixels between them. For each covered pixel, the GPU runs the fragment shader, which computes the final color. This is where the material and lights meet: a MeshStandardMaterial runs a PBR lighting equation that combines the surface's base color, its metalness and roughness, the surface normal, and every light in the scene to produce one RGB value per pixel.

The reason this stays smooth is parallelism. A modern GPU has thousands of cores, and rasterization is embarrassingly parallel: every pixel's color is computed independently, so the hardware computes large batches of them at once. A model with, say, 50,000 triangles is not drawn one triangle at a time on the CPU; the vertex data is uploaded to GPU memory once, and the GPU chews through it. The browser's only job each frame is to issue a handful of draw calls and let the silicon do the work.

The viewer also enables soft shadows, which add a second pass:

renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;

Shadow mapping renders the scene a second time from each shadow-casting light's point of view, recording depth into a texture. During the main pass, each pixel checks that depth map to ask "is something between me and the light?" PCF (percentage-closer filtering) samples the map at several nearby points and averages the result, which softens the otherwise jagged shadow edge.

Lighting presets are just light rigs

The viewer ships four lighting presets, and they are a clean illustration of how scene lighting works in practice: there is no single "correct" light, only a rig of several lights balanced against each other. The studio preset is the classic three-point setup used in photography:

scene.add(new THREE.AmbientLight(0xffffff, 0.4));        // base fill everywhere

const key = new THREE.DirectionalLight(0xffffff, 1);     // main light
key.position.set(5, 5, 5);
key.castShadow = true;
scene.add(key);

const fill = new THREE.DirectionalLight(0xffffff, 0.3);  // softens shadows
fill.position.set(-5, 0, -5);
scene.add(fill);

const back = new THREE.DirectionalLight(0xffffff, 0.2);  // rim / separation
back.position.set(0, 5, -5);
scene.add(back);

The outdoor preset swaps in a HemisphereLight (sky color from above, ground color from below) plus a warm directional "sun." The dark preset goes the other way: a dim ambient plus a single PointLight with a falloff distance, so the model emerges from a near-black background like a product shot. The flat preset is the simplest of all: a single full-intensity AmbientLight and nothing else, which removes all shading so you see pure base color, useful for inspecting geometry without lighting distractions. Each preset is applied by first removing the existing lights from the scene and then adding the new set, so switching is instant.

Orbit controls and the render loop

A static render is not a viewer. The interaction (drag to rotate, scroll to zoom, right-drag to pan) comes from three.js's OrbitControls, an add-on that translates mouse and touch events into camera movement around a target point.

const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;

Damping is the detail that makes it feel good. With enableDamping on, releasing the mouse does not stop the camera instantly; it glides to a halt over a few frames, which reads as momentum. That smoothing requires controls.update() to be called every frame, which brings us to the render loop:

const animate = () => {
  animationIdRef.current = requestAnimationFrame(animate);
  controls.update();          // apply damping, auto-rotate
  renderer.render(scene, camera);
};
animate();

requestAnimationFrame is the right tool here, not setInterval. The browser calls it in sync with the display's refresh (typically 60Hz, higher on some monitors), pauses it when the tab is backgrounded to save battery, and never schedules a frame faster than the screen can show it. The loop's whole job is: update the controls, then draw scene-from-camera. That single render() call is what triggers the GPU rasterization described above.

When a new model loads, the viewer also frames it automatically. It wraps the model in a group, measures its bounding box, recenters it on the origin, and scales it so the largest dimension is about 2 units. Then it positions the camera at a distance proportional to the model size so the object fills the viewport regardless of whether the source file used millimeters or meters:

const box = new THREE.Box3().setFromObject(wrapper);
const maxDim = Math.max(size.x, size.y, size.z);
const scale = maxDim > 0.001 ? 2 / maxDim : 1;
wrapper.scale.setScalar(scale);

This is why a tiny 3D-printed bolt and a building-sized architectural model both show up at a sensible size.

Why it all runs locally with no plugin

Two properties make this work without a server round-trip or an install. First, WebGL is a built-in browser API backed by the GPU already in the machine; there is nothing to download because the rendering hardware and the driver are already present and the browser exposes them through a standard interface. Second, the entire pipeline above runs on data the browser already has in memory. The viewer reads the uploaded file with file.arrayBuffer(), parses it in JavaScript, and uploads the resulting geometry to the GPU. The file is never sent anywhere, which is both a privacy property (your model stays on your machine) and a latency property (no upload, no wait).

The cost model is honest about its limits. Parsing happens on the CPU, so a multi-hundred-megabyte FBX will take a moment and a chunk of memory; the GPU can only hold so much geometry; and very high triangle counts will eventually drop the frame rate on a weak integrated GPU. But for the overwhelming majority of models people actually inspect, those limits are far away, and the GPU's parallelism keeps the orbit smooth.

Try it

If you want to see the pipeline in action, the 3D Model Viewer on loopaloo runs exactly the code described here. Pick a .glb, .gltf, .obj, .stl, or .fbx file, switch between the studio, outdoor, dark, and flat lighting rigs, toggle wireframe to see the raw triangle mesh, and read off the vertex and face counts the loader reports. Everything renders on your own GPU, in your own browser, with nothing uploaded and nothing installed.

Related Tools

Related Articles

Try Our Free Tools

200+ browser-based tools for developers and creators. No uploads, complete privacy.

Explore All Tools