Vol.7
One table for quality presets — and the trap in auto detection
| Quality | Particles | Extras |
|---|---|---|
| low | ×0.5 / 500 | Bloom off |
| standard | ×1 / 2,000 | Bloom low |
| high | ×4 / 8,000 | trails |
| ultra | ×10 / 20,000 | smoke + distortion |
What is in the table
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
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.
- Pros
- Decided instantly at startup. Simple.
- Cons
- Always low where deviceMemory is missing. Cannot reflect thermal state at equal specs.
- 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.