lod-performance-budget

Use this skill whenever creating scene geometry, configuring LOD switching, setting texture resolutions, implementing instancing or culling, or writing any performance-sensitive rendering code in the

SKILL.md

GitHub

This skill defines the performance budget and Level of Detail (LOD) system for the Myelin simulator. Every geometry object, every texture, and every rendering decision must stay within these budgets. Exceeding them degrades the learner experience on target hardware — a safety-training platform cannot afford to be unusable.


Resolution and Quality Tiers

Four quality tiers. The active tier is detected at session start and may be downgraded adaptively during the session (see webgl-renderer-baseline skill for the adaptive logic).

Tier Viewport minimum Texture target Close-up geometry target
MINIMUM 1280 × 720 1024px 32–48 segments
HD 1920 × 1080 2048px 64–96 segments
HD_PLUS 2560 × 1440 2048–4096px 96–128 segments
UHD 3840 × 2160 4096px 128+ segments

"Segments" refers to the subdivision count on curved geometry (valve handwheel spokes, pipe radius, padlock shackle arc). Flat planar geometry has no segment count — it is always minimal.


Memory Ceilings

Hard limits that must not be exceeded at any quality tier:

Budget Ceiling
Geometry (GPU buffer) 128 MB
Textures (VRAM) 512 MB
Physics world 32 MB
Total scene memory (geometry + textures) 640 MB

Monitor memory usage in the performance tracking loop:

function getMemoryUsage(renderer) {
  const info = renderer.info.memory;
  return {
    geometries: info.geometries,
    textures:   info.textures,
    // Estimate bytes: roughly 1k per geometry, (w*h*4) per texture
    estimatedTextureMB: estimateTextureMB(renderer)
  };
}

function estimateTextureMB(renderer) {
  let totalBytes = 0;
  renderer.info.programs?.forEach(program => {
    // Sum texture sizes from active programs
    totalBytes += estimateProgramTextureCost(program);
  });
  return totalBytes / (1024 * 1024);
}

If texture memory approaches 450 MB (88% of ceiling), downgrade one tier and dispose unused textures.


LOD Object Configuration

Each primary interactive object in the LOTO scene must have explicit LOD levels. Use Three.js LOD object.

function createLODObject(lod0Geo, lod1Geo, lod2Geo, lod3Geo, material) {
  const lod = new THREE.LOD();

  // LOD3 — VR/high detail (shown at close range < 2m)
  const mesh3 = new THREE.Mesh(lod3Geo, material);
  lod.addLevel(mesh3, 0);       // distance 0 = highest detail

  // LOD2 — desktop (shown at < 5m)
  const mesh2 = new THREE.Mesh(lod2Geo, material);
  lod.addLevel(mesh2, 2);

  // LOD1 — tablet (shown at < 12m)
  const mesh1 = new THREE.Mesh(lod1Geo, material);
  lod.addLevel(mesh1, 5);

  // LOD0 — mobile (shown at all distances on MINIMUM tier)
  const mesh0 = new THREE.Mesh(lod0Geo, material);
  lod.addLevel(mesh0, 12);

  return lod;
}

LOD distance thresholds (in world-space metres). These values assume a camera FOV of 60° and a standard simulator scene scale where the valve assembly spans approximately 0.8m:

LOD level Detail Switch-in distance
LOD3 Close inspection < 2m from camera
LOD2 Normal interaction 2–5m
LOD1 Mid-range 5–12m
LOD0 Distant / mobile > 12m

Per-Object Face Count Budgets

Face counts for primary LOTO objects. "Close-up" means LOD2/LOD3. These are target maximums — use fewer faces where the visual result is acceptable.

Object LOD0 LOD1 LOD2 LOD3
Valve handwheel 80 400 1200 3200
Valve body + stem 60 280 900 2400
Pipe assembly 40 200 600 1600
Hasp body 50 240 750 2000
Padlock body 60 320 1000 2800
Padlock shackle 20 120 380 1000
Pressure gauge 30 160 500 1400
Gauge glass 8 40 120 320
Safety tag 6 20 60 160
Bleed valve 30 150 480 1300

Total LOTO scene face budget (all objects combined):

Tier Max faces
MINIMUM < 500
HD (LOD1) < 2,000
HD_PLUS (LOD2) < 8,000
UHD (LOD3) < 20,000

Texture Budget per Object

Texture sizes for primary objects. One texture set = all PBR channels (base color, metalness, roughness, normal, AO). Each texture channel uses this size.

Object MINIMUM HD HD_PLUS UHD
Valve assembly (body + handwheel) 256 512 1024 2048
Hasp 256 512 1024 2048
Padlock 256 512 1024 2048
Pipe assembly 128 256 512 1024
Gauge 128 256 512 1024
Safety tag 128 256 256 512
Environment / floor 256 512 1024 2048

Texture atlas strategy: where objects share material properties (e.g. all steel hardware), pack their textures into a shared atlas to reduce draw calls and VRAM fragmentation.


