@genex-ai/cli-demo 0.69.0-dev.175 → 0.70.0-dev.182
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +365 -14
- package/package.json +2 -1
- package/templates/controllers/character/vrm/vrm-loader.ts +74 -11
- package/templates/controllers/quality/governor.ts +147 -0
- package/templates/controllers/quality/pick-asset.ts +57 -0
- package/templates/controllers/quality/tier.ts +170 -0
- package/templates/skills/genex-ai-menu/SKILL.md +7 -1
- package/templates/skills/genex-ai-skybox/SKILL.md +14 -3
- package/templates/skills/genex-threejs-adaptive-quality/SKILL.md +141 -0
- package/templates/skills/genex-threejs-adaptive-quality/references/adaptive-quality.md +105 -0
- package/templates/skills/genex-threejs-bloom/SKILL.md +4 -1
- package/templates/skills/genex-threejs-bloom/references/bloom.md +1 -1
- package/templates/skills/genex-threejs-embed-auth/SKILL.md +4 -1
- package/templates/skills/genex-threejs-game-ui/SKILL.md +8 -6
- package/templates/skills/genex-threejs-image-pipeline/SKILL.md +5 -0
- package/templates/skills/genex-threejs-image-pipeline/references/image-pipeline.md +1 -1
- package/templates/skills/genex-threejs-lighting-design/SKILL.md +5 -1
- package/templates/skills/genex-threejs-multiplayer/SKILL.md +7 -1
- package/templates/skills/genex-threejs-multiplayer/references/host-physics.md +6 -3
- package/templates/skills/genex-threejs-physics-rapier/references/colliders-from-assets.md +1 -0
- package/templates/skills/genex-threejs-screen-space-ambient-occlusion/references/ambient-occlusion.md +1 -1
- package/templates/skills/genex-threejs-shadow-systems/SKILL.md +6 -0
- package/templates/skills/genex-threejs-shadow-systems/references/shadow-systems.md +1 -1
- package/templates/skills/genex-threejs-skill-router/SKILL.md +14 -1
- package/templates/skills/genex-threejs-skill-router/references/routing-map.md +16 -5
- package/templates/skills/genex-threejs-spectral-ocean/references/spectral-ocean.md +1 -1
- package/templates/skills/genex-threejs-touch-controls/SKILL.md +7 -0
- package/templates/skills/genex-threejs-water-optics/references/water-optics.md +1 -1
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Genex adaptive-quality: the runtime governor (mobile-readiness program).
|
|
2
|
+
// Tiers pick the START; the governor owns the RUNTIME knobs forever — phones
|
|
3
|
+
// thermally throttle after minutes, so a game that benched fine at 0:30
|
|
4
|
+
// degrades at 8:00. Feed it a frame time every frame; wire the callbacks to
|
|
5
|
+
// the runtime-changeable knobs only (DPR, post passes, draw distance, frame
|
|
6
|
+
// cap — NEVER context-creation flags like antialias, those are fixed).
|
|
7
|
+
//
|
|
8
|
+
// Step-down ladder on sustained slowness: DPR ×0.8 → post off → draw distance
|
|
9
|
+
// down → 30fps cap. Step-up is slow (hysteresis) and a step that failed twice
|
|
10
|
+
// is never re-attempted. It also self-reports renderer.info counts to
|
|
11
|
+
// window.__GENEX_QUALITY__ — the crash watchdog attaches them to its beacons,
|
|
12
|
+
// which is how the field data that calibrates the whole program gets its
|
|
13
|
+
// memory signal.
|
|
14
|
+
import type { QualityTier } from './tier.ts';
|
|
15
|
+
|
|
16
|
+
export interface GovernorCallbacks {
|
|
17
|
+
/** Apply a DPR multiplier (1 = tier cap). E.g. renderer.setPixelRatio(Math.min(realDpr, tier.dprCap * m)). */
|
|
18
|
+
setDprScale?: (multiplier: number) => void;
|
|
19
|
+
/** Toggle the post stack between the tier's level and 'off'. */
|
|
20
|
+
setPostEnabled?: (enabled: boolean) => void;
|
|
21
|
+
/** Apply a draw-distance multiplier (1 = tier scale). */
|
|
22
|
+
setDrawDistanceScale?: (multiplier: number) => void;
|
|
23
|
+
/** Apply a frame cap (0 = uncapped). Pace to a STABLE 30 over a stuttery 45. */
|
|
24
|
+
setFrameCap?: (fps: number) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface RendererInfoLike {
|
|
28
|
+
info?: { memory?: { textures?: number; geometries?: number } };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const SLOW_WINDOW_MS = 4000; // sustained, not spikes — shader compiles must not trigger steps
|
|
32
|
+
const RECOVER_WINDOW_MS = 20000; // step up slowly (hysteresis)
|
|
33
|
+
const MEM_REPORT_MS = 5000;
|
|
34
|
+
|
|
35
|
+
export class QualityGovernor {
|
|
36
|
+
private tier: QualityTier;
|
|
37
|
+
private steps: Array<{ apply: () => void; revert: () => void; failures: number; pendingRecovery?: boolean }>;
|
|
38
|
+
private applied = 0;
|
|
39
|
+
private slowSince: number | null = null;
|
|
40
|
+
private goodSince: number | null = null;
|
|
41
|
+
private lastMemReport = 0;
|
|
42
|
+
private paused = false;
|
|
43
|
+
|
|
44
|
+
constructor(tier: QualityTier, callbacks: GovernorCallbacks, renderer?: RendererInfoLike) {
|
|
45
|
+
this.tier = tier;
|
|
46
|
+
this.rendererRef = renderer;
|
|
47
|
+
const c = callbacks;
|
|
48
|
+
this.steps = [
|
|
49
|
+
{
|
|
50
|
+
apply: () => c.setDprScale?.(0.8),
|
|
51
|
+
revert: () => c.setDprScale?.(1),
|
|
52
|
+
failures: 0,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
apply: () => c.setPostEnabled?.(false),
|
|
56
|
+
revert: () => c.setPostEnabled?.(true),
|
|
57
|
+
failures: 0,
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
apply: () => c.setDrawDistanceScale?.(0.6),
|
|
61
|
+
revert: () => c.setDrawDistanceScale?.(1),
|
|
62
|
+
failures: 0,
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
apply: () => c.setFrameCap?.(30),
|
|
66
|
+
revert: () => c.setFrameCap?.(this.tier.frameCap),
|
|
67
|
+
failures: 0,
|
|
68
|
+
},
|
|
69
|
+
];
|
|
70
|
+
// Visibility lifecycle: a backgrounded game must stop burning GPU/audio on
|
|
71
|
+
// exactly the thermally-constrained device class. The game's loop should
|
|
72
|
+
// also pause itself; the governor at minimum stops judging frames.
|
|
73
|
+
try {
|
|
74
|
+
document.addEventListener('visibilitychange', () => {
|
|
75
|
+
this.paused = document.visibilityState !== 'visible';
|
|
76
|
+
this.slowSince = null;
|
|
77
|
+
this.goodSince = null;
|
|
78
|
+
});
|
|
79
|
+
} catch {
|
|
80
|
+
/* non-DOM context */
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private rendererRef?: RendererInfoLike;
|
|
85
|
+
|
|
86
|
+
/** Call once per frame with the frame delta in ms (performance.now() based). */
|
|
87
|
+
frame(deltaMs: number): void {
|
|
88
|
+
if (this.paused) return;
|
|
89
|
+
const now = performance.now();
|
|
90
|
+
const budgetMs = 1000 / Math.min(this.tier.frameCap, 60) + 8; // ~24ms at 30, ~25ms at 60
|
|
91
|
+
|
|
92
|
+
if (deltaMs > budgetMs) {
|
|
93
|
+
this.goodSince = null;
|
|
94
|
+
if (this.slowSince == null) this.slowSince = now;
|
|
95
|
+
else if (now - this.slowSince > SLOW_WINDOW_MS) {
|
|
96
|
+
this.stepDown();
|
|
97
|
+
this.slowSince = null;
|
|
98
|
+
}
|
|
99
|
+
} else {
|
|
100
|
+
this.slowSince = null;
|
|
101
|
+
if (this.goodSince == null) this.goodSince = now;
|
|
102
|
+
else if (now - this.goodSince > RECOVER_WINDOW_MS) {
|
|
103
|
+
this.stepUp();
|
|
104
|
+
this.goodSince = null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Memory self-report for the crash watchdog's beacons (field calibration).
|
|
109
|
+
if (now - this.lastMemReport > MEM_REPORT_MS) {
|
|
110
|
+
this.lastMemReport = now;
|
|
111
|
+
try {
|
|
112
|
+
const mem = this.rendererRef?.info?.memory;
|
|
113
|
+
if (mem) {
|
|
114
|
+
(window as Window & { __GENEX_QUALITY__?: { tex?: number; geo?: number } }).__GENEX_QUALITY__ =
|
|
115
|
+
{ tex: mem.textures ?? 0, geo: mem.geometries ?? 0 };
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
/* observational only */
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private stepDown(): void {
|
|
124
|
+
if (this.applied >= this.steps.length) return;
|
|
125
|
+
const step = this.steps[this.applied]!;
|
|
126
|
+
// A re-application of a rung whose recovery is still pending means the
|
|
127
|
+
// revert did NOT hold — that's the failure being counted, never the
|
|
128
|
+
// successful revert itself.
|
|
129
|
+
if (step.pendingRecovery) {
|
|
130
|
+
step.pendingRecovery = false;
|
|
131
|
+
step.failures++;
|
|
132
|
+
}
|
|
133
|
+
step.apply();
|
|
134
|
+
this.applied++;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private stepUp(): void {
|
|
138
|
+
if (this.applied === 0) return;
|
|
139
|
+
const step = this.steps[this.applied - 1]!;
|
|
140
|
+
// Never re-attempt a knob whose recovery failed twice (reverting it put
|
|
141
|
+
// the game back over budget both times) — it stays applied for the session.
|
|
142
|
+
if (step.failures >= 2) return;
|
|
143
|
+
step.pendingRecovery = true;
|
|
144
|
+
step.revert();
|
|
145
|
+
this.applied--;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Genex adaptive-quality: per-tier asset variant selection (mobile-readiness
|
|
2
|
+
// program, Option A+D). Generated assets ship with GUARANTEED downscale rungs
|
|
3
|
+
// in R2 — sibling roles like "skybox-equirect@2048" — so phones can load a
|
|
4
|
+
// ~11 MB sky instead of the 8192x4096 original (~178 MB decoded). Desktop
|
|
5
|
+
// always loads the bare URL: the ladder exists so phones stop dying, never to
|
|
6
|
+
// make desktops uglier.
|
|
7
|
+
//
|
|
8
|
+
// FALLBACK CONTRACT: a rung can be missing (remixed old URLs, an environment
|
|
9
|
+
// whose backfill hasn't run). loadTextureWithFallback retries the bare URL on
|
|
10
|
+
// a rung failure, so the worst case is today's behavior — never a broken boot.
|
|
11
|
+
import type { QualityTier } from './tier.ts';
|
|
12
|
+
|
|
13
|
+
const GENEX_GENERATIONS_RE = /^https:\/\/assets\.genex\.technology\/generations\/[^/]+\/[^/@]+$/;
|
|
14
|
+
|
|
15
|
+
/** Rung widths by role family — mirrors the server's ladder (store.ts MOBILE_RUNGS). */
|
|
16
|
+
function rungWidthFor(url: string, tier: QualityTier): number | null {
|
|
17
|
+
if (tier.name !== 'phone' && tier.name !== 'phone-low') return null;
|
|
18
|
+
const role = url.split('/').pop() ?? '';
|
|
19
|
+
if (role === 'skybox-equirect') return tier.name === 'phone-low' ? 2048 : 4096;
|
|
20
|
+
if (role === 'texture-basecolor' || role === 'image-main' || /^image-alt-\d+$/.test(role)) {
|
|
21
|
+
return tier.name === 'phone-low' ? 1024 : 2048;
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolve the URL a THIS-tier device should load. Non-generated URLs and
|
|
28
|
+
* desktop tiers pass through untouched; phone tiers get the rung sibling.
|
|
29
|
+
*/
|
|
30
|
+
export function pickAsset(url: string, tier: QualityTier): string {
|
|
31
|
+
if (!GENEX_GENERATIONS_RE.test(url)) return url;
|
|
32
|
+
const width = rungWidthFor(url, tier);
|
|
33
|
+
return width ? `${url}@${width}` : url;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Load an image URL through the ladder with the bare-URL fallback. Use it for
|
|
38
|
+
* generated skyboxes/textures instead of raw TextureLoader.loadAsync:
|
|
39
|
+
*
|
|
40
|
+
* const texture = await loadTextureWithFallback(
|
|
41
|
+
* SKYBOX_URL, tier, (u) => new THREE.TextureLoader().loadAsync(u),
|
|
42
|
+
* );
|
|
43
|
+
*/
|
|
44
|
+
export async function loadTextureWithFallback<T>(
|
|
45
|
+
url: string,
|
|
46
|
+
tier: QualityTier,
|
|
47
|
+
load: (resolvedUrl: string) => Promise<T>,
|
|
48
|
+
): Promise<T> {
|
|
49
|
+
const picked = pickAsset(url, tier);
|
|
50
|
+
if (picked === url) return load(url);
|
|
51
|
+
try {
|
|
52
|
+
return await load(picked);
|
|
53
|
+
} catch {
|
|
54
|
+
// Missing rung (old asset, un-backfilled env) — degrade to the original.
|
|
55
|
+
return load(url);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Genex adaptive-quality: device-tier detection (mobile-readiness program).
|
|
2
|
+
// Boot-conservative by design — on phones you can recover from ugly, you
|
|
3
|
+
// cannot recover from a jetsam kill, and boot is exactly when iOS kills. The
|
|
4
|
+
// governor (governor.ts) steps quality UP after smooth seconds, so a
|
|
5
|
+
// too-cautious start costs moments of softness, never a crash.
|
|
6
|
+
//
|
|
7
|
+
// Detection honesty: no single signal is trustworthy. iOS masks the WebGL
|
|
8
|
+
// renderer string to "Apple GPU" (screen+DPR+OS-version is the real Apple
|
|
9
|
+
// signal); Android exposes detailed renderer strings (Adreno/Mali) worth a
|
|
10
|
+
// small lookup; the runtime governor measures actual frame times and corrects
|
|
11
|
+
// both directions. All heuristics here only pick the STARTING tier.
|
|
12
|
+
|
|
13
|
+
export type TierName = 'phone-low' | 'phone' | 'desktop' | 'desktop-high';
|
|
14
|
+
|
|
15
|
+
export interface QualityTier {
|
|
16
|
+
name: TierName;
|
|
17
|
+
/** Cap for renderer.setPixelRatio(Math.min(devicePixelRatio, dprCap)). */
|
|
18
|
+
dprCap: number;
|
|
19
|
+
/** WebGL context antialias (context-creation-FIXED — cannot change live). */
|
|
20
|
+
antialias: boolean;
|
|
21
|
+
/** Directional/spot shadow map size (0 = shadows off). */
|
|
22
|
+
shadowMapSize: number;
|
|
23
|
+
/** 'off' = tone mapping only; 'light' = +FXAA/vignette; 'full' = the named stack. */
|
|
24
|
+
postLevel: 'off' | 'light' | 'full';
|
|
25
|
+
/** Multiplier for particle counts / scatter density. */
|
|
26
|
+
particleScale: number;
|
|
27
|
+
/** Multiplier for draw distance / fog far. */
|
|
28
|
+
drawDistanceScale: number;
|
|
29
|
+
/** Frame target — phones pace to a STABLE 30 over a stuttery 45. */
|
|
30
|
+
frameCap: number;
|
|
31
|
+
/** Max remote players fully animated/drawn (multiplayer); rest billboard. */
|
|
32
|
+
remoteAvatarCap: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const TIERS: Record<TierName, QualityTier> = {
|
|
36
|
+
'phone-low': {
|
|
37
|
+
name: 'phone-low',
|
|
38
|
+
dprCap: 1,
|
|
39
|
+
antialias: false,
|
|
40
|
+
shadowMapSize: 512,
|
|
41
|
+
postLevel: 'off',
|
|
42
|
+
particleScale: 0.25,
|
|
43
|
+
drawDistanceScale: 0.5,
|
|
44
|
+
frameCap: 30,
|
|
45
|
+
remoteAvatarCap: 4,
|
|
46
|
+
},
|
|
47
|
+
phone: {
|
|
48
|
+
name: 'phone',
|
|
49
|
+
dprCap: 1.5,
|
|
50
|
+
antialias: false,
|
|
51
|
+
shadowMapSize: 1024,
|
|
52
|
+
postLevel: 'light',
|
|
53
|
+
particleScale: 0.5,
|
|
54
|
+
drawDistanceScale: 0.75,
|
|
55
|
+
frameCap: 60,
|
|
56
|
+
remoteAvatarCap: 8,
|
|
57
|
+
},
|
|
58
|
+
desktop: {
|
|
59
|
+
name: 'desktop',
|
|
60
|
+
dprCap: 2,
|
|
61
|
+
antialias: true,
|
|
62
|
+
shadowMapSize: 2048,
|
|
63
|
+
postLevel: 'full',
|
|
64
|
+
particleScale: 1,
|
|
65
|
+
drawDistanceScale: 1,
|
|
66
|
+
frameCap: 60,
|
|
67
|
+
remoteAvatarCap: 64,
|
|
68
|
+
},
|
|
69
|
+
'desktop-high': {
|
|
70
|
+
name: 'desktop-high',
|
|
71
|
+
dprCap: 2,
|
|
72
|
+
antialias: true,
|
|
73
|
+
shadowMapSize: 2048,
|
|
74
|
+
postLevel: 'full',
|
|
75
|
+
particleScale: 1,
|
|
76
|
+
drawDistanceScale: 1,
|
|
77
|
+
frameCap: 240,
|
|
78
|
+
remoteAvatarCap: 64,
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** Quality setting persisted PER DEVICE (localStorage) — quality is a property
|
|
83
|
+
* of the phone, not the player, so it deliberately does not ride account
|
|
84
|
+
* state. 'auto' = heuristics + governor. */
|
|
85
|
+
const SETTING_KEY = 'genex:quality';
|
|
86
|
+
export type QualitySetting = 'auto' | 'low' | 'medium' | 'high';
|
|
87
|
+
|
|
88
|
+
export function getQualitySetting(): QualitySetting {
|
|
89
|
+
try {
|
|
90
|
+
const v = localStorage.getItem(SETTING_KEY);
|
|
91
|
+
if (v === 'low' || v === 'medium' || v === 'high') return v;
|
|
92
|
+
} catch {
|
|
93
|
+
/* storage blocked */
|
|
94
|
+
}
|
|
95
|
+
return 'auto';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function setQualitySetting(v: QualitySetting): void {
|
|
99
|
+
try {
|
|
100
|
+
localStorage.setItem(SETTING_KEY, v);
|
|
101
|
+
} catch {
|
|
102
|
+
/* storage blocked */
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isTouchDevice(): boolean {
|
|
107
|
+
try {
|
|
108
|
+
const coarse = matchMedia('(pointer: coarse)').matches;
|
|
109
|
+
const touch = navigator.maxTouchPoints > 1;
|
|
110
|
+
const nav = navigator as Navigator & { userAgentData?: { mobile?: boolean } };
|
|
111
|
+
const mobileUA = nav.userAgentData
|
|
112
|
+
? !!nav.userAgentData.mobile
|
|
113
|
+
: /Mobi|Android/i.test(navigator.userAgent);
|
|
114
|
+
return (touch && coarse) || mobileUA;
|
|
115
|
+
} catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Android GPU model → strong-enough-for-'phone' (vs 'phone-low'). Tiny,
|
|
121
|
+
* vendored on purpose (no detect-gpu dependency): renderer strings are only
|
|
122
|
+
* meaningful on Android/desktop anyway, and the governor corrects mistakes. */
|
|
123
|
+
const STRONG_ANDROID_GPU = /Adreno \(TM\) [67]\d\d|Adreno \(TM\) 8|Mali-G7[18]|Mali-G[89]\d|Immortalis|Xclipse/i;
|
|
124
|
+
|
|
125
|
+
function androidGpuLooksStrong(): boolean {
|
|
126
|
+
try {
|
|
127
|
+
const canvas = document.createElement('canvas');
|
|
128
|
+
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
|
|
129
|
+
if (!gl) return false;
|
|
130
|
+
const info = gl.getExtension('WEBGL_debug_renderer_info');
|
|
131
|
+
const renderer = info ? String(gl.getParameter(info.UNMASKED_RENDERER_WEBGL)) : '';
|
|
132
|
+
const ext = gl.getExtension('WEBGL_lose_context');
|
|
133
|
+
if (ext) ext.loseContext(); // free the probe context immediately
|
|
134
|
+
return STRONG_ANDROID_GPU.test(renderer);
|
|
135
|
+
} catch {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Pick the STARTING tier. Manual setting wins; 'auto' uses heuristics:
|
|
142
|
+
* - non-touch → desktop
|
|
143
|
+
* - touch: old OS or small memory signals → phone-low; strong/new → phone
|
|
144
|
+
* The kit may have clamped devicePixelRatio — prefer its pristine stash.
|
|
145
|
+
*/
|
|
146
|
+
export function detectTier(): QualityTier {
|
|
147
|
+
const setting = getQualitySetting();
|
|
148
|
+
if (setting === 'low') return TIERS['phone-low'];
|
|
149
|
+
if (setting === 'medium') return TIERS.phone;
|
|
150
|
+
if (setting === 'high') return TIERS.desktop;
|
|
151
|
+
|
|
152
|
+
if (!isTouchDevice()) return TIERS.desktop;
|
|
153
|
+
|
|
154
|
+
const ua = navigator.userAgent;
|
|
155
|
+
const kit = (window as Window & { __GENEX_KIT__?: { realDpr?: number } }).__GENEX_KIT__;
|
|
156
|
+
const dpr = kit?.realDpr ?? window.devicePixelRatio;
|
|
157
|
+
const isIOS = /iPhone|iPad|iPod/.test(ua) || (navigator.maxTouchPoints > 1 && /Mac/.test(ua));
|
|
158
|
+
|
|
159
|
+
if (isIOS) {
|
|
160
|
+
// iOS version floors device age (iOS 17 ⇒ XS/2018+; screen+DPR refine).
|
|
161
|
+
const m = ua.match(/OS (\d+)_/);
|
|
162
|
+
const major = m ? Number(m[1]) : 0;
|
|
163
|
+
if (major >= 17) return TIERS.phone;
|
|
164
|
+
return TIERS['phone-low'];
|
|
165
|
+
}
|
|
166
|
+
const am = ua.match(/Android (\d+)/);
|
|
167
|
+
const androidMajor = am ? Number(am[1]) : 0;
|
|
168
|
+
if (androidMajor >= 13 && (dpr >= 2.5 || androidGpuLooksStrong())) return TIERS.phone;
|
|
169
|
+
return TIERS['phone-low'];
|
|
170
|
+
}
|
|
@@ -83,7 +83,13 @@ npx genex wait <gen-id>
|
|
|
83
83
|
|
|
84
84
|
Never wire the clip as a bare `<video loop>` — the residual seam shows every
|
|
85
85
|
cycle. Two stacked `<video>` elements with the same src crossfade at the
|
|
86
|
-
cycle end; any seam disappears deterministically, no regeneration lottery
|
|
86
|
+
cycle end; any seam disappears deterministically, no regeneration lottery.
|
|
87
|
+
|
|
88
|
+
**Phone tiers get the poster, not the videos** (`$genex-threejs-adaptive-quality`):
|
|
89
|
+
two preloading 720p decoders while the 3D scene boots is a spike at exactly the
|
|
90
|
+
moment phones get killed for memory. On a phone tier, show the key-art poster
|
|
91
|
+
image (a captured frame of the clip works) and skip `seamlessLoop` entirely —
|
|
92
|
+
or defer ONE non-preloading video until after the first gameplay frame:
|
|
87
93
|
|
|
88
94
|
```ts
|
|
89
95
|
/** Deterministic seamless loop: two stacked <video>s crossfade at cycle end. */
|
|
@@ -35,15 +35,23 @@ in local dev, the published game, and remixes).
|
|
|
35
35
|
|
|
36
36
|
## Load it as background + environment
|
|
37
37
|
|
|
38
|
-
Load the equirect JPG
|
|
39
|
-
background and the lighting
|
|
38
|
+
Load the equirect JPG through the quality tier's rung ladder, mark it
|
|
39
|
+
equirectangular, and use it for both the visible background and the lighting.
|
|
40
|
+
The bare URL is an 8192×4096 original — ~178 MB decoded, over half a phone's
|
|
41
|
+
GPU budget in one texture — so phones must load the downscale rung the platform
|
|
42
|
+
stores next to every skybox (`$genex-threejs-adaptive-quality`):
|
|
40
43
|
|
|
41
44
|
```ts
|
|
42
45
|
import * as THREE from "three";
|
|
46
|
+
import { detectTier } from "./controllers/quality/tier.ts";
|
|
47
|
+
import { loadTextureWithFallback } from "./controllers/quality/pick-asset.ts";
|
|
43
48
|
|
|
44
49
|
// the URL `npx genex skybox` printed (R2 sends CORS headers, so cross-origin works):
|
|
45
50
|
const SKYBOX_URL = "https://assets.genex.technology/generations/<id>/skybox-equirect";
|
|
46
|
-
const
|
|
51
|
+
const tier = detectTier(); // reuse the boot tier if you already have it
|
|
52
|
+
const texture = await loadTextureWithFallback(SKYBOX_URL, tier, (u) =>
|
|
53
|
+
new THREE.TextureLoader().loadAsync(u),
|
|
54
|
+
);
|
|
47
55
|
texture.mapping = THREE.EquirectangularReflectionMapping;
|
|
48
56
|
texture.colorSpace = THREE.SRGBColorSpace;
|
|
49
57
|
|
|
@@ -51,6 +59,9 @@ scene.background = texture; // visible sky
|
|
|
51
59
|
scene.environment = texture; // image-based lighting on PBR materials
|
|
52
60
|
```
|
|
53
61
|
|
|
62
|
+
Desktop gets the original; phones get the `@2048`/`@4096` rung; a missing rung
|
|
63
|
+
falls back to the original automatically — never a broken boot.
|
|
64
|
+
|
|
54
65
|
For sharper reflections/lighting, pre-filter it with `PMREMGenerator`:
|
|
55
66
|
|
|
56
67
|
```ts
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: genex-threejs-adaptive-quality
|
|
3
|
+
description: Make a Genex Three.js game phone-survivable with the vendored adaptive-quality kit — device-tier detection, tier-budgeted renderer settings, a runtime governor that steps quality down before phones run out of memory, per-tier asset rungs for generated skyboxes/textures, and a Quality picker in settings. Load for every game at boot wiring time, and whenever a game is heavy, crashes on phones, or gets flagged desktop-only.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Genex Three.js Adaptive Quality
|
|
7
|
+
|
|
8
|
+
Phones enforce a hard GPU-memory ceiling desktops don't have: iOS silently
|
|
9
|
+
kills the page when a game allocates too much, and the kill arrives at BOOT —
|
|
10
|
+
exactly when a skybox, models, and the post stack all decode at once. This
|
|
11
|
+
skill wires the vendored quality kit so the game boots conservatively on
|
|
12
|
+
phones, steps quality UP when the device proves smooth, and never gets uglier
|
|
13
|
+
on desktop. **You can recover from ugly; you cannot recover from a killed
|
|
14
|
+
page.**
|
|
15
|
+
|
|
16
|
+
This is a completion gate like the post stack: every game wires the tier at
|
|
17
|
+
boot before it is called done. It costs three lines, not a testing burden —
|
|
18
|
+
you still verify on desktop only.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npx genex controller quality
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Installs `src/controllers/quality/{tier.ts, governor.ts, pick-asset.ts}` —
|
|
27
|
+
game-owned code, edit freely.
|
|
28
|
+
|
|
29
|
+
## Wire the tier at boot (before renderer construction)
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import { detectTier } from "./controllers/quality/tier.ts";
|
|
33
|
+
import { QualityGovernor } from "./controllers/quality/governor.ts";
|
|
34
|
+
|
|
35
|
+
const tier = detectTier(); // phone-low | phone | desktop; manual Quality setting wins
|
|
36
|
+
const renderer = new THREE.WebGLRenderer({ antialias: tier.antialias });
|
|
37
|
+
renderer.setPixelRatio(Math.min(window.devicePixelRatio, tier.dprCap));
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The tier owns every budget decision: `dprCap` (1.5 on phones — the single
|
|
41
|
+
biggest framebuffer lever), `antialias` (off on phones; it is fixed at context
|
|
42
|
+
creation and can never change live), `shadowMapSize` (1024 phone / 2048
|
|
43
|
+
desktop), `postLevel` (`'light'` = tone mapping + cheap passes on phones;
|
|
44
|
+
`'full'` = the named stack on desktop), `particleScale`, `drawDistanceScale`,
|
|
45
|
+
`frameCap`, and `remoteAvatarCap` for multiplayer. The exact ladder and which
|
|
46
|
+
knobs may change at runtime vs load time vs never: [references/adaptive-quality.md](references/adaptive-quality.md).
|
|
47
|
+
|
|
48
|
+
## Wire the governor (runtime — phones throttle over minutes)
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
const governor = new QualityGovernor(tier, {
|
|
52
|
+
setDprScale: (m) => renderer.setPixelRatio(Math.min(window.devicePixelRatio, tier.dprCap * m)),
|
|
53
|
+
setPostEnabled: (on) => (composerEnabled = on),
|
|
54
|
+
setDrawDistanceScale: (m) => (scene.fog!.far = baseFogFar * tier.drawDistanceScale * m),
|
|
55
|
+
}, renderer);
|
|
56
|
+
|
|
57
|
+
// In the render loop, with the same performance.now() delta the loop already computes:
|
|
58
|
+
governor.frame(deltaMs);
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Sustained slow frames step down (DPR ×0.8 → post off → draw distance →
|
|
62
|
+
30 fps cap); twenty smooth seconds step back up; a knob that failed twice
|
|
63
|
+
stays down for the session. It keeps governing forever — thermal throttling
|
|
64
|
+
arrives at minute eight, not second thirty. It also pauses judgment when the
|
|
65
|
+
tab is hidden and publishes memory counts the platform's crash telemetry
|
|
66
|
+
reads; pause your own loop and audio on `visibilitychange` too.
|
|
67
|
+
|
|
68
|
+
## Generated assets: load through the rungs
|
|
69
|
+
|
|
70
|
+
Generated skyboxes are 8192×4096 — about 178 MB decoded, over half a phone's
|
|
71
|
+
whole budget in one texture. Every generated image asset ships with downscale
|
|
72
|
+
rungs; phones must load through them:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
import { detectTier } from "./controllers/quality/tier.ts";
|
|
76
|
+
import { loadTextureWithFallback } from "./controllers/quality/pick-asset.ts";
|
|
77
|
+
|
|
78
|
+
const texture = await loadTextureWithFallback(SKYBOX_URL, tier, (u) =>
|
|
79
|
+
new THREE.TextureLoader().loadAsync(u),
|
|
80
|
+
);
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Desktop loads the original, phones the `@2048` rung (~11 MB), and a missing
|
|
84
|
+
rung falls back to the original — never a broken boot. The `$genex-ai-skybox`
|
|
85
|
+
and `$genex-ai-texture` skills show the wiring in place.
|
|
86
|
+
|
|
87
|
+
## Quality picker in settings
|
|
88
|
+
|
|
89
|
+
The pause/settings screen (see `$genex-threejs-game-ui`) always carries a
|
|
90
|
+
Quality entry: **Auto / Low / Medium / High**, wired to
|
|
91
|
+
`setQualitySetting(...)` from `tier.ts` + a reload or re-tier. Persisted
|
|
92
|
+
per-device in localStorage on purpose — quality is a property of the phone,
|
|
93
|
+
not the player's account. Default Auto.
|
|
94
|
+
|
|
95
|
+
## Budgets that ride the tier (not separate rules)
|
|
96
|
+
|
|
97
|
+
- Shadows: `tier.shadowMapSize`, `shadow.autoUpdate = false` for static scenes,
|
|
98
|
+
at most 2 cascades on phones (`$genex-threejs-shadow-systems`).
|
|
99
|
+
- Post: phone floor is a BUILT tone-mapping/output pass (`postLevel: 'light'`
|
|
100
|
+
adds FXAA/vignette); SSAO, volumetrics, and DoF are desktop-tier only
|
|
101
|
+
(`$genex-threejs-skill-router` owns the floor wording).
|
|
102
|
+
- Particles/scatter: multiply counts by `tier.particleScale`; render heavy
|
|
103
|
+
transparency at half resolution and upsample.
|
|
104
|
+
- Animation: distant mixers update at 1/2–1/4 rate; multiplayer remotes above
|
|
105
|
+
`tier.remoteAvatarCap` billboard instead of animating
|
|
106
|
+
(`$genex-threejs-multiplayer`).
|
|
107
|
+
- Physics: phone tiers prefer hull/cuboid colliders for props — trimesh only
|
|
108
|
+
where gameplay demands it (`$genex-threejs-physics-rapier`).
|
|
109
|
+
- Shaders: default `mediump` (iOS exception: float-texture sampling needs
|
|
110
|
+
`highp sampler2D`); precompile with `renderer.compileAsync(scene, camera)`
|
|
111
|
+
during the loader screen so first-frame jank doesn't read as a stall; skip
|
|
112
|
+
max anisotropy on phones.
|
|
113
|
+
- Disposal: level swaps traverse the outgoing scene and dispose geometry,
|
|
114
|
+
materials, AND textures (three never frees them for you); watch
|
|
115
|
+
`renderer.info.memory` while testing — if textures/geometries climb across
|
|
116
|
+
swaps, you leak toward the kill.
|
|
117
|
+
|
|
118
|
+
## WebGPU games
|
|
119
|
+
|
|
120
|
+
The scaffold ships WebGL and stays the default; if this project already uses
|
|
121
|
+
`WebGPURenderer`, keep it (never switch renderers mid-project). Context loss
|
|
122
|
+
differs: WebGL fires `webglcontextlost` events; WebGPU exposes a
|
|
123
|
+
`device.lost` promise — attach a handler that pauses the loop and rebuilds,
|
|
124
|
+
mirroring the scaffold's WebGL pattern. All tier knobs apply identically
|
|
125
|
+
except `antialias` (WebGPU MSAA is per-render-target and CAN change at
|
|
126
|
+
runtime).
|
|
127
|
+
|
|
128
|
+
## Failure conditions
|
|
129
|
+
|
|
130
|
+
- Renderer constructed before `detectTier()` → context-creation knobs
|
|
131
|
+
(antialias) are locked wrong for the session. Tier first, renderer second.
|
|
132
|
+
- Governor wired to context-creation flags → no-op at best. Runtime knobs
|
|
133
|
+
only: DPR, post toggles, distances, frame cap.
|
|
134
|
+
- Skybox loaded with a bare `TextureLoader.loadAsync(SKYBOX_URL)` on a game
|
|
135
|
+
that targets phones → ~178 MB decoded; route it through
|
|
136
|
+
`loadTextureWithFallback`.
|
|
137
|
+
- Quality stepping on every spike → shader compiles read as slowness. The
|
|
138
|
+
governor requires SUSTAINED slow windows; do not shorten them.
|
|
139
|
+
- Testing quality tiers by resizing the desktop window → tiers key off touch +
|
|
140
|
+
OS, not viewport. Trust desktop verification plus the preflight report
|
|
141
|
+
`genex preview` prints.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# Genex adaptive quality — tiers, knobs, and the governor in depth
|
|
2
|
+
|
|
3
|
+
Use this reference when tuning the tier ladder, deciding which knob may change
|
|
4
|
+
when, or teaching the governor a game-specific step.
|
|
5
|
+
|
|
6
|
+
## Why boot-conservative
|
|
7
|
+
|
|
8
|
+
The phone budget is a hard ceiling that includes GPU memory (textures,
|
|
9
|
+
framebuffers), and the OS kill arrives with no catchable event. Boot is the
|
|
10
|
+
danger window: skybox + models + post targets decode together. So phone tiers
|
|
11
|
+
START one notch below what the heuristics suggest and the governor steps UP
|
|
12
|
+
after ~20 smooth seconds. The cost of guessing low is moments of softness; the
|
|
13
|
+
cost of guessing high is a dead page.
|
|
14
|
+
|
|
15
|
+
## The tier ladder
|
|
16
|
+
|
|
17
|
+
| Knob | phone-low | phone | desktop |
|
|
18
|
+
|---|---|---|---|
|
|
19
|
+
| DPR cap | 1.0 | 1.5 | 2 |
|
|
20
|
+
| antialias (context) | off | off | on |
|
|
21
|
+
| Shadow map | 512 (static-cached) | 1024 | 2048 |
|
|
22
|
+
| Post level | tone map only | + FXAA/vignette | full named stack |
|
|
23
|
+
| Skybox rung | @2048 (~11 MB) | @4096 (~45 MB) | original |
|
|
24
|
+
| Texture rung (props) | @1024 | @2048 | original |
|
|
25
|
+
| Particles/scatter | 0.25× | 0.5× | 1× |
|
|
26
|
+
| Draw distance | 0.5× | 0.75× | 1× |
|
|
27
|
+
| Frame target | stable 30 | 60 | 60 |
|
|
28
|
+
| Remote avatars animated | 4 | 8 | all |
|
|
29
|
+
| Prop colliders | hull/cuboid | hull | as designed |
|
|
30
|
+
|
|
31
|
+
A DPR drop from 3 (raw iPhone) to 1.5 cuts every full-screen surface — color,
|
|
32
|
+
depth, and each post target — to a quarter of the bytes. It is the single
|
|
33
|
+
strongest lever the tier owns.
|
|
34
|
+
|
|
35
|
+
## The knob split — what may change when
|
|
36
|
+
|
|
37
|
+
Getting this wrong produces silent no-ops or a session stuck ugly:
|
|
38
|
+
|
|
39
|
+
- **Context-creation-fixed (never changes live):** `antialias`, `alpha`,
|
|
40
|
+
`stencil`, `powerPreference` on WebGL. Changing them means a new context and
|
|
41
|
+
a full re-init — the tier must decide them BEFORE the renderer exists.
|
|
42
|
+
(WebGPU differs: MSAA is per-render-target sample count and is runtime-
|
|
43
|
+
changeable.)
|
|
44
|
+
- **Load-time (fixed for the session once fetched):** asset rungs (skybox and
|
|
45
|
+
texture resolutions), model LOD sets. `pickAsset` decides them from the tier
|
|
46
|
+
at load; switching later means a re-fetch — treat as fixed.
|
|
47
|
+
- **Runtime-free (the governor's domain):** `setPixelRatio`, post passes on/off
|
|
48
|
+
and their target resolutions, shadow map size (realloc), draw distance and
|
|
49
|
+
fog, LOD bias, particle counts, mixer update rates, frame cap, remote-avatar
|
|
50
|
+
animation count.
|
|
51
|
+
|
|
52
|
+
## Governor mechanics
|
|
53
|
+
|
|
54
|
+
- Slow = frame delta over budget for a SUSTAINED window (4 s) — never single
|
|
55
|
+
spikes, which are usually shader compiles or GC. Precompiling with
|
|
56
|
+
`renderer.compileAsync` during the loader screen removes most spikes at the
|
|
57
|
+
source (and keeps first-frame jank from reading as a stall to the platform's
|
|
58
|
+
telemetry).
|
|
59
|
+
- Step-down order: DPR ×0.8 → post off → draw distance ×0.6 → 30 fps cap.
|
|
60
|
+
Each step is the cheapest remaining lever with the biggest headroom return.
|
|
61
|
+
- Step-up needs 20 smooth seconds (hysteresis), and a step that had to be
|
|
62
|
+
re-applied twice is pinned for the session — oscillating quality reads worse
|
|
63
|
+
than stable-low.
|
|
64
|
+
- The governor never stops: thermal throttling degrades phones after minutes
|
|
65
|
+
of play, so a boot-time benchmark alone always ends up wrong.
|
|
66
|
+
- A stable 30 fps cap beats a stuttery 40–50: consistent frame pacing reads
|
|
67
|
+
smoother and halves GPU work per second (heat, battery, memory bandwidth).
|
|
68
|
+
- Backgrounded tab (`visibilitychange`): pause the render loop and audio, not
|
|
69
|
+
just the governor — a hidden game burning GPU is pure thermal debt on the
|
|
70
|
+
device class that can least afford it.
|
|
71
|
+
|
|
72
|
+
## Detection honesty
|
|
73
|
+
|
|
74
|
+
- Apple devices mask the GPU renderer string ("Apple GPU") — screen dims + DPR
|
|
75
|
+
+ iOS major version are the usable signals there, and the governor corrects
|
|
76
|
+
the rest from measured frames.
|
|
77
|
+
- Android exposes real renderer strings (Adreno/Mali/Xclipse); the vendored
|
|
78
|
+
lookup in `tier.ts` promotes strong GPUs to the `phone` tier. It is a
|
|
79
|
+
heuristic on purpose — extend the regex when field data shows a
|
|
80
|
+
misclassified family, and let the governor absorb the rest.
|
|
81
|
+
- Never burn a probe context on a memory-strapped phone at play time; the one
|
|
82
|
+
probe in `tier.ts` runs at boot and frees its context immediately.
|
|
83
|
+
|
|
84
|
+
## Memory discipline that rides the tier
|
|
85
|
+
|
|
86
|
+
- Dispose on every level swap: traverse the outgoing scene and call
|
|
87
|
+
`.dispose()` on geometry, material, AND each material's textures — material
|
|
88
|
+
dispose does not free textures, and three frees nothing automatically.
|
|
89
|
+
- Watch `renderer.info.memory.{textures,geometries}` across swaps in dev; a
|
|
90
|
+
monotonic climb is a leak marching toward the OS kill. The governor
|
|
91
|
+
publishes these counts for the platform's field telemetry.
|
|
92
|
+
- Prefer meshopt/instanced geometry for repeats; `BatchedMesh` batches
|
|
93
|
+
HETEROGENEOUS static meshes into one draw where instancing (identical
|
|
94
|
+
meshes only) can't.
|
|
95
|
+
- Half-resolution transparency: render heavy particle/transparency passes to a
|
|
96
|
+
half-size target and composite up — fill-rate is the phone bottleneck.
|
|
97
|
+
|
|
98
|
+
## Multiplayer at tier
|
|
99
|
+
|
|
100
|
+
Remotes are visual-only; with the shared avatar file, `loadVrmClone` gives N
|
|
101
|
+
remotes one set of GPU geometry/textures. Animate and fully draw only the
|
|
102
|
+
nearest `tier.remoteAvatarCap`; beyond it, freeze the mixer and billboard or
|
|
103
|
+
hide. Matchmade games can also declare a lower `maxPlayers` in
|
|
104
|
+
`genex.matchmaking` for phone-heavy audiences — capacity is a server-owned
|
|
105
|
+
knob.
|