device.ts

examples/fractals/device.ts · raw · open the example · all examples · site home

Exports: PlatformQualitysplatsOfTierplatformsaveQualitypixelRatioOfsaveHiDpideviceTiertableBytesFramePaceBudgetResolution

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
// What this device can take: samples per bake, the pixel ratio and the GPU
// memory the page's tables may use, by platform and backend (desktop,
// iOS / iPadOS, Android; WebGPU or WebGL2), and a resolution that follows
// the frame rate. HiDPI (the screen's own pixel ratio, at most 2) is on by
// default on desktops and off (one canvas pixel per CSS pixel) on phones and
// tablets; a choice is kept per device, and ?hidpi=0 | 1 overrides it.
// Samples come in two qualities per platform (standard by default, high for
// newer devices), kept per device too, ?quality=standard | high overriding. Phones and tablets get fewer samples and fewer pixels:
// a splat costs its sort and its fill, and their GPUs share memory with
// everything else.
import type { WebGPURenderer } from "three/webgpu";

export type Platform = "desktop" | "ios" | "android";
export type Quality = "standard" | "high";

// Samples per bake on WebGPU, standard and high (WebGL2 takes half)
const SPLATS: Record<Platform, [number, number]> = {
  desktop: [2_000_000, 4_000_000],
  ios: [1_000_000, 2_000_000],
  android: [500_000, 1_000_000],
};

export const splatsOf = (p: Platform, webgpu: boolean, quality: Quality) => SPLATS[p][quality === "high" ? 1 : 0] / (webgpu ? 1 : 2);

export interface Tier {
  platform: Platform;
  // Samples per bake, in the gallery and the demos, at this quality
  quality: Quality;
  splats: number;
  // HiDPI on, and the canvas's pixel ratio before the frame rate scales it
  hiDpi: boolean;
  pixelRatio: number;
  // Bytes of tables the page may keep
  memory: number;
}

export function platform(): Platform {
  const agent = navigator.userAgent;
  if (/iPhone|iPad|iPod/.test(agent) || (/Macintosh/.test(agent) && navigator.maxTouchPoints > 1)) {
    return "ios";
  }
  return /Android/.test(agent) ? "android" : "desktop";
}

const HIDPI_KEY = "radix-fractals:hidpi";
const QUALITY_KEY = "radix-fractals:quality";

function load(key: string) {
  try {
    return localStorage.getItem(key);
  } catch {
    return null;
  }
}

function save(key: string, value: string) {
  try {
    localStorage.setItem(key, value);
  } catch {
    // Storage off: the choice lasts the visit
  }
}

export const saveQuality = (quality: Quality) => save(QUALITY_KEY, quality);

export const pixelRatioOf = (hiDpi: boolean) => (hiDpi ? Math.min(devicePixelRatio || 1, 2) : 1);

export const saveHiDpi = (on: boolean) => save(HIDPI_KEY, on ? "1" : "0");

function hiDpiFor(p: Platform) {
  const choice = new URLSearchParams(location.search).get("hidpi") ?? load(HIDPI_KEY);
  return choice === null ? p === "desktop" : choice === "1";
}

function qualityFor(): Quality {
  return (new URLSearchParams(location.search).get("quality") ?? load(QUALITY_KEY)) === "high" ? "high" : "standard";
}

export function deviceTier(webgpu: boolean): Tier {
  const p = platform();
  const hiDpi = hiDpiFor(p);
  const quality = qualityFor();
  // iOS reloads a tab past roughly 300 MB of GPU memory
  const memory = p === "ios" ? 200e6 : p === "android" ? 260e6 : webgpu ? 900e6 : 360e6;
  return { platform: p, quality, splats: splatsOf(p, webgpu, quality), hiDpi, pixelRatio: pixelRatioOf(hiDpi), memory };
}

// Bytes a table of `splats` samples takes (sample.ts: 40 per sample) with
// the orders kept for its transitions
export const tableBytes = (splats: number) => splats * 52;

// This device's own frame time (ms, up to a 20 fps frame), measured on
// frames that bake nothing: every eighth frame is left free for it. Bakes
// size their chunks against it (Budget), so a 60 fps desktop and a 40 fps
// phone both bake in what their frames leave.
export class FramePace {
  base = 16.7;
  private frame = 0;

  // Whether this frame may bake
  bakes() {
    this.frame += 1;
    return this.frame % 8 !== 0;
  }

  // A frame that baked nothing
  update(dt: number) {
    if (dt > 0 && dt < 250) {
      this.base = Math.min(50, this.base + (dt - this.base) * 0.25);
    }
  }
}

// Rows to bake per frame for one kind of bake: grows while frames stay
// within `slack` ms of the device's frame time, halves after a late frame
// and holds a few frames
export class Budget {
  chunk: number;
  private hold = 0;
  private readonly pace: FramePace;
  private readonly min: number;
  private readonly max: number;

  constructor(pace: FramePace, min: number, max: number, start = min) {
    this.pace = pace;
    this.min = min;
    this.max = max;
    this.chunk = start;
  }

  next(dt: number, slack: number) {
    const over = dt - this.pace.base;
    if (dt < 300 && over > slack * 2 + 2) {
      this.chunk = Math.max(this.min, this.chunk / 2);
      this.hold = 6;
    } else if (this.hold > 0) {
      this.hold -= 1;
    } else if (over < slack) {
      this.chunk = Math.min(this.max, this.chunk * 1.15);
    }
    return Math.floor(this.chunk);
  }
}

// Lowers the pixel ratio while frames run slow (under ~42 fps) and raises
// it again after a few good seconds, once a second at most. Frames that
// bake are left out of the average.
export class Resolution {
  scale = 1;
  private ema = 16.7;
  private timer = 0;
  private good = 0;
  private readonly renderer: WebGPURenderer;
  private base: number;
  private readonly min: number;

  constructor(renderer: WebGPURenderer, base: number, min = 0.55) {
    this.renderer = renderer;
    this.base = base;
    this.min = min;
    renderer.setPixelRatio(base);
  }

  // A new pixel ratio to scale (HiDPI on or off)
  setBase(base: number) {
    this.base = base;
    this.renderer.setPixelRatio(base * this.scale);
  }

  update(dt: number, busy = false) {
    if (dt <= 0 || dt > 250) {
      return;
    }
    if (!busy) {
      this.ema += (dt - this.ema) * 0.05;
    }
    this.timer += dt;
    if (this.timer < 1000) {
      return;
    }
    this.timer = 0;
    let next = this.scale;
    if (this.ema > 24) {
      next = Math.max(this.min, this.scale * 0.85);
      this.good = 0;
    } else if (this.ema < 18 && this.scale < 1) {
      this.good += 1;
      if (this.good >= 3) {
        next = Math.min(1, this.scale * 1.1);
        this.good = 0;
      }
    } else {
      this.good = 0;
    }
    if (Math.abs(next - this.scale) > 0.01) {
      this.scale = next;
      this.renderer.setPixelRatio(this.base * next);
    }
  }
}