中文 English

Jining Mido Information Technology Co., Ltd

Draw Calls from 3,064 to 837: A Full Browser 3D Performance Cleanup, Start to Finish

draw call optimizationThree.js performancebrowser 3D stuttershader compilation freezeGLB Worker parsing3D performance budgetgeometry batchingWebGL draw callsself-hosted 3D world

A browser-based 3D world hits the same set of symptoms once it grows: the screen freezes for a few seconds on entry, the camera stutters when you turn, and adding one prop stutters the whole scene. The instinctive reaction is to delete models and shrink the scene — but shrinking the scene means shrinking the product. This article breaks one complete performance cleanup into pieces: how the symptoms were attributed, the five changes made, how much each metric moved, and how it was verified. Every number comes from acceptance scripts in the repository that you can re-run — none are estimates. This cleanup is already built into the Genesis foundation; if you are building your own 3D world from scratch, you can apply the attribution method directly.

One: Attribute Before You Touch Anything

In browser 3D, draw calls hit the wall before triangle counts do: every submission from CPU to GPU is one draw call, and once objects multiply and materials diversify, the submission count alone eats the frame budget. A scene with two thousand objects can collapse the frame rate on two or three thousand submissions, even if every object is tiny.

The step worth more than any fix comes before fixing: measure the cost accurately. Our early instinct was to use visible=false to hide objects and save cost — testing showed this metric is unreliable in this project: three modules take over visibility every frame, so a hidden object may still sit in the render pipeline costing money. The authoritative measurement became per-object counting in onBeforeRender, packaged into a diagnostic trio: diagTurnLag (camera-turn lag attribution), diagRenderCost (per-object render cost), and diagTurnSweep (full-scene sweep statistics). Without this step, none of the later changes can be held accountable to a number.

Two: The Five Changes

1. Geometry batching: cut the submission count

Static meshes sharing materials get merged into batched submissions, so the GPU takes fewer "phone calls." This is the direct treatment for the draw call count: in the densest zone, draw calls went from 3,064 to 837 (−73%).

2. Point-light pool: never let one new lamp recompile the whole scene

WebGL compiles shader program variants per light configuration. When the number of lights changes, every material recompiles. On the ANGLE/D3D11 path in Windows, shader reflection is synchronous and uninterruptible at 160–340 ms per program — one light-count change measured an 8,989 ms full-scene freeze.

The fix is a light pool: 12 resident lights, rented and returned, so the scene's light count never changes and neither does the recompilation. Measured freeze from light-count changes: 8,989 ms down to zero spikes. The methodology: turn uncontrollable driver behavior into a budgetable resource.

3. Move GLB parsing into a Worker

Parsing (JSON and binary unpacking, texture decoding) on the main thread stalls the render loop — that is the "frozen for seconds on entry" feel. The fix: move all parsing into a Worker with zero-copy transferable data, pre-downscale textures in OffscreenCanvas, and bake static models. Acceptance result: 23/23 parse jobs run in the Worker and render correctly.

4. Warm-up budget: trade "frozen frame" for "model appears a bit later"

The first time a new mesh enters the screen, shader compilation kicks in and blocks the main thread. The fix is a warm-up budget: un-warmed meshes stay off-screen; after compilation, getUniforms() runs on a 170 ms-per-frame budget to spread the shader reflection cost across frames. The trade: models appear slightly later; the payoff: the frame never freezes. First-entry worst frame at spawn: 386 ms, with zero frames above 500 ms.

5. Full-scene placeholder field: something to look at while loading

While models load, a single draw call renders the whole scene as placeholders (covering 1,000+ objects), fading out over 250 ms as each model arrives. Measured: 1,071/1,071 full-scene coverage, and draw calls in that scene dropped from 2,150 to 956 (−56%). Distant views show something; close views show the real model. Visuals and performance don't conflict.

Three: The Numbers Side by Side

