three.js (WebGLRenderer r170–r185) and real-time game performance: rules
Scope: WebGLRenderer, Rapier (rapier3d-compat 0.19), browser and Electron. The repository installs three 0.185.1. Every claim marked [src r185] was read from node_modules/three in this checkout. The same code is at https://github.com/mrdoob/three.js/tree/r185/src. Items marked [r170+] are version-specific.
The six fixed bugs mapped to rules
| Bug | Rule that prevents it |
|---|---|
| 1. Physics world serialized each tick for audio | G3, I5 |
2. New FogExp2 each frame |
A1, A2 |
3. renderer.compile() each frame against the canvas while rendering into a composer target |
A3, A7 |
| 4. Four 2048 cascades re-rendered each frame over ~1M triangles | C1–C5 |
| 5. Full-scene depth prepass for soft particles | F3 |
6. 1280x720 HUD CanvasTexture repainted and uploaded each frame |
E1, E2 |
A. Program and material cache
Primary sources: WebGLRenderer.js setProgram/getProgram, WebGLPrograms.js, WebGLLights.js, Material.js, WebGLRenderer docs, MDN WebGL best practices.
How the cache works [src r185]. For each draw, setProgram compares cached material state against the current state. On any mismatch it sets needsProgramChange. Then getProgram calls programCache.getParameters() and builds the string getProgramCacheKey(). A cache hit skips the GLSL compile, but the CPU still builds parameters and a key string for every material on every draw that mismatches. With hundreds of materials, this repeated work alone can take most of a frame.
The triggers checked in setProgram [src r185]:
material.versionchanged. Eachmaterial.needsUpdate = trueincrements it.lights.state.versionchanged (only for materials withneedsLights).outputColorSpacediffers from the value the program was built for.- Instancing, batching, skinning,
instanceColor,morphTexture, orBatchedMesh.colorTexturepresence changed on the object that uses the material. envMapidentity changed. This includesscene.environmentfor Standard, Lambert, and Phong materials.material.fog === true && materialProperties.fog !== scene.fog. This is an object identity check.- Clipping plane count or intersection count changed.
- Vertex alphas, tangents, morph target, morph normal, or morph colour presence, or the morph target count changed.
toneMappingdiffers.lightProbeGridpresence changed. This check is new in recent releases [r180+].
Rules:
- A1. Create one
FogorFogExp2object at load time and mutate.color,.density,.near, and.farin place. A new fog object fails the identity check for every fogged material. Those values are uniforms, so mutating them never recompiles. - A2. Do not switch between
FogandFogExp2, and do not togglescene.fogtonullat runtime. The fog type is part of the program key. To “disable” fog, set the density to 0, as the three.js manual recommends: “dummy values like zero intensity lights, white textures, or zero density fog”. - A3. The program variant depends on the bound render target [src r185]:
- With the canvas (
_currentRenderTarget === null), the program usesoutputColorSpace = renderer.outputColorSpaceandtoneMapping = renderer.toneMapping. - With a render target, it uses
ColorManagement.workingColorSpaceandNoToneMapping.
When a material renders to both the canvas and a composer target, it holds two programs. Each switch between them goes through
getProgramagain. - With the canvas (
- A4. Keep each material on one target class. With an EffectComposer, render everything through the composer and let
OutputPassapply tone mapping and sRGB. Do not render the same scene materials to the canvas as well. Debug views and minimaps should use their own material instances. - A5. Lights:
lights.state.versionincreases only when the light hash changes [src r185]. The hash holds the count of each light type, the shadow-casting count of each type, the spot-map count, and the light-probe count. Adding or removing a light, togglinglight.visible, or togglinglight.castShadowrecompiles every lit material. Animateintensity(0 = off) instead. discoverthreejs tips gives the same rule. - A6. Never set
material.needsUpdate = trueper frame, and never assign a newenvMaporscene.environmentper frame. Change uniforms (color,opacity,roughness, and so on) instead. Uniforms never needneedsUpdate. - A7. Call
renderer.compile()orcompileAsync()once, at load or level start. Bind the same render target the real frame uses first (renderer.setRenderTarget(composer.readBuffer)), then restore it. This prewarms the correct variant [src r185]:getParametersreadsrenderer.getRenderTarget(). A per-framecompile()walks the whole scene. If its target differs from the real render’s target, every material flips between variants twice per frame. - A8. Prefer
compileAsync[r158+, current in r185]. It pollsKHR_parallel_shader_compilefor completion instead of blocking. The docs say “it is recommended to use this version of compile() whenever possible”. The extension is not Baseline (MDN), socompileAsyncfalls back to blocking on browsers that lack it. - A9. Prewarm every variant that gameplay can reach before play starts. This covers shadow on or off, each light count, instanced versus non-instanced use of a shared material, and skinned versus static. Pay one compile per variant during the loading screen instead of a hitch in play.
- A10.
onBeforeCompile: the defaultcustomProgramCacheKey()returnsthis.onBeforeCompile.toString()[src r185]. Two materials whose closures share source text but capture different values get the same program, which produces wrong shaders. OverridecustomProgramCacheKey()to return a short string that encodes the values that change the GLSL. Keep it stable, because a key that changes each frame forces a new program each frame. - A11. Changing
renderer.shadowMap.typeat runtime setsneedsUpdateon every material in the scene [src r185, r182+ code path]. Choose the shadow type once at startup. - A12.
Material.typeis static and read-only [r170+] (migration guide). Code that changedtypeto force updates no longer works. - A13. Detection methods:
renderer.info.programs.lengthmust stay flat during play. Log it each second, because any growth means new variants.- Also watch
renderer.info.render.callsand.triangles. Setinfo.autoReset = falseand callinfo.reset()once per frame when a frame has several passes, such as a composer or shadows (docs). - In a Chrome CPU profile, look for
getProgram,getParameters,getProgramCacheKey, orWebGLProgramin steady state. Any of these in steady state is a bug. - Spector.js captures one frame and shows
linkProgramcalls, redundant state changes, and texture uploads. - Avoid
getError,getProgramParameter, andget*Parameterin production, because each one is a sync round-trip to the GPU process (MDN). Keeprenderer.debug.checkShaderErrors = falsein release builds for the same reason.
B. Draw calls and per-object CPU cost
Sources: BatchedMesh docs, MDN best practices, three.js forum: optimise rendering, discoverthreejs tips, Unity: optimizing draw calls, Object3D source.
- B1. Each visible
Meshcosts at least one draw call, plus uniform uploads, a frustum test, and a matrix update. Shadow passes and depth prepasses repeat the draw call. The CPU cost per draw in WebGL (validation plus the IPC to the GPU process) usually limits performance before triangle count does. Start with a budget of a few hundred draws per frame on integrated GPUs (see J4). - B2. Use
InstancedMeshfor many copies of one geometry and one material (trees, rocks, debris, bullets). Write matrices into the instance buffer and callinstanceMatrix.needsUpdate = trueonly when a matrix changed. UseaddUpdateRangeon the attribute for partial uploads [src r185]. - B3. Use
BatchedMesh[r159+, mature r170+] for many different geometries that share one material. It renders through multi-draw. It hasperObjectFrustumCulledandsortObjects, bothtrueby default (docs). SetsortObjects = falsefor opaque content when CPU-bound, because sorting runs every frame. Moving a mesh into or out of aBatchedMeshorInstancedMeshchanges its program variant (see A). - B4. Merge static geometry (
BufferGeometryUtils.mergeGeometries) by spatial chunk, not across the whole world. One merged mesh is never culled while any part of it is visible. Merged terrain therefore always draws in full and always enters every shadow cascade (forum). - B5. Frustum culling tests
geometry.boundingSphere[src r185 Mesh.js]. The test is cheap per object but useless for huge meshes. If you deform vertices on the GPU or move instances, recompute bounds or set a correct bounding sphere. Do not disablefrustumCulledglobally. - B6.
renderer.rendercallsscene.updateMatrixWorld()each frame whenscene.matrixWorldAutoUpdateis true [src r185]. This walks every object.- For static objects, set
matrixAutoUpdate = falseand callupdateMatrix()after edits. - For static subtrees, also set
matrixWorldAutoUpdate = false. - A cleaner approach is to drive transforms from the sim snapshot and call
updateMatrixWorld()once.
- For static objects, set
- B7. Do not call
scene.traverse,getObjectByName, orgetObjectsByPropertyper frame. Build typed lookup arrays when entities spawn or despawn, then iterate those arrays. - B8. Sort cost:
renderer.sortObjects(defaulttrue) sorts the opaque, transmissive, and transparent lists each frame [src r185 WebGLRenderLists]. Opaque objects sort front-to-back by material and program to reduce state changes. With few objects the sort is cheap. With thousands of tiny objects, batch them first. - B9. Uniform upload cost scales with the material count multiplied by the uniform count. Share material instances. Many
Meshobjects can reference one material, and three.js skips re-uploading when the program and material are unchanged. Unity’s SRP Batcher works on the same principle of “as few shader variants as possible” (Unity SRP Batcher). - B10. Hide objects with
visible = falseinstead of removing and re-adding them. Never hide a light this way (see A5).
C. Shadows
Sources: WebGLShadowMap.js, LightShadow.js, WebGLRenderer docs: shadowMap, CSM addon docs, migration guide r182, discoverthreejs tips.
- C1. Cost model: each shadow map re-renders every
castShadowobject inside its light frustum with a depth material. A point light renders 6 cube faces. N cascades means N full caster passes. The work is (caster draws + caster triangles) × maps. The fragment fill cost is the map size squared. One 2048² map is 4.2M texels, and four of them are 16.8M texels per frame. - C2. Make shadows update on demand:
- Use
renderer.shadowMap.autoUpdate = falseplusneedsUpdate = truewhen something moves. - Per light [src r185], use
light.shadow.autoUpdate = falseandlight.shadow.needsUpdate = true. Use this to update far cascades every Nth frame (staggered) while the near cascade updates each frame.
- Use
- C3. Use caster hygiene:
- Terrain, large static ground, and distant scenery should usually only
receiveShadow. - Small props that cast tiny shadows should not cast.
- Particles, the HUD, and the sky should do neither.
- Use a low-poly proxy mesh as the caster (via layers or a separate invisible caster mesh) for high-poly models.
- Terrain, large static ground, and distant scenery should usually only
- C4. Fit the shadow camera tightly. Texel density is the map size divided by the frustum extent. A loose
shadow.camerawastes resolution and includes more casters. The CSM addon’s defaultmaxFaris 100000 (docs). Set it to the real shadow distance (for example 300–1500 m for a flight sim). Use 2–3 cascades, not 4. Reduce the map size of far cascades if you split the lights yourself. - C5. CSM addon rules:
csm.update()must run each frame beforerender.setupMaterial()patches each material throughonBeforeCompile, so call it once per material, never per frame.updateFrustums()is needed only after camera or CSM settings change. The addon is WebGL-only.CSMShadowNodeis for WebGPURenderer. - C6.
PCFSoftShadowMapis deprecated [r182+].WebGLShadowMaplogs a warning and switches toPCFShadowMap, which is now soft (Vogel disk plus interleaved gradient noise). SetPCFShadowMapdirectly. The type change triggers a full-scene recompile (A11). After an upgrade, re-tunebiasandnormalBias(forum report). - C7. For static worlds, bake the static shadows into lightmaps or AO. Then cast only from dynamic objects into a small, tight frustum that follows the player.
D. Post-processing
Sources: EffectComposer.js, UnrealBloomPass.js, WebGLRenderer docs, MDN best practices, discoverthreejs tips, migration guide.
- D1. Each full-screen pass costs at least one read and one write of the whole target. At 3456×2234 (a 16” Retina display at DPR 2), the target holds 7.7M pixels, and a half-float RGBA target is about 62 MB of traffic per pass. Count passes, and fuse them where possible (one “uber” pass for colour grading, vignette, and grain).
- D2.
EffectComposercreates its targets asHalfFloatTypeby default [src r185]. This is needed for HDR bloom and tone mapping inOutputPass. It is 2× the bandwidth of RGBA8. Keep it for HDR, and use RGBA8 targets for LDR-only passes. - D3.
UnrealBloomPassallocates 1 bright target plus 5 mips × 2 blur targets, all half-float, with blur kernels of 6–22 taps [src r185]. Run bloom at half resolution or lower (bloomPass.resolution), or use a cheaper custom bloom with fewer mips. - D4. Cap the pixel ratio:
renderer.setPixelRatio(Math.min(devicePixelRatio, 1.5–2)), and callcomposer.setPixelRatiowith the same value. Better still, render 3D at a dynamic scale (0.6–1.0 of the canvas) and composite the HUD at native resolution. MDN: “Render to a smaller back buffer” by settingcanvas.width/heightwhile keeping the CSS size. - D5. MSAA versus FXAA and SMAA:
- Canvas MSAA (
antialias: true) is cheap on tile-based GPUs (Apple Silicon included), but has no effect once you render into a composer target. - For a composer, choose one option:
WebGLRenderTarget({ samples: 4 })on the composer’s render target, or a post AA pass. FXAA is the cheapest. SMAA costs 3 passes and 2 lookup textures.SMAAPassno longer takes a width and height [r175+]. - Never stack MSAA with FXAA or SMAA.
- Set
antialias: falseon the context when you use a composer.
- Canvas MSAA (
- D6.
OutputPassshould be the last pass and should own tone mapping and the sRGB conversion.OutputPasssupportsCustomToneMapping[r173+], and autoClear is off in the output pass [r174+]. Leaverenderer.toneMappingas you set it, becauseOutputPassreads it. Rendering the scene to the canvas as well invalidates the material programs (A3). - D7. Use
invalidateFramebuffer(or let three.js do it) for depth and MSAA attachments you will not read back. Storing them costs bandwidth on tile-based GPUs (MDN).
E. Textures and GPU memory
Sources: WebGLTextures.js, Texture.js, MDN best practices, three.js manual: disposing, Khronos KTX Artist Guide, migration guide r170.
- E1.
texture.needsUpdate = trueon aCanvasTexturere-uploads the whole canvas. That upload often includes a colour-space conversion or premultiply pass and a pipeline flush. MDN: DOM-element uploads “incur a processing pass that will temporarily switch GL Programs internally, causing a pipeline flush”. A 1280×720 RGBA canvas is 3.7 MB per upload, plus mipmap generation if enabled. - E2. For a HUD texture:
- Repaint and upload only when the content changes. Use dirty flags for each widget.
- Throttle numeric readouts to 10–15 Hz.
- Prefer DOM or CSS overlays, or SDF text in GPU quads, for fast-changing text.
- Set
generateMipmaps = falseandminFilter = LinearFilteron screen-space textures. - Size the canvas to the HUD region, not the full screen.
- E3.
Texture.updateRanges/addUpdateRangeperforms partialtexSubImage2Duploads [src r185]. It works only forDataTexture-styleimage.data(RGBA). It does not apply toCanvasTexture. - E4. Upload textures before drawing or between passes, never in the middle of the frame (MDN). Stage new assets with
renderer.initTexture(tex)during loading so the first use does not hitch. - E5. Mipmaps:
- Use them for any texture that is minified in 3D. They cost +33% memory but give a large sampling speed-up and less aliasing.
- Turn them off for screen-space and UI textures.
- [r170+] Mipmaps are generated whenever
generateMipmapsistrue, regardless of the filter. Set it tofalseexplicitly when you use a non-mip filter.
- E6. Use KTX2/Basis through
KTX2Loaderfor colour textures, because they stay compressed in VRAM and are faster to sample. Use ETC1S for albedo and UASTC for normal, roughness, and metalness data (Khronos guide). The memory difference is roughly 4–8× against RGBA8. - E7. Set anisotropy (
renderer.capabilities.getMaxAnisotropy(), typically 4–8) on ground and runway textures seen at grazing angles. It costs bandwidth, so leave it at 1 elsewhere. - E8. Prefer RGBA8 over RGB8, because RGB8 is often emulated and slow (MDN). Do not assume float render targets on mobile (check
EXT_color_buffer_float). - E9. Dispose discipline: three.js never frees GPU resources on its own. Disposing a material does not dispose its textures, and render targets and passes have their own
dispose()(manual). Assert thatrenderer.info.memory.geometriesand.texturesreturn to their baseline after a level unload. - E10. Budget VRAM per pixel: choose a VRAM budget, divide it by the maximised drawing-buffer pixel count, and cap caches at that ratio on resize (MDN). Do not exceed 4096² textures for portability.
F. Transparency and particles
Sources: WebGLRenderLists.js, three.js forum: soft particles performance, forum: soft particles depth, Godot: optimizing 3D.
- F1. Transparent objects sort back-to-front by object, not by triangle [src r185], and cannot be batched by material. Keep the transparent object count low. Draw a particle system as one
Points,InstancedMesh, orBatchedMeshwith per-particle sorting on the GPU or in a worker only if you need it. Additive blending needs no sorting at all. - F2. Overdraw is the real cost of particles: fragment work equals the screen coverage times the layer count. Use smaller or fewer quads, tight-fit geometry (polygon cards instead of quads with mostly zero alpha), and render particles into a half-resolution target, then composite.
- F3. Soft particles need scene depth, but never re-render the scene for it. Attach a
DepthTextureto the main (composer) render target, then sample that texture in the particle pass, which renders after the opaque pass. The forum reports a “significant performance boost” from this approach over a separate depth prepass. You can also skip softness for small or far particles, which handles most cases. - F4. Transparent particles:
depthWrite = false,depthTest = true. WithdepthTestoff, particles show through walls. WithdepthWriteon, particles clip each other. - F5. Use
alphaTestand cutout (opaque) materials for foliage and fences. They stay in the opaque list, are sortable, and write depth.
G. Garbage collection and allocation
Sources: discoverthreejs tips, MDN: structuredClone, Chrome DevTools: memory.
- G1. Do not allocate in the frame loop. This means no
new Vector3/Quaternion/Matrix4, no.clone(), no array or object literals, nomap/filter, no closures, and no spread operators. Use module-level scratch objects. GC pauses of 5–30 ms appear as periodic hitches even when the average frame time is low. - G2. Pre-size typed arrays for per-entity data (positions, velocities, render transforms), and reuse event objects from pools.
- G3. Never call
JSON.stringify/parse,structuredClone, orworld.takeSnapshot()in a hot path. Each one is O(world size) per call and allocates garbage of the same size. Answer lookups (for example “where is the listener”, or “which body is the engine”) from maintained indices or handle maps. - G4. QuickJS boundary: crossing the guest/host boundary costs a marshal for each value. Batch script commands into one call per tick, pass typed-array views or numeric IDs instead of object graphs, and do not expose per-frame object snapshots to scripts.
- G5. Check the result with a DevTools Performance recording. Minor GC events must be rare, and the “JS heap” line should be flat without a sawtooth pattern. Use allocation sampling to find the source (Chrome docs).
H. Game loop
Sources: Gaffer On Games, “Fix Your Timestep!”, WebKit bug 236780 (rAF above 60 Hz), Electron BrowserWindow, MDN best practices (flush and rAF).
- H1. Run the sim at a fixed
dt(for example 1/60 s) with an accumulator, and render with interpolationalpha = accumulator / dtbetween the previous and current state. Use slerp for quaternions. With a fixeddt, physics behaves the same at 30, 60, or 120 Hz, and the result is deterministic and replayable. - H2. Prevent the spiral of death: clamp the frame delta (Gaffer uses 0.25 s) and cap the steps per frame (for example 4–5). When over budget, the game slows down instead of locking up.
- H3. Drive the loop from
requestAnimationFrameorrenderer.setAnimationLoop. Read time from the rAF timestamp, notDate.now(). Never assume one callback equals one sim step. Never usesetIntervalfor rendering. - H4. On 120 Hz ProMotion displays, Chrome and Firefox call rAF at 120 Hz, which leaves an 8.3 ms budget for each frame. Desktop Safari and WKWebView may cap rAF at 60 Hz. Choices:
- Render each rAF and keep the sim at 60 Hz with interpolation.
- Or render on alternate callbacks to hold a stable 60 fps when 120 is not reachable. A steady 60 fps feels better than a rate that jumps between 70 and 120.
- H5. Separate the sim from rendering in time: sim (physics plus scripts) → write render transforms → render. Do not let the render code query physics or scripts directly.
- H6. Move work to workers in this order: asset decode (KTX2 transcoding already happens in workers, and so does meshopt decode), pathfinding, and procedural generation. Physics can run in a worker only if you accept one frame of latency and transfer typed arrays (
postMessagewith transferables), never object graphs. - H7. In Electron,
backgroundThrottlingdefaults to true, so rAF and timers throttle when a window is hidden or occluded. Pause the sim onvisibilitychange, and resynchronise the accumulator on resume to avoid a catch-up burst.
I. Physics (Rapier JS 0.19)
Sources: Rapier colliders, Rapier rigid bodies, Rapier scene queries, Rapier serialization, @dimforge/rapier3d-compat@0.19.3 pipeline/world.d.ts in this repository.
- I1. Terrain should use a heightfield. It uses “much less memory” than a trimesh and suits large terrain. Use a trimesh only for static, non-heightmap geometry, and avoid long, thin triangles. Never put a trimesh on a dynamic body, because it has no interior and objects get stuck in it.
- I2. Dynamic concave objects should use a compound of convex pieces (convex decomposition). Use
convexMeshinstead ofconvexHullwhen the input is already convex, because it skips the hull computation. Round variants of cylinders, cones, and convex shapes are faster than the sharp versions. Round cuboids are slower than plain cuboids. - I3. Keep the collider count low. Merge static colliders by chunk, and use collision groups to drop pairs. The docs say collision groups are preferred because they skip the most computation. Solver groups still compute contacts.
- I4. Let bodies sleep, and pass
wakeUp: trueonly when needed. Sleeping bodies cost nothing until they wake. - I5.
world.takeSnapshot()serializes the whole world into aUint8Array. It is meant for save files, the network, or rollback, never for per-tick queries. For lookups, keep handle-to-entity maps and readbody.translation()andbody.linvel()directly. - I6. CCD is off for dynamic-versus-dynamic pairs by default because it is expensive. Enable it only on fast, small bodies such as bullets or a landing aircraft. CCD is never automatic against trimesh, polyline, or heightfield shapes, so a fast body over terrain needs explicit CCD, substeps, or soft-CCD prediction. A large
softCcdPredictionincreases the number of collision pairs. - I7. Move kinematic bodies with
setNextKinematicTranslation/Rotation. Never teleport dynamic bodies, because that is non-physical and can create deep penetrations. - I8. Scene queries run against the broad-phase structures that
step()updates. If you move colliders without stepping, callpropagateModifiedBodyPositionsToColliders()before querying (0.19 API; the olderupdateSceneQueries()is gone). Filter queries withQueryFilterFlagsor groups, and prefercastRay(first hit) overintersectionsWithRay, because cost grows with the number of hits. - I9. Set
world.lengthUnitto the typical size of an object in world units. Internal tolerances and sleep thresholds scale with it, which matters for metre-scale aircraft over kilometre-scale terrain. Keeptimestepfixed (H1).numSolverIterationsdefaults to 4. Raise it per body withsetAdditionalSolverIterationsinstead of raising it for the whole world.
J. Profiling method (Chrome and Electron)
Sources: Chrome DevTools: analyze runtime performance, MDN EXT_disjoint_timer_query, web3dsurvey: EXT_disjoint_timer_query_webgl2, Khronos thread: Chrome re-enabling timer queries after Site Isolation, stats-gl, Spector.js, migration guide r169.
- J1. Find which processor limits you first:
- Halve the resolution. If the frame time drops, you are fill-bound or bandwidth-bound.
- If it does not drop, you are CPU-bound or vertex-bound.
- Then disable shadows, post, and particles one at a time.
This bisection found causes 4–6 faster than reading the code.
- J2. CPU: use a DevTools Performance recording (or Electron’s DevTools) with “Disable JavaScript samples” off. Sort the Bottom-Up view by self time. In steady state, three.js time should sit in
renderBufferDirect,setProgram(uniforms only), andprojectObject. It should not sit ingetProgram,getParameters,compile,updateMatrixWorldon static trees,traverse, or a serializer. - J3. GPU time:
EXT_disjoint_timer_query_webgl2is exposed in desktop Chrome, where it was re-enabled after Site Isolation. It is absent in Safari and on many mobile devices. Feature-detect it and handleGPU_DISJOINT_EXT.stats-glwraps it and replacesGPUStatsPanel, which was removed [r169]. When the extension is missing, use the DevTools GPU track or Xcode’s Metal System Trace on the Electron process. - J4. Frame budgets: 16.6 ms at 60 Hz and 8.3 ms at 120 Hz. Plan for about 70% of each, because the browser compositor and GC need headroom. A 60 Hz split: sim and scripts ≤ 4 ms, render submit ≤ 4 ms, GPU ≤ 10 ms. Starting budgets for an M1-class or integrated-GPU target at 1080p:
- ≤ 300–500 draw calls including shadow passes (
info.render.callswithautoReset = false) - ≤ 1–2M triangles drawn per frame including shadow passes
- ≤ 2–3 full-screen post passes
- ≤ 512 MB of textures
These numbers are starting points, not vendor figures. Measure them on your slowest target and enforce them in an automated perf harness.
- ≤ 300–500 draw calls including shadow passes (
- J5. Measure on the slowest supported hardware (a base M1, an Intel iGPU, or a low-end Windows laptop), not on an M4 Pro. Use the release build with DevTools closed. Run a fixed, scripted camera route for a fixed duration, and report the p50, p95, and p99 frame times (not average FPS). Report
info.programs.length,info.memory, draws, and triangles alongside them. - J6. Use Spector.js for one-frame captures to count
linkProgram,texImage2D/texSubImage2D, and redundantuseProgramcalls. Usechrome://gputo confirm hardware acceleration and the ANGLE backend in use.
K. Asset authoring
Sources: glTF Transform CLI, meshoptimizer, Khronos KTX Artist Guide, three.js LOD, Godot: optimizing 3D (LOD, occlusion), Godot MultiMesh, forum: 168→9 draw calls case study.
- K1. Set polycount budgets for each asset class and enforce them in the pipeline. Example: hero aircraft 30–80k triangles, props 0.5–5k, terrain chunk 8–32k at LOD0. Budget triangles across the whole frame, including the shadow passes (C1).
- K2. LODs: ship 2–4 levels for every mesh visible at a distance, using
THREE.LODor your own distance bands, with hysteresis to prevent popping. Generate them withgltf-transform simplify(meshoptimizer). Use impostors or billboards for far vegetation. - K3. Chunk meshes so culling works. Divide terrain and city geometry into a grid of tiles (for example 256–1024 m for a flight sim), each with its own bounds and LOD. Tiles then frustum-cull, stream, and fall outside the shadow cascades.
- K4. Use atlases or array textures so that many props share one material, which allows
BatchedMeshor merging (B3, B4). Exporters that split a mesh for each material slot multiply the draw calls (the forum case study above). - K5. A
gltf-transformpipeline:inspectdedup,prune,weldinstance(repeated meshes →EXT_mesh_gpu_instancing)joinandflattenfor static content onlysimplifyquantize(KHR_mesh_quantizationneeds no decoder)meshopt(EXT_meshopt_compression, which suits animated and morph content better than Draco)resize, thenetc1s(colour) oruastc(normal and data maps) for KTX2
optimizeruns these steps in bulk. Its defaults can join and flatten named nodes, so do not use it on assets whose node names gameplay scripts reference. - K6. Author for the renderer:
- Reuse one material for each surface type.
- Avoid
DoubleSide(twice the raster work and the shadow cost). - Avoid alpha-blended foliage (use
alphaTest). - Keep morph target and skin counts minimal.
- Supply tangents only when a normal map exists.
Each feature toggle is a program variant (A).