Performance Targets by Hardware Tier

Hardware FPS target Active objects Physics bodies Quality tier
High-end desktop 60 fps Up to 5,000 Up to 200 HD_PLUS / UHD
Mid-range desktop 45 fps LOD-aggressive 100 HD
Low-end / tablet 30 fps Kinematic fallback Kinematic only MINIMUM

Kinematic fallback: on low-end hardware, physics bodies are replaced with kinematic animation only — no constraint solving. The step validation logic still runs (distance checks, angle checks) but through animation state rather than physics simulation. Assessment accuracy is maintained; only the physical feel changes.


Capability Detection

Detect hardware capability at session start. Do not ask the user to choose a quality setting — autodetect and inform them if a downgrade was applied.

async function detectQualityTier(renderer) {
  const gl = renderer.getContext();
  const debugInfo = gl.getExtension('WEBGL_debug_renderer_info');

  const caps = {
    maxTextureSize:    gl.getParameter(gl.MAX_TEXTURE_SIZE),
    maxVertexUniforms: gl.getParameter(gl.MAX_VERTEX_UNIFORM_VECTORS),
    webgl2:            gl instanceof WebGL2RenderingContext,
    floatTextures:     !!gl.getExtension('OES_texture_float')
  };

  // Rough heuristic — refine based on observed performance after first 5 seconds
  if (caps.maxTextureSize >= 8192 && caps.webgl2) {
    return 'HD_PLUS';
  } else if (caps.maxTextureSize >= 4096 && caps.webgl2) {
    return 'HD';
  } else {
    return 'MINIMUM';
  }
}

After the first 5 seconds of rendering, use the actual observed frame time average (from webgl-renderer-baseline adaptive logic) to confirm or override the initial tier detection.


Instancing

Use THREE.InstancedMesh for repeated geometry: bolt heads, warning labels, pipe segments. Even if only 4–6 instances exist, instancing reduces draw calls.

function createInstancedBolts(boltGeometry, boltMaterial, positions) {
  const mesh = new THREE.InstancedMesh(boltGeometry, boltMaterial, positions.length);
  const matrix = new THREE.Matrix4();

  positions.forEach((pos, i) => {
    matrix.setPosition(pos.x, pos.y, pos.z);
    mesh.setMatrixAt(i, matrix);
  });
  mesh.instanceMatrix.needsUpdate = true;
  return mesh;
}

Frustum Culling

Three.js frustum culling is automatic — do not disable it. Ensure all objects have correct boundingBox or boundingSphere set. Objects with incorrect or missing bounding volumes are not correctly culled.

function prepareGeometryForCulling(geometry) {
  geometry.computeBoundingBox();
  geometry.computeBoundingSphere();
}

For the HUD overlay elements (HTML/CSS layer), culling does not apply — they are always rendered. Keep the HUD element count low (< 20 DOM elements active at once during simulation).


Asset Loading Strategy

Use a two-phase loading approach to achieve the startup target of <= 5 seconds to first interaction (PRD Section 10.2):

Phase 1 (blocking — must complete before scene renders):

  • LOD1/LOD2 geometry for primary interactive objects (valve, hasp, padlock)
  • HD textures for primary objects
  • HDRI 2k fallback
  • Physics world initialisation

Phase 2 (non-blocking — loads in background):

  • LOD3 geometry upgrades
  • 4k/8k HDRI upgrades
  • Secondary scene objects (pipes, fixtures, background)
  • Failure-state effect assets
async function loadSceneAssets(qualityTier, onProgress) {
  // Phase 1 — blocking
  const [primaryGeometry, primaryTextures, hdri] = await Promise.all([
    loadPrimaryGeometry(qualityTier),
    loadPrimaryTextures(qualityTier),
    loadEnvironment(renderer, scene, Math.min(qualityTier, 'HD'))  // cap at HD for phase 1
  ]);
  onProgress(0.7);  // 70% complete — scene can render

  // Phase 2 — non-blocking upgrades
  setTimeout(async () => {
    await loadSecondaryAssets(qualityTier);
    await upgradeHDRIIfNeeded(qualityTier);
    onProgress(1.0);
  }, 0);

  return { primaryGeometry, primaryTextures };
}

What Not To Do

  • Do not exceed the face count budgets for any LOD level
  • Do not disable Three.js frustum culling
  • Do not load all assets before rendering — use the two-phase loading strategy
  • Do not apply LOD3 detail at distances > 2m — it wastes GPU budget
  • Do not use a single geometry for all LOD levels (no LOD = always LOD3 = MINIMUM tier unusable)
  • Do not set texture sizes above the tier maximum — it pushes VRAM over ceiling on constrained devices
  • Do not skip geometry.computeBoundingBox() — invisible objects that should be culled will still be rendered
  • Do not use physics constraints on low-end/mobile — use kinematic fallback