Anime AR Stock

Vol.7

One table for quality presets — and the trap in auto detection

  • quality
  • mobile GPU
  • auto detection
QualityParticlesExtras
low×0.5 / 500Bloom off
standard×1 / 2,000Bloom low
high×4 / 8,000trails
ultra×10 / 20,000smoke + distortion
Fig. 7. Quality does not fork effects — it switches a particle-budget table.

What is in the table

quality/qualityConfig.ts (some columns omitted)
1export const QUALITY_CONFIGS = {2  low:      { particleMultiplier: 0.5, maxParticlesPerEmitter: 500,   bloomQuality: 'off'   },3  standard: { particleMultiplier: 1,   maxParticlesPerEmitter: 2000,  bloomQuality: 'low'   },4  high:     { particleMultiplier: 4,   maxParticlesPerEmitter: 8000,  bloomQuality: 'high'  }, // trails5  ultra:    { particleMultiplier: 10,  maxParticlesPerEmitter: 20000, bloomQuality: 'ultra' }  // trails + smoke + distortion6};

Between low and ultra there is a 20× gap in particle multiplier and 40× in per-emitter cap. With gaps that wide, writing “fewer on phones” inside each jutsu would never stay consistent. ParticleEmitter and every jutsu’s VFX read this table, and all quality differences live here.

The same table approach covers other heavy work. The person mask from Vol.10 (Selfie Segmentation) runs every 4th frame at 128px on low / standard, every 2nd at 192px on high, and every 2nd at 256px on ultra. Per-jutsu bloom (Vol.6) also lives in its own table keyed by quality name.

Why selectedQuality and resolvedQuality are separate

The value the user picks (which can be auto) and the value that indexes the table are separate state. If we persisted the resolved low or high for someone who chose auto, they could never get back to auto after moving devices or after we fix the detection logic. Only selectedQuality is saved to localStorage; resolvedQuality is recomputed on startup and on every change.

Found on re-reading: auto is always low on Safari

quality/qualityStore.ts
1function resolveAutoQuality(): ResolvedQualityLevel {2  const cores = navigator.hardwareConcurrency ?? 4;3  const memoryGb = navigator.deviceMemory ?? 4;   // ← Safari / Firefox には無い4  if (cores <= 4 || memoryGb <= 4) return 'low';5  if (cores >= 10 && memoryGb >= 8) return 'high';6  return 'standard';7}

navigator.deviceMemory is only implemented in Chromium browsers. On Safari and Firefox it is undefined, the fallback 4 kicks in, memoryGb <= 4 holds, and the result is low regardless of hardware. Since auto is the default, every iPhone Safari user — even on the latest model — sees jutsu with no bloom and 0.5× particles.

The code itself carries the TODO: “auto detection is a simple implementation for now” and “replace with dynamic demotion using the real FPS PerformanceMonitor measures (e.g. drop high to standard if FPS < 30 for several seconds)”. Spec-based guessing is not even a guess on browsers that hide the inputs, so moving to measured-FPS demotion is the right direction.

A. Guess from specs (current)Chosen
Pros
Decided instantly at startup. Simple.
Cons
Always low where deviceMemory is missing. Cannot reflect thermal state at equal specs.
B. Demote on measured FPS (TODO)
Pros
Decided by real render load. Independent of browser API gaps.
Cons
Users sit through a few heavy seconds. Needs hysteresis so it does not flap.

Why: A remains for now. The first fix is to stop defaulting to low when deviceMemory is absent on Safari; B comes after.

What building it taught us

  • When quality spans 20–40×, use one table instead of per-jutsu branches. The conversation shifts from “this jutsu” to “this row”.
  • Keep the user’s choice (auto) separate from the resolved value, and persist only the choice.
  • Spec APIs do not exist on every browser. If a fallback value flows straight into the decision, everyone on that browser lands on the same tier.