Oleksii
Oleksii

Reputation: 301

Keeping FPS in a certain range with three.js

Having an instance of WebGLRenderer created with antialias = true results in noticeable performance issues as resolution grows, especially on retina displays (window.devicePixelRatio === 2).

Since it's not possible to change the antialiasing mode on the fly, the question is: how to automatically adjust the pixel ratio trying to keep FPS higher than a certain threshold (e.g. 30)?

Upvotes: 2

Views: 981

Answers (1)

Oleksii
Oleksii

Reputation: 301

The idea is to monitor FPS in the rendering loop (by measuring intervals between requestAnimationFrame calls) and decreasing or increasing DPR accordingly.

"Monitoring" means recording those intervals into an array, removing min/max values (to avoid peaks), taking an average and comparing it with predefined thresholds.

const highFrequencyThreshold = 20; // ~50 FPS
const lowFrequencyThreshold = 34;  // ~30 FPS

const minDpr = 0.5;
const maxDpr = window.devicePixelRatio;
const deltaDpr = 0.1;

const relaxPeriod = 4000;
const accumulatorLength = 20;

const frameTimestamp = performance.now();
const frequencyAccumulator = [];
const lastUpdatedAt = null;

const renderer = new WebGLRenderer({
  antialias: true,
});

renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

animate();

function animate(timestamp: number = performance.now()) {
  requestAnimationFrame(animate);

  monitor(frameTimestamp, timestamp);
  frameTimestamp = timestamp;

  // animation calculations and rendering
  // ...
}

function monitor(frameTimestamp: number, now: number) {
  collectFrequency(now - frameTimestamp);

  // accumulator is not fully filled
  if (frequencyAccumulator.length < accumulatorLength) {
    return;
  }

  // an update happened recently
  if (now - lastUpdatedAt < relaxPeriod) {
    return;
  }

  const dpr = renderer.getPixelRatio();
  const frequencyMedian = median(...frequencyAccumulator);

  if (frequencyMedian > lowFrequencyThreshold && dpr > minDpr) {
    updateDpr(dpr, -deltaDpr, now);
  } else if (frequencyMedian < highFrequencyThreshold && dpr < maxDpr) {
    updateDpr(dpr, deltaDpr, now);
  }
}

function collectFrequency(frequency: number) {
  if (frequency > 0) {
    frequencyAccumulator.push(frequency);
  }

  if (frequencyAccumulator.length > accumulatorLength) {
    frequencyAccumulator.shift();
  }
}

function updateDpr(dpr: number, delta: number, now: number) {
  renderer.setPixelRatio(dpr + delta);
  frequencyAccumulator = [];
  lastUpdatedAt = now;
}

function median(...elements: number[]): number {
  const indexOfMin = elements.indexOf(Math.min(...elements));
  const indexOfMax = elements.indexOf(Math.max(...elements));
  const noMinMax = elements.filter((_, index) => index !== indexOfMin && index !== indexOfMax);

  return average(...noMinMax);
}

function average(...elements: number[]): number {
  return elements.reduce((sum, value) => sum + value, 0) / elements.length;
}

Note that updating the DPR could result in short-time animation freezing.

Also, something more clever could be used for balancing the DPR value (rather than calling the updateDpr() with linear step of 0.1), e.g. bisectional search.

Upvotes: 1

Related Questions