SymptomFixMeasured result
Too many submissionsGeometry batchingDense zone draw calls 3,064 → 837 (−73%)
One new lamp freezes the scene12-resident light pool, rent-and-returnLight-change freeze 8,989 ms → 0 spikes
Parsing stalls the main threadGLB parsing in Worker, zero-copy transfer23/23 parsed in Worker and rendered correctly
New objects freeze the frameWarm-up budget, 170 ms/frameFirst-entry worst 386 ms, zero frames > 500 ms
Blank screen while loadingFull-scene placeholder field, 250 ms fade1,071/1,071 coverage, draw calls 2,150 → 956 (−56%)

Four: The Method Is Worth More Than the Numbers

Three reusable judgments came out of this cleanup:

  1. visible=false is not a cost metric. Any module can take over visibility per frame; whether a hidden object actually saves cost is decided by the pipeline's counters, not by the switch in your code.
  2. Turn uncontrollable driver behavior into a budgetable resource. The light pool and the warm-up budget are the same idea applied twice: synchronous compilation at the driver level can't be avoided, so you control when it happens and how much of each frame it gets.
  3. Visuals and performance are not a trade-off. The placeholder field proves "see the rough shape first, the detail later" beats both "wait for everything" and "stare at a blank screen."

Five: How It Was Verified

The repository contains 54 accept_*.js acceptance scripts with real exit codes, JSON reports, and VERDICT ACCEPTED as the pass criteria. Every number in the table above maps to a script you can re-run — after any change, run them once and you know whether something regressed. No vibes.

Six: The Limits of These Numbers

  • The numbers come from this project's dense-zone measurements. Your scene will differ. What transfers directly is the attribution method and the five categories of change — not the numbers.
  • Batching and the light pool treat the "submission and compilation" layer; face counts and texture sizes remain hard costs. That belongs to the asset pipeline (LOD, compression, texture surgery) — a separate article.
  • Mobile, tablet, and PC share the same pipeline, but hardware differences are real: absolute frame rates on low-end devices need per-device measurement.

FAQ

Q: Do these optimizations require modifying engine source code?

A: All of them live at the Three.js application layer; the engine itself is untouched. That is also why they survive engine upgrades.

Q: Does the placeholder field flicker or pop?

A: The placeholder-to-real-model transition is a 250 ms fade-out, not an instant swap; models that aren't ready never abruptly replace their placeholder.

Q: If I'm building my own world, where should I start?

A: Build the diagnostics first (per-object counting), run one sweep to see your draw call and long-frame distribution, then decide what to fix. Copying any single fix without knowing where you're stuck is, most likely, wasted work.

Source & Repository

All three addresses host identical content; the first two are faster within mainland China. The repository includes deployment docs and the acceptance scripts.

  • Gitee (faster in mainland China): https://gitee.com/miduoxinxijeji/miduo.git
  • GitCode (mirror): https://gitcode.com/qq_35054471/virtual-world
  • GitHub: https://github.com/miduo100/3d-virtual-world

About Genesis

Genesis is a self-hosted 3D virtual world system built on Three.js + WebGL, helping individuals and businesses build their own 3D spaces. Accessible directly from a browser, compatible with both PC and mobile, it supports multiplayer online, federated teleportation, a shop system, and Agent integration—where an AI can enter your world as an embodied character. Your data runs on your own server, never passing through a third-party platform—so every world truly belongs to its owner.

Want this performance foundation ready-made instead of re-stepping on the same rakes? Genesis Virtual World CRM is a Three.js 3D virtual world foundation you deploy on your own server — the unglamorous parts (rendering, assets, performance governance, multi-device support) are already laid; you write the top layer. The official site (search "Genesis Virtual World CRM") has a demo world you can walk through.

About the name: Genesis in this article refers to Genesis Virtual World CRM — they are the same self-hosted 3D virtual world product. If "Genesis" doesn't turn us up in search, search "Genesis Virtual World CRM" instead.
← Back to Articles