3D Load Time vs. Runtime Performance
Separate startup acquisition and preparation from recurring CPU and GPU frame work, then verify that each optimization improves the intended experience.
A model can load in 800 milliseconds and render at 18 frames per second. Another can take 8 seconds to prepare and then hold a perfect 60 frames per second. Load performance and runtime performance share resources, but they are different systems with different milestones and fixes.
Confusing them leads to false claims. Draco may improve acquisition while leaving triangles unchanged after decode. Merging primitives may improve CPU frame time while making one larger file or a longer first upload. Streaming can shorten the first view while adding later work. Write separate budgets before optimizing.
Define the phases#
For a web-delivered GLB, startup commonly includes:
- request discovery and scheduling;
- server response and network transfer;
- GLB parsing and reference resolution;
- decoder initialization;
- geometry decompression;
- image decode or texture transcode;
- scene-object creation;
- GPU buffer and texture upload;
- shader compilation;
- first visible render.
Runtime commonly includes:
- input and application logic;
- animation evaluation and transform updates;
- culling and scene traversal;
- draw submission;
- skinning, morphing, and vertex shading;
- rasterization and fragment shading;
- transparency and shadows;
- post-processing;
- garbage collection or streaming jobs that continue in the background.
The boundary is not absolute. A runtime can defer texture upload until a material appears or compile a shader on its first use. Streaming deliberately moves preparation into the interactive phase. The key is to label the work and connect it to a user-visible moment.
Load milestones need product meaning#
“Load complete” is too vague. Define several events:
Request started: the asset URL enters the network scheduler.
Payload acquired: all required initial response bytes have arrived.
Structure ready: hierarchy and coarse bounds exist, allowing a placeholder or framing decision.
First recognizable frame: the user can identify the intended object.
Interaction ready: input responds within the product's latency expectation.
Inspection ready: all detail required by maximum normal zoom is available.
A progressive viewer might reach recognizable at 900 milliseconds, interaction at 1.4 seconds, and inspection at 4.5 seconds. A monolithic loader might report one completion at 3.8 seconds. The first delivers useful feedback sooner even if full fidelity finishes later.
Use performance marks from production code and collect distributions. A median of 1.5 seconds with p95 at 8 seconds indicates many users still encounter a poor wait.
Runtime should be measured in milliseconds#
Frames per second is intuitive but nonlinear. Improving from 30 to 40 fps changes frame time from 33.33 to 25 milliseconds, an 8.33-millisecond gain. Improving from 120 to 130 fps changes about 8.33 to 7.69 milliseconds, only 0.64 milliseconds.
Set a frame budget:
- 60 Hz: approximately 16.67 ms;
- 72 Hz: approximately 13.89 ms;
- 90 Hz: approximately 11.11 ms;
- 120 Hz: approximately 8.33 ms;
- 30 Hz: approximately 33.33 ms.
All application and rendering work must fit. A 60 Hz product cannot allocate 16.67 ms entirely to the 3D model; UI, network callbacks, analytics, layout, and browser composition also exist.
Record median, p95, p99, and longest frame during representative interaction. A stable 14 ms experience feels better than one alternating between 8 and 45 ms even if their average is similar.
An optimization impact matrix#
Different changes affect phases differently.
Geometry compression reduces transfer; adds decode; usually preserves runtime triangle count.
Texture resizing reduces transfer, decode memory, GPU memory, and often upload; can improve sampling; may reduce visual detail.
Mesh simplification can reduce transfer, decode output, buffer memory, upload, vertex work, and rasterization; risks shape and shading.
Material consolidation may reduce images, primitives, compilation variants, and draw submissions; can increase atlas size or reduce culling flexibility.
Instancing reduces duplicated buffers and draw submissions for repetition; does not reduce total visible instance triangles.
Preloading starts acquisition earlier; can compete with more important page resources; does not reduce bytes or runtime cost.
Lazy loading protects initial page work; makes the model wait begin later when requested; does not make the asset intrinsically faster.
Shader precompilation moves compilation before interaction; may extend explicit preparation; does not remove compilation work.
LOD reduces recurring work at smaller screen sizes; adds asset management and may add transfer if all levels are fetched eagerly.
Use this matrix to form a testable hypothesis. “Apply Draco to improve frame rate” is usually the wrong causal model. “Apply Draco to cut cold payload while keeping decode under 200 ms” can be tested.
Network improvements can be invisible after loading#
Once the same decoded resources exist, the GPU does not know whether they arrived as 10 MB raw buffers or 1 MB compressed data. Steady-state frame timing should be nearly unchanged unless the loader produces a different layout or precision.
This is still valuable. Faster transfer can improve conversion, bounce rate, and mobile data use. Just report the right metric.
Test cold and warm separately. An HTTP-cached 10 MB model may appear faster than a newly versioned 1 MB model on a repeat visit. Compare equivalent cache states and use content-hashed URLs to make cache behavior deliberate.
Server compression, CDN placement, priorities, and request discovery can improve acquisition without altering the GLB. Include deployment configuration in performance ownership.
Runtime improvements can worsen startup#
Precomputing LODs into one GLB adds bytes. An optimized vertex layout may need offline processing but normally has no client penalty; a client-side reorder would. Compiling all shaders upfront prevents later hitches but creates a longer loading screen.
Material atlasing can reduce draws while creating one larger image that takes longer to decode. Merging static objects can reduce CPU submission but increase geometry upload and draw hidden regions due to broad bounds.
Record both sides whenever changing scene structure:
- total and initial transferred bytes;
- time to recognizable and interactive states;
- peak loading memory;
- first-use compilation spikes;
- steady CPU frame time;
- steady GPU frame time;
- frame-time percentiles during movement;
- lifecycle and unload behavior.
An optimization is a trade only if the product accepts the regression. Otherwise it is simply a regression hidden by one favorable counter.
Find whether a slow frame is CPU or GPU limited#
If the CPU prepares work slower than the GPU, reducing draw calls, object traversal, scripting, or animation can help. If the GPU is limiting, investigate vertices, fragments, materials, resolution, overdraw, and passes.
Use engine and browser profilers, GPU timer queries where responsibly supported, and controlled experiments. Lowering render resolution to 50% in each dimension reduces pixel count by 75%. A large GPU-time improvement suggests fragment or pixel-related cost. Replacing the model with a low-geometry version tests vertex and triangle contribution. Replacing materials with a simple opaque shader tests shading and texture work.
CPU and GPU can overlap, and synchronization can blur the result. Avoid reading a GPU result immediately in a way that forces a stall. Follow the profiler's documented method and compare several frames.
A CPU-bound application can show low GPU utilization while still missing frame rate. A GPU-bound application can have fast JavaScript frames that wait later for the graphics queue.
Streaming shifts work into runtime#
Streaming is useful when the user needs only part of an asset or quality hierarchy. Load a low LOD and modest textures, then request higher detail based on camera or intent. It reduces initial wait and peak if resources replace rather than accumulate.
But streamed work competes with interaction:
- network callbacks run;
- images decode;
- geometry decompresses;
- resources upload;
- shaders compile;
- old resources need disposal;
- visual levels transition.
Throttle or schedule work so a 400-millisecond decode does not create a visible input freeze. Worker decoding helps, but GPU upload and scene mutation may still occur on the main rendering path.
Define a streaming budget such as no background task longer than 20 ms on the main thread, no more than 40 MiB temporary memory, and no frame over 33 ms during a normal quality upgrade. Values should come from the product and device tier.
Shader compilation belongs in both reports#
Compilation is startup work when explicitly warmed and runtime work when deferred. Material combinations generate program variants for features such as skinning, morphs, normal maps, transmission, shadows, fog, and clipping.
Count distinct program signatures. A model with 50 materials might use 5 programs or 50 depending on features and renderer specialization. Precompile only likely production variants; compiling unused permutations increases wait and memory.
After the first visible frame, move the camera through every region and trigger variant states. Capture the first switch to transparent, animated, or highlighted material. If a hitch occurs once, it is probably deferred preparation; if every frame remains slow, it is recurring workload.
Persisting compiled binaries across sessions is browser- and driver-dependent. Do not base a guaranteed milestone on a warm driver cache.
Garbage collection and lifecycle#
Load pipelines allocate temporary objects and buffers. Releasing references can trigger later garbage collection. A massive cleanup immediately after first render can produce a noticeable pause. Reusing typed arrays or staging work can help, but retained buffers create leaks if ownership is unclear.
Profile the first 30 seconds, not just the load callback. Watch for:
- a large garbage collection after decode;
- source ArrayBuffers retained indefinitely;
- images duplicated between cache layers;
- old route assets still referenced;
- repeated shader and material creation;
- timers or listeners updating removed scenes.
Cycle load and unload at least 10 times. Startup may pass once and fail after accumulation.
A two-part benchmark protocol#
Startup protocol#
- Select a production URL and production build.
- Clear HTTP, service worker, and application caches.
- Use a repeatable network condition and target device.
- Mark request, response, decode, upload, first frame, and interaction.
- Capture the main-thread trace and memory peak.
- Repeat at least five cold runs.
- Repeat warm-cache and in-memory navigation runs separately.
Runtime protocol#
- Wait until required streaming and compilation are complete.
- Use a fixed camera path and interaction script.
- Include the worst normal visibility and material state.
- Capture CPU and GPU frame components.
- Run long enough to reveal thermal throttling.
- Record median, p95, p99, and long frames.
- Repeat with diagnostic material, resolution, and LOD controls.
Do not start runtime measurement while background loading differs between candidates unless that is the experience under test.
Set separate acceptance gates#
An example mobile web specification might require:
- first recognizable model under 2.5 seconds at the defined network profile;
- interaction ready under 3.5 seconds;
- no startup main-thread task above 150 ms;
- peak process growth below a measured safe envelope;
- steady median frame below 16.67 ms on the median tier;
- p95 below 25 ms;
- no frame above 50 ms during standard orbit;
- no memory growth after 10 route cycles.
These are illustrative, not universal. A technical configurator and a decorative hero model have different needs. What matters is explicit thresholds and a repeatable test.
Frequent reporting errors#
Reporting compressed size as a frame optimization. It is a delivery metric unless decoded structure changes.
Reporting average fps from a desktop. It hides low-tier behavior and spikes.
Starting the timer at the loader call. Late script discovery and page competition disappear from the result.
Ending at network completion. Decode, upload, and compilation remain.
Profiling before background work settles. Candidate runs contain different tasks.
Ignoring cold decoder cost. A warm local developer cache misrepresents new users.
Calling streaming free. It relocates work into the session.
Acceptance checklist#
- Startup and recurring frame budgets are documented separately.
- User-visible milestones have stable definitions.
- Network, decode, upload, compilation, and interaction timings are distinct.
- Cold, warm HTTP, and warm in-memory states are not mixed.
- Frame performance is reported in milliseconds and percentiles.
- CPU- and GPU-limited cases are investigated with controls.
- Each optimization has a phase-specific hypothesis.
- Tradeoffs are measured on both startup and runtime axes.
- Deferred compilation and streaming are included in interaction traces.
- Memory and garbage collection are observed beyond the load callback.
- Repeated load/unload cycles remain stable.
- Production devices, builds, hosting, and camera paths are used.
Sources and further reading#
- MDN: Performance API
- Chrome Developers: performance panel
- MDN: EXT_disjoint_timer_query_webgl2
- Three.js documentation: WebGLRenderer.info
- web.dev: optimize resource loading
Load time asks how soon useful 3D becomes available. Runtime performance asks whether every subsequent frame meets its deadline. Measure both, name the user moment each change improves, and reject wins that quietly break the other axis.