How to Diagnose a Slow 3D Model
Use a repeatable decision tree to isolate network, decode, memory, CPU submission, vertex, fragment, shader, and lifecycle bottlenecks.
“The model is slow” can mean the download waits, the page freezes during decode, the first rotation hitches, every frame stutters, memory grows after navigation, or the device becomes hot and degrades. Each symptom points to a different experiment. Changing random export settings makes the result harder to explain.
Start by reproducing the issue in a production build on an affected device. Record one baseline. Then use large diagnostic controls that isolate a pipeline stage before spending time on fine tuning.
Write the symptom as an observable event#
Replace “slow” with a sentence that another person can verify:
- the GLB request completes after 9 seconds on the defined network profile;
- input freezes for 1.2 seconds after response completion;
- the first appearance of glass causes a 300-millisecond frame;
- steady orbit has a median 28-millisecond GPU frame;
- memory grows 45 MiB after every product switch;
- frame time begins at 14 ms and reaches 24 ms after 8 minutes;
- WebGL context is lost while loading the third variant.
Name the device, browser or engine, asset revision, URL, camera, resolution, cache state, and scene. Without those dimensions, two people can discuss different failures under one label.
Capture a short video with an on-screen timestamp or frame overlay when the defect is temporal. A profiler trace is easier to interpret when the visible hitch is known.
Establish a clean baseline#
Use the shipping bundle and hosting path. Development builds can add validation, double invocation, source maps, logging, and hot-reload behavior. Local files remove network and server behavior. A powerful developer workstation can hide low-tier decode and thermal limits.
For startup:
- clear HTTP, service-worker, and in-memory asset caches;
- reload from a stable production-like URL;
- capture network and main-thread timelines;
- mark response, decode, scene creation, upload, first frame, and interaction;
- save peak and stabilized memory estimates;
- repeat at least five times.
For runtime:
- wait for intended startup and shader preparation to finish;
- run a fixed orbit or interaction path;
- capture CPU and GPU frame timing;
- include shadow, transparency, and variant states;
- run long enough to reveal thermal change;
- repeat with the same camera and output resolution.
Do not compare one cold run with one warm run.
Branch one: the network is late#
In the network waterfall, inspect when the request starts and how long it waits, connects, receives the first byte, and transfers.
If the request starts late, investigate:
- viewer scripts discovered late in the document;
- client-only component mounting;
- user intent policies and intersection observers;
- chained manifest or API requests;
- preload opportunities for an immediately required model;
- main-thread work delaying the code that starts fetch.
If time to first byte is high, investigate server region, origin processing, cache misses, authentication, redirects, and CDN behavior. If transfer dominates, inventory asset bytes, throughput, competing requests, compression, and caching.
Control: replace the GLB URL with a 10 KB valid model hosted on the same route. If request scheduling and response remain late, mesh optimization is not the first fix. If timing collapses, payload or server transfer is implicated.
Check content length and transferred bytes. A service worker or application cache can make the network panel look fast while still performing expensive local processing.
Branch two: the response finishes but the UI freezes#
A long main-thread task after response suggests parsing, scene construction, image handling, upload preparation, or application code. Inspect the performance trace's call stacks and worker activity.
Use four replacement controls:
- same scene with uncompressed simple geometry;
- same geometry with 4 × 4 placeholder images;
- a one-node GLB with the original image set tested separately;
- original asset decoded in a worker-supported configuration.
If placeholder images remove the freeze, image decode or upload is likely. If a one-primitive model removes it while bytes stay similar, scene-object construction or primitive fragmentation may be responsible. If worker configuration moves time off the main thread but wall time remains, interaction can improve even without faster completion.
Look for one task longer than 50 milliseconds and for repeated medium tasks. A 700-millisecond monolith visibly blocks input; 40 tasks of 20 milliseconds can also starve frames.
Do not assume WebAssembly means nonblocking. WebAssembly can execute on the main thread unless the integration uses workers.
Branch three: only the first view or first feature hitches#
One-time hitches often come from deferred shader compilation, texture upload, environment processing, bounds calculation, or pipeline creation.
Rotate through the asset once, then repeat the identical path. If the second pass is smooth, the issue is preparation rather than recurring complexity. Toggle one feature at a time:
- shadows;
- normal mapping;
- skinning or morphs;
- transparency;
- environment lighting;
- clear coat, transmission, or other extensions;
- selection outline or post effects.
Record the exact material or pass first visible during the spike. A single rare variant can be warmed during an explicit loading stage, simplified, or deferred until user intent. Precompiling every theoretical variant may create a longer startup and wasted programs.
Check texture dimensions during the hitch. First use of a 4096 image can trigger a substantial upload and mip operation. Stage uploads or lower the resource, then confirm the trace changes.
Branch four: steady frames are CPU-bound#
Indicators include long script or render-submission time, many draws, high object traversal, and little response to lower pixel resolution. The GPU may finish quickly while the CPU misses the deadline.
Run controls:
- disable rendering but keep application updates where possible;
- replace every material with one simple shared program;
- hide half the scene objects without changing resolution;
- freeze animation and matrix updates;
- instance repeated geometry;
- merge a test cluster of compatible primitives;
- disable UI and third-party script work for diagnosis.
If one shared material helps, state and program changes or material setup are suspects. If hiding objects helps in proportion to count rather than triangles, traversal and submissions matter. If freezing animation helps, inspect joint, morph, physics, and reactive state work.
Count draws by pass. A main-view count of 300 can become 900 with two shadow passes. Count scene nodes, primitives, materials, programs, and visible instances. Find tiny primitives with few triangles per draw.
Inspect application allocations. Creating vectors, arrays, cloned materials, or React state objects every frame can cause garbage collection. Reuse safe temporary objects and keep frame-loop state outside render-driven component churn where appropriate.
Branch five: steady frames are vertex- or geometry-bound#
Replace the model with a candidate containing 10% of the triangles while preserving material and screen coverage. Disable skinning or morphing in another diagnostic build if the asset type permits.
A large GPU improvement implicates vertex or triangle work. Investigate:
- rendered vertices after seams;
- triangles and instance multiplication;
- shadow and depth passes;
- small or degenerate triangles;
- skin influences and morph targets;
- missing LOD;
- geometry outside the useful camera;
- lack of culling because of giant primitives.
Wireframe the exact view. If flat regions contain subpixel triangles, generate a reduction ladder. Protect silhouette, deformation, openings, and material boundaries. Profile each passing candidate in the full scene.
If geometry reduction changes nothing, stop decimating. The bottleneck lies elsewhere or the test did not affect the relevant pass.
Branch six: steady frames are fragment-bound#
Halve render width and height. Pixel count falls by 75%. If GPU frame time improves sharply, investigate per-pixel work.
Replace materials with a cheap opaque shader. Disable transparency, shadows, lights, environment sampling, and post-processing individually. Use an overdraw visualization if available.
Common causes include:
- large transparent layers rendered back to front;
- double-sided foliage or glass;
- expensive material extensions over broad screen areas;
- too many texture samples;
- high dynamic-resolution or device pixel ratio;
- multiple shadow-filter samples;
- full-screen bloom, antialiasing, or tone effects;
- objects inside objects producing hidden overdraw.
A 2-triangle blended plane covering the screen can be more fragment-expensive than a 100,000-triangle opaque object occupying a small region. Triangle count is not the deciding metric.
Use alpha mask instead of blend when hard coverage is acceptable. Render opaque surfaces front to back where the engine permits. Reduce pixel ratio or adopt dynamic resolution with stable hysteresis. Simplify the shader on distant LODs.
Branch seven: memory grows or the context is lost#
Record resource counts before load, after load, after disposal, and after several route cycles. Watch unique textures, geometries, materials, programs, render targets, typed arrays, and event listeners.
Diagnostic controls include:
- switch between two tiny models 20 times;
- switch between two production models 10 times;
- reuse the same stable URL versus cache-busting URLs;
- explicitly dispose scene-owned resources;
- remove the entire renderer and context at route exit;
- serialize loads instead of overlapping old and new scenes.
If counts form a staircase, find the owner retaining references. Cached resources are not automatically leaks, but caches need bounds and eviction policy. Shared resources must not be disposed while another scene uses them; scene-owned resources must leave when the scene does.
During a context loss, log resource estimates and lifecycle events. Test restoration in development. A retry loop that recreates duplicate scenes can worsen the failure.
Branch eight: performance degrades over minutes#
Suspect thermal throttling, accumulation, background work, or changing scene state. Plot frame time, memory, object count, and quality tier against elapsed time.
Use a repeatable cool start and fixed brightness. Run 10–20 minutes. If frame time worsens while resource counts remain flat, thermal or power limits are likely. If counts grow, investigate lifecycle. If quality rises automatically, the adaptation policy may be too optimistic.
Reduce continuous GPU load through resolution, shadows, material cost, overdraw, or LOD. A short burst optimization such as faster network decode will not fix a thermal steady-state problem.
Use hysteresis for adaptive quality. Do not switch from high to low and back every few seconds around one threshold. Require sustained evidence and gradual recovery.
Inspect the asset itself#
Alongside runtime traces, build a GLB report:
- container and component bytes;
- node, mesh, primitive, and material counts;
- triangles and rendered vertices;
- attributes, indices, morphs, skins, and animation;
- images with dimensions, channels, and formats;
- required and used extensions;
- bounds, dimensions, and outliers;
- opaque, masked, blended, and double-sided primitives;
- repeated geometry and material signatures.
Run the Khronos validator. Validation finds structural and conformance issues, not performance acceptance. A valid 500 MB file with 20,000 primitives is still a poor mobile asset.
Render a neutral turntable as the visual baseline. Every diagnostic simplification that becomes a shipping candidate needs a fixed comparison.
Keep an experiment log#
For each change, record hypothesis, one variable changed, build hash, device, metrics, and conclusion. For example:
“Hypothesis: fragment-bound due to blended glass. Change: glass hidden, all else unchanged. Result: GPU median 22.4 → 13.1 ms; CPU unchanged. Conclusion: investigate glass coverage, sidedness, and shader.”
This is more useful than “optimized materials, 40% faster,” which does not say what changed or which time improved.
Revert failed diagnostic changes. Do not accumulate five controls into a branch and then guess which mattered.
Decision sequence#
- Define the exact symptom and reproducible scene.
- Capture network, main-thread, memory, CPU frame, and GPU evidence.
- Decide whether the problem is startup, first use, steady frame, lifecycle, or thermal.
- Apply the largest safe isolation control for that branch.
- Confirm the expected metric moves.
- Build a production-quality intervention.
- Recheck every acceptance axis for regression.
- Document the cause and add a gate or benchmark.
Diagnostic checklist#
- The issue is phrased as a measured observable event.
- Device, build, URL, camera, cache, and resolution are fixed.
- Production behavior is separated from development overhead.
- Cold and warm startup captures are labeled.
- First-use hitches are tested on a repeated camera path.
- CPU and GPU frame timing are distinguished.
- Resolution, material, geometry, and object-count controls isolate stages.
- Draws are counted by pass, not only in the main view.
- Peak and repeated-navigation memory are measured.
- Sustained tests check thermal behavior.
- Each experiment changes one meaningful variable.
- The final fix improves the original user-visible symptom.
Sources and further reading#
- Chrome Developers: performance panel
- MDN: Performance API
- MDN: WebGL best practices
- Three.js documentation: WebGLRenderer.info
- Khronos glTF Validator
Diagnosis is the shortest path from “slow” to a fix that survives review. Name the failed moment, isolate one pipeline stage with a strong control, and require the production intervention to move the same metric on the affected device.