openvibers/openvibe.games Commits
Client quality tiers (high/medium/low) from the device or the player's choice, with a Settings tab: resolution and shadows apply live (#11)
Changes
@@@ -37,6 +37,14 @@ import {registerMapAssets,rebuildTerrainVisual,} from './render/sceneSetup.js'++import {++ applyEngineQuality,++ applySceneQuality,++ qualityForTier,++ readDeviceSignals,++ resolveQualityTier,++ resolveStartupQuality,++} from './render/quality.js'import { Viewmodel } from './render/viewmodel.js'import { ClientState } from './state/clientState.js'import { characterSelect, type CharacterInfo } from './ui/characterSelect.js'@@@ -95,6 +103,7 @@ async function start(): Promise<void> {const rapierPromise = loadRapier()const environment = new Environment(scene, engine)++ environment.setAntialias(resolveStartupQuality().antialias)// Render immediately so the customization preview is live.let gameLoop: (() => void) | null = null@@@ -171,6 +180,12 @@ async function start(): Promise<void> {registerPhysgunModule()registerRiggingModule()const hud = new Hud(uiRoot, state, content, connection, icons, weaponSettings)++ hud.onQualityChange = (override) => {++ const quality = qualityForTier(resolveQualityTier(override, readDeviceSignals()))++ applyEngineQuality(engine, quality)++ applySceneQuality(scene, quality)++ environment.setAntialias(quality.antialias)++ }// Touch on a coarse pointer, keyboard otherwise; a device with both// switches to whichever was used last, carrying the view angle across.@@@ -123,6 +123,14 @@ export class Environment {}}++ /** Edge smoothing (FXAA in the post pipeline): follows the graphics quality tier, kept across camera rebuilds. */++ private fxaa = true++++ setAntialias(on: boolean): void {++ this.fxaa = on++ if (this.pipeline) this.pipeline.fxaaEnabled = on++ }++/** Attach the HDR tonemapping pipeline to the active gameplay camera. */attachCamera(camera: Camera): void {this.pipeline?.dispose()@@@ -131,7 +139,7 @@ export class Environment {this.pipeline.imageProcessing.toneMappingEnabled = truethis.pipeline.imageProcessing.ditheringEnabled = truethis.pipeline.imageProcessing.exposure = 1.1-- this.pipeline.fxaaEnabled = true++ this.pipeline.fxaaEnabled = this.fxaa}/** Sync toward the server's shared day fraction (smoothed, no sun jumps). */nnew file mode 100644@@@ -0,0 +1,257 @@++/**++ * Quality-tier resolution and persistence, with no DOM and no GPU: the whole++ * point of keeping those two pure is that the browser bootstrap is the only++ * place allowed to make device-specific mistakes.++ */++import { describe, expect, it, vi } from 'vitest'++import type { AbstractEngine } from '@babylonjs/core/Engines/abstractEngine.js'++import {++ applyEngineQuality,++ applySceneQuality,++ getQualityOverride,++ isLowEnd,++ parseQualityOverride,++ QUALITY_STORAGE_KEY,++ qualityForTier,++ readDeviceSignals,++ resolveQualityTier,++ resolveStartupQuality,++ setQualityOverride,++ type DeviceSignals,++ type QualityStorage,++} from './quality.js'++++/** In-memory Storage stand-in — the tests must not need jsdom. */++class MemoryStorage implements QualityStorage {++ private readonly map = new Map<string, string>()++ getItem(key: string): string | null {++ return this.map.get(key) ?? null++ }++ setItem(key: string, value: string): void {++ this.map.set(key, value)++ }++ removeItem(key: string): void {++ this.map.delete(key)++ }++}++++/** Ordinary desktop defaults; override one field per test. */++const signals = (over: Partial<DeviceSignals> = {}): DeviceSignals => ({++ coarsePointer: false,++ hardwareConcurrency: 8,++ deviceMemory: 8,++ ...over,++})++++describe('resolveQualityTier', () => {++ it('lets the player override win over every device signal', () => {++ const weakTouch = signals({ coarsePointer: true, hardwareConcurrency: 2, deviceMemory: 2 })++ expect(resolveQualityTier('high', weakTouch)).toBe('high')++ expect(resolveQualityTier('medium', signals({ hardwareConcurrency: 16 }))).toBe('medium')++ expect(resolveQualityTier('low', signals())).toBe('low')++ })++++ it('defaults a touch device with no other evidence to medium', () => {++ // A phone is not a "weak desktop": medium, not high.++ expect(resolveQualityTier('auto', signals({ coarsePointer: true }))).toBe('medium')++ })++++ it('drops genuinely weak hardware to low', () => {++ expect(resolveQualityTier('auto', signals({ hardwareConcurrency: 2 }))).toBe('low')++ expect(resolveQualityTier('auto', signals({ deviceMemory: 2 }))).toBe('low')++ })++++ it('treats a weak touch device as low, not medium', () => {++ // The touch default is a default: concrete low-end evidence overrides it,++ // otherwise the weakest devices — mostly phones — would never reach low.++ expect(resolveQualityTier('auto', signals({ coarsePointer: true, deviceMemory: 2 }))).toBe(++ 'low',++ )++ })++++ it('leaves an ordinary machine on high', () => {++ expect(resolveQualityTier('auto', signals())).toBe('high')++ })++++ it('does not mistake an ordinary 4-core / 4 GB machine for low-end', () => {++ expect(resolveQualityTier('auto', signals({ hardwareConcurrency: 4, deviceMemory: 4 }))).toBe(++ 'high',++ )++ })++++ it('treats unknown device facts as capable rather than weak', () => {++ const unknown = signals({ hardwareConcurrency: undefined, deviceMemory: undefined })++ expect(resolveQualityTier('auto', unknown)).toBe('high')++ })++})++++describe('isLowEnd', () => {++ it('ignores absent or nonsensical values instead of failing into low', () => {++ expect(isLowEnd({ hardwareConcurrency: undefined, deviceMemory: undefined })).toBe(false)++ expect(isLowEnd({ hardwareConcurrency: 0, deviceMemory: 0 })).toBe(false)++ expect(isLowEnd({ hardwareConcurrency: Number.NaN, deviceMemory: Number.NaN })).toBe(false)++ })++})++++describe('parseQualityOverride', () => {++ it('accepts exactly the four valid values', () => {++ expect(parseQualityOverride('high')).toBe('high')++ expect(parseQualityOverride('medium')).toBe('medium')++ expect(parseQualityOverride('low')).toBe('low')++ expect(parseQualityOverride('auto')).toBe('auto')++ })++++ it('falls back to auto for anything else', () => {++ expect(parseQualityOverride(null)).toBe('auto')++ expect(parseQualityOverride('')).toBe('auto')++ expect(parseQualityOverride('ultra')).toBe('auto')++ expect(parseQualityOverride('HIGH')).toBe('auto')++ expect(parseQualityOverride('{"tier":"low"}')).toBe('auto')++ })++})++++describe('quality persistence', () => {++ it('defaults to auto with nothing stored', () => {++ expect(getQualityOverride(new MemoryStorage())).toBe('auto')++ })++++ it('round-trips each tier through storage', () => {++ const storage = new MemoryStorage()++ for (const tier of ['high', 'medium', 'low'] as const) {++ setQualityOverride(tier, storage)++ expect(storage.getItem(QUALITY_STORAGE_KEY)).toBe(tier)++ expect(getQualityOverride(storage)).toBe(tier)++ }++ })++++ it('clears the key when set back to auto', () => {++ const storage = new MemoryStorage()++ setQualityOverride('low', storage)++ setQualityOverride('auto', storage)++ expect(storage.getItem(QUALITY_STORAGE_KEY)).toBeNull()++ expect(getQualityOverride(storage)).toBe('auto')++ })++++ it('ignores corrupt stored values rather than breaking boot', () => {++ const storage = new MemoryStorage()++ storage.setItem(QUALITY_STORAGE_KEY, '{not json')++ expect(getQualityOverride(storage)).toBe('auto')++ storage.setItem(QUALITY_STORAGE_KEY, 'ultra')++ expect(getQualityOverride(storage)).toBe('auto')++ })++++ it('survives a storage that throws on every access', () => {++ const hostile: QualityStorage = {++ getItem: () => {++ throw new Error('denied')++ },++ setItem: () => {++ throw new Error('denied')++ },++ removeItem: () => {++ throw new Error('denied')++ },++ }++ expect(getQualityOverride(hostile)).toBe('auto')++ expect(() => setQualityOverride('low', hostile)).not.toThrow()++ })++ it('keeps the choice for the session when the default storage cannot hold it', () => {++ // No localStorage here (Node), as in a browser that blocks it: the choice still sticks until reload.++ setQualityOverride('medium')++ expect(getQualityOverride()).toBe('medium')++ setQualityOverride('auto')++ expect(getQualityOverride()).toBe('auto')++ })++})++++describe('resolveStartupQuality', () => {++ it('uses the persisted override over the device heuristic', () => {++ const storage = new MemoryStorage()++ setQualityOverride('high', storage)++ const settings = resolveStartupQuality(++ storage,++ signals({ coarsePointer: true, deviceMemory: 2 }),++ )++ expect(settings.tier).toBe('high')++ expect(settings.antialias).toBe(true)++ })++++ it('defaults a touch device to medium settings', () => {++ const settings = resolveStartupQuality(new MemoryStorage(), signals({ coarsePointer: true }))++ expect(settings.tier).toBe('medium')++ expect(settings.antialias).toBe(false)++ expect(settings.shadows).toBe(true)++ })++++ it('drops a low-end desktop to low settings with shadows off', () => {++ const settings = resolveStartupQuality(new MemoryStorage(), signals({ hardwareConcurrency: 2 }))++ expect(settings.tier).toBe('low')++ expect(settings.shadows).toBe(false)++ })++++ it('leaves a capable desktop at full resolution with AA and shadows', () => {++ const settings = resolveStartupQuality(new MemoryStorage(), signals())++ expect(settings.tier).toBe('high')++ expect(settings.hardwareScalingLevel).toBe(1)++ expect(settings.antialias).toBe(true)++ expect(settings.shadows).toBe(true)++ })++})++++describe('qualityForTier', () => {++ it('renders progressively fewer pixels as the tier falls', () => {++ const { high, medium, low } = {++ high: qualityForTier('high'),++ medium: qualityForTier('medium'),++ low: qualityForTier('low'),++ }++ expect(high.hardwareScalingLevel).toBe(1)++ expect(medium.hardwareScalingLevel).toBeGreaterThan(high.hardwareScalingLevel)++ expect(low.hardwareScalingLevel).toBeGreaterThan(medium.hardwareScalingLevel)++ })++++ it('turns antialiasing off below high and shadows off at low', () => {++ expect(qualityForTier('high')).toMatchObject({ antialias: true, shadows: true })++ expect(qualityForTier('medium')).toMatchObject({ antialias: false, shadows: true })++ expect(qualityForTier('low')).toMatchObject({ antialias: false, shadows: false })++ })++})++++describe('readDeviceSignals', () => {++ it('reads the coarse-pointer query and the navigator facts', () => {++ const matchMedia = vi.fn((query: string) => ({ matches: query === '(pointer: coarse)' }))++ const result = readDeviceSignals({++ matchMedia,++ navigator: { hardwareConcurrency: 4, deviceMemory: 2 },++ })++ expect(result).toEqual({++ coarsePointer: true,++ hardwareConcurrency: 4,++ deviceMemory: 2,++ })++ expect(matchMedia).toHaveBeenCalledWith('(pointer: coarse)')++ })++++ it('tolerates a browser missing matchMedia or navigator entirely', () => {++ expect(readDeviceSignals({})).toEqual({++ coarsePointer: false,++ hardwareConcurrency: undefined,++ deviceMemory: undefined,++ })++ })++})++++describe('applying settings', () => {++ it('sets the engine hardware scaling level', () => {++ const setHardwareScalingLevel = vi.fn()++ const engine = { setHardwareScalingLevel } as unknown as AbstractEngine++ applyEngineQuality(engine, qualityForTier('medium'))++ expect(setHardwareScalingLevel).toHaveBeenCalledWith(1.25)++ })++++ it('toggles the scene shadow switch', () => {++ const scene = { shadowsEnabled: true }++ applySceneQuality(scene, qualityForTier('low'))++ expect(scene.shadowsEnabled).toBe(false)++ })++})nnew file mode 100644@@@ -0,0 +1,178 @@++/**++ * Client quality tiers and hardware scaling.++ *++ * The client ships one renderer for every device: WebGPU when available,++ * WebGL otherwise. It used to be created with antialiasing on and no++ * resolution scaling — the right choice only for the fastest half of the++ * hardware it runs on. This module resolves a single tier (high/medium/low)++ * from the player's persisted choice plus a coarse, conservative read of the++ * device (touch screen, very few cores, very little memory) and maps it to the++ * small, testable table of renderer consequences the bootstrap applies.++ *++ * Nothing here touches the DOM at module load: tier resolution and persistence++ * are pure functions over plain data, so they are unit-tested without a++ * browser. The live device read is injectable for the same reason.++ */++import type { AbstractEngine } from '@babylonjs/core/Engines/abstractEngine.js'++++export const QUALITY_TIERS = ['high', 'medium', 'low'] as const++export type QualityTier = (typeof QUALITY_TIERS)[number]++++/** 'auto' = no player override, use the hardware heuristic. */++export type QualityOverride = QualityTier | 'auto'++++export const QUALITY_STORAGE_KEY = 'openvibe.quality'++++export interface QualitySettings {++ tier: QualityTier++ /**++ * Babylon hardware scaling: 1 renders at full canvas resolution, higher++ * values render fewer pixels. This is the lever that degrades most++ * gracefully, so every non-high tier raises it.++ */++ hardwareScalingLevel: number++ antialias: boolean++ shadows: boolean++}++++/**++ * Consequences per tier. Antialiasing and shadow maps are per-pixel costs a++ * weak GPU cannot recover, so they go first; medium keeps shadows (the++ * scene's authored lighting) but drops AA and renders at 80% resolution.++ */++const QUALITY_SETTINGS: Record<QualityTier, Omit<QualitySettings, 'tier'>> = {++ high: { hardwareScalingLevel: 1, antialias: true, shadows: true },++ medium: { hardwareScalingLevel: 1.25, antialias: false, shadows: true },++ low: { hardwareScalingLevel: 1.5, antialias: false, shadows: false },++}++++/** The full renderer settings for a tier. Pure — safe to unit-test. */++export function qualityForTier(tier: QualityTier): QualitySettings {++ return { tier, ...QUALITY_SETTINGS[tier] }++}++++/** Device facts the heuristic cares about, already read from the browser. */++export interface DeviceSignals {++ coarsePointer: boolean++ hardwareConcurrency: number | undefined++ deviceMemory: number | undefined++}++++/** The slice of `window`/`navigator` this module reads; injectable for tests. */++export interface SignalSource {++ matchMedia?: (query: string) => { matches: boolean }++ navigator?: { hardwareConcurrency?: number; deviceMemory?: number }++}++++/** Read live device signals, tolerating a browser that lacks any of them. */++export function readDeviceSignals(++ source: SignalSource = globalThis as unknown as SignalSource,++): DeviceSignals {++ const nav = source.navigator++ return {++ coarsePointer: source.matchMedia?.('(pointer: coarse)')?.matches ?? false,++ hardwareConcurrency:++ typeof nav?.hardwareConcurrency === 'number' ? nav.hardwareConcurrency : undefined,++ deviceMemory: typeof nav?.deviceMemory === 'number' ? nav.deviceMemory : undefined,++ }++}++++// Deliberately conservative: a device has to be genuinely small to be called++// low-end. `hardwareConcurrency` counts logical cores and 4 is an ordinary++// laptop or phone, so the bar is 2 cores / 2 GB — "below average" is not++// evidence that medium settings are unaffordable.++const LOW_END_MAX_CORES = 2++const LOW_END_MAX_MEMORY_GB = 2++++/** Conservative low-end read: only clearly small devices qualify. */++export function isLowEnd(++ signals: Pick<DeviceSignals, 'hardwareConcurrency' | 'deviceMemory'>,++): boolean {++ const cores = signals.hardwareConcurrency++ const memory = signals.deviceMemory++ return (++ (typeof cores === 'number' && cores > 0 && cores <= LOW_END_MAX_CORES) ||++ (typeof memory === 'number' && memory > 0 && memory <= LOW_END_MAX_MEMORY_GB)++ )++}++++/**++ * The player's override always wins. Otherwise weak hardware goes to low —++ * including a weak touch device, where medium would still be too much — and a++ * touch device with no such evidence defaults to medium rather than high.++ */++export function resolveQualityTier(override: QualityOverride, signals: DeviceSignals): QualityTier {++ if (override !== 'auto') return override++ if (isLowEnd(signals)) return 'low'++ if (signals.coarsePointer) return 'medium'++ return 'high'++}++++/**++ * Minimal storage surface. Structural on purpose: tests pass an in-memory++ * object and boot survives a hostile `localStorage` without a DOM.++ */++export interface QualityStorage {++ getItem(key: string): string | null++ setItem(key: string, value: string): void++ removeItem(key: string): void++}++++/** Tolerant of anything on disk: only an exact tier (or 'auto') is valid. */++export function parseQualityOverride(raw: string | null): QualityOverride {++ return raw === 'high' || raw === 'medium' || raw === 'low' || raw === 'auto' ? raw : 'auto'++}++++/**++ * The choice made this session (default storage only): it wins over what storage says, so a browser that cannot++ * store it (private mode, a blocked localStorage) still shows and applies the player's choice until reload.++ */++let sessionOverride: QualityOverride | null = null++++/** The persisted player override; 'auto' when unset, corrupt or unreadable. */++export function getQualityOverride(storage?: QualityStorage): QualityOverride {++ if (!storage && sessionOverride) return sessionOverride++ try {++ return parseQualityOverride((storage ?? localStorage).getItem(QUALITY_STORAGE_KEY))++ } catch {++ return 'auto'++ }++}++++/** Persist the player's choice; 'auto' clears it, returning to the heuristic. */++export function setQualityOverride(override: QualityOverride, storage?: QualityStorage): void {++ if (!storage) sessionOverride = override++ try {++ const store = storage ?? localStorage++ if (override === 'auto') store.removeItem(QUALITY_STORAGE_KEY)++ else store.setItem(QUALITY_STORAGE_KEY, override)++ } catch {++ // Private mode / quota: a quality preference is a convenience, never a++ // blocker. The next resolution simply falls back to the device heuristic.++ }++}++++/** Resolve the settings to boot with: player override first, device second. */++export function resolveStartupQuality(++ storage?: QualityStorage,++ signals: DeviceSignals = readDeviceSignals(),++): QualitySettings {++ return qualityForTier(resolveQualityTier(getQualityOverride(storage), signals))++}++++/**++ * Apply the parts of a tier an existing engine can change. Resolution scaling++ * takes effect immediately; antialiasing is fixed at construction, so a change++ * to it only lands on the next load. Shadow maps go through the Scene below.++ */++export function applyEngineQuality(engine: AbstractEngine, settings: QualitySettings): void {++ engine.setHardwareScalingLevel(settings.hardwareScalingLevel)++}++++/** Shadow maps are a Scene concern, not an engine one. */++export function applySceneQuality(++ scene: { shadowsEnabled: boolean },++ settings: QualitySettings,++): void {++ scene.shadowsEnabled = settings.shadows++}@@@ -32,6 +32,12 @@ import {type SurfaceMaterialData,type StaticObjectV2,} from '@openvibe/content'++import {++ applyEngineQuality,++ applySceneQuality,++ resolveStartupQuality,++ type QualitySettings,++} from './quality.js'import { Water } from './water.js'import {applyPatchTexture,@@@ -46,13 +52,26 @@ import {* which one is active.*/++/**++ * The quality profile an engine was booted with. Shadow maps live on Scene,++ * not on the engine, so createScene needs the profile without re-reading the++ * device (which could disagree with the engine's antialiasing decision).++ */++const engineQuality = new WeakMap<AbstractEngine, QualitySettings>()++export async function createEngine(canvas: HTMLCanvasElement): Promise<AbstractEngine> {++ const quality = resolveStartupQuality()++ let engine: AbstractEngineif (await WebGPUEngine.IsSupportedAsync) {-- const engine = new WebGPUEngine(canvas, { antialias: true })-- await engine.initAsync()-- return engine++ const webgpu = new WebGPUEngine(canvas, { antialias: quality.antialias })++ await webgpu.initAsync()++ engine = webgpu++ } else {++ engine = new Engine(canvas, quality.antialias)}-- return new Engine(canvas, true)++ applyEngineQuality(engine, quality)++ engineQuality.set(engine, quality)++ return engine}export function createScene(engine: AbstractEngine): Scene {@@@ -60,6 +79,8 @@ export function createScene(engine: AbstractEngine): Scene {// Fallback sky color for engines without the atmosphere addon; the// Environment module owns all lights.scene.clearColor = new Color4(0.45, 0.62, 0.82, 1)++ const quality = engineQuality.get(engine)++ if (quality) applySceneQuality(scene, quality)return scene}@@@ -4,6 +4,14 @@ import type { Connection } from '../net/connection.js'import type { ClientState } from '../state/clientState.js'import type { IconFactory } from './iconFactory.js'import { weaponModuleFor, type WeaponSettings } from '../weapons/registry.js'++import {++ getQualityOverride,++ readDeviceSignals,++ resolveQualityTier,++ setQualityOverride,++ type QualityOverride,++ type QualityTier,++} from '../render/quality.js'/*** HUD: crosshair/prompt/toasts, the always-visible hotbar, and a single@@@ -15,7 +23,8 @@ import { weaponModuleFor, type WeaponSettings } from '../weapons/registry.js'* 3D models. All mutations round-trip through the server.*/--type MenuTab = 'inventory' | 'crafting' | 'equipment' | 'skills' | 'standing' | 'players'++type MenuTab =++ 'inventory' | 'crafting' | 'equipment' | 'skills' | 'standing' | 'players' | 'settings'export class Hud {private root: HTMLElement@@@ -30,6 +39,8 @@ export class Hud {private activeTab: MenuTab = 'inventory'private dragFrom: number | null = nullonUiCaptureChange: ((captured: boolean) => void) | null = null++ /** Applies a new graphics quality to the running engine and scene (wired in main.ts). */++ onQualityChange: ((override: QualityOverride) => void) | null = nullconstructor(root: HTMLElement,@@@ -523,6 +534,7 @@ export class Hud {['equipment', 'Equipment'],['skills', 'Skills'],['players', 'Players'],++ ['settings', 'Settings'],]for (const [tab, label] of defs) {const b = document.createElement('button')@@@ -561,9 +573,72 @@ export class Hud {else if (this.activeTab === 'equipment') this.renderEquipment()else if (this.activeTab === 'skills') this.renderSkills()else if (this.activeTab === 'standing') this.renderStanding()++ else if (this.activeTab === 'settings') this.renderSettings()else this.renderPlayers()}++ /** Settings: graphics quality (Auto follows the device; resolution and shadows apply at once). */++ private renderSettings(): void {++ const tierLabel: Record<QualityTier, string> = { high: 'High', medium: 'Medium', low: 'Low' }++ const wrap = document.createElement('div')++ wrap.className = 'settings-list'++ const row = document.createElement('div')++ row.className = 'skill-row'++ const head = document.createElement('div')++ head.className = 'skill-head'++ const title = document.createElement('span')++ title.id = 'quality-label'++ title.textContent = 'Graphics quality'++ const now = document.createElement('span')++ now.className = 'skill-level'++ head.append(title, now)++ // A button group (Tab + Enter/Space), not a radiogroup: each option is its own stop and says whether it is pressed.++ const seg = document.createElement('div')++ seg.className = 'segmented'++ seg.setAttribute('role', 'group')++ seg.setAttribute('aria-labelledby', 'quality-label')++ const hint = document.createElement('div')++ hint.className = 'hint-line'++ const options: [QualityOverride, string][] = [++ ['auto', 'Auto'],++ ['high', 'High'],++ ['medium', 'Medium'],++ ['low', 'Low'],++ ]++ const buttons: [QualityOverride, HTMLButtonElement][] = []++ // Updated in place, so the pressed button keeps keyboard focus.++ const sync = (): void => {++ const override = getQualityOverride()++ const effective = resolveQualityTier(override, readDeviceSignals())++ now.textContent = tierLabel[effective]++ for (const [value, b] of buttons) {++ const on = value === override++ b.className = on ? 'segmented-option active' : 'segmented-option'++ b.setAttribute('aria-pressed', String(on))++ }++ hint.textContent =++ (override === 'auto' ? `Auto picks ${tierLabel[effective]} for this device. ` : '') +++ 'Changes apply right away.'++ }++ for (const [value, label] of options) {++ const b = document.createElement('button')++ b.type = 'button'++ b.textContent = label++ b.addEventListener('click', () => {++ if (value === getQualityOverride()) return++ setQualityOverride(value)++ this.onQualityChange?.(value)++ sync()++ })++ buttons.push([value, b])++ seg.appendChild(b)++ }++ sync()++ row.append(head, seg, hint)++ wrap.appendChild(row)++ this.menuBodyEl.appendChild(wrap)++ }++/** Faction standings: who likes you, who wants you dead. */private renderStanding(): void {const wrap = document.createElement('div')@@@ -559,7 +559,7 @@ body.ovg-frame-hidden #ov-footer {left: 50%;bottom: 92px;transform: translateX(-50%);-- width: 460px;++ width: min(460px, calc(100vw - 16px));max-height: 62vh;display: none;flex-direction: column;@@@ -573,16 +573,19 @@ body.ovg-frame-hidden #ov-footer {.menu-tabs {display: flex;++ overflow-x: auto;++ scrollbar-width: none;border-bottom: 1px solid #2a333d;background: rgba(16, 21, 27, 0.9);}.menu-tab {-- flex: 1;++ flex: 1 0 auto;background: none;border: 0;color: #8fa0b0;-- padding: 11px 0;++ padding: 11px 10px;++ white-space: nowrap;font-size: 13px;letter-spacing: 0.05em;cursor: pointer;@@@ -689,6 +692,52 @@ body.ovg-frame-hidden #ov-footer {gap: 4px;}++.settings-list {++ display: flex;++ flex-direction: column;++ gap: 14px;++}++++/* A segmented choice in the menu's own palette: the selected option takes the tab accent. */++.segmented {++ display: flex;++ gap: 4px;++ padding: 3px;++ margin: 6px 0 8px;++ background: #1d242c;++ border: 1px solid #2a333d;++ border-radius: 8px;++}++++.segmented-option {++ flex: 1;++ min-height: 40px;++ background: none;++ border: 0;++ border-radius: 6px;++ color: #8fa0b0;++ font-size: 13px;++ letter-spacing: 0.03em;++ cursor: pointer;++ transition:++ background 0.12s,++ color 0.12s;++}++++.segmented-option:hover {++ color: #cfd8e0;++}++++.segmented-option.active {++ background: #2a333d;++ color: #f0b040;++}++++.segmented-option:focus-visible {++ outline: 2px solid #f0b040;++ outline-offset: 1px;++}++.weapon-title {font-size: 15px;color: #f0b040;nnew file mode 100644@@@ -0,0 +1,3 @@++// file: 'apps/client/src/render/quality.test.ts'++// covers: 'apps/client/src/render'++// Generated by node test/run.js --index; do not edit.
@@ -37,6 +37,14 @@ import {
@@ -37,6 +37,14 @@ import {
registerMapAssets,
registerMapAssets,
rebuildTerrainVisual,
rebuildTerrainVisual,
} from './render/sceneSetup.js'
} from './render/sceneSetup.js'
+import {
+ applyEngineQuality,
+ applySceneQuality,
+ qualityForTier,
+ readDeviceSignals,
+ resolveQualityTier,
+ resolveStartupQuality,
+} from './render/quality.js'
import { Viewmodel } from './render/viewmodel.js'
import { Viewmodel } from './render/viewmodel.js'
import { ClientState } from './state/clientState.js'
import { ClientState } from './state/clientState.js'
import { characterSelect, type CharacterInfo } from './ui/characterSelect.js'
import { characterSelect, type CharacterInfo } from './ui/characterSelect.js'
@@ -95,6 +103,7 @@ async function start(): Promise<void> {
@@ -95,6 +103,7 @@ async function start(): Promise<void> {
const rapierPromise = loadRapier()
const rapierPromise = loadRapier()
const environment = new Environment(scene, engine)
const environment = new Environment(scene, engine)
+ environment.setAntialias(resolveStartupQuality().antialias)
// Render immediately so the customization preview is live.
// Render immediately so the customization preview is live.
let gameLoop: (() => void) | null = null
let gameLoop: (() => void) | null = null
@@ -171,6 +180,12 @@ async function start(): Promise<void> {
@@ -171,6 +180,12 @@ async function start(): Promise<void> {
registerPhysgunModule()
registerPhysgunModule()
registerRiggingModule()
registerRiggingModule()
const hud = new Hud(uiRoot, state, content, connection, icons, weaponSettings)
const hud = new Hud(uiRoot, state, content, connection, icons, weaponSettings)
+ hud.onQualityChange = (override) => {
+ const quality = qualityForTier(resolveQualityTier(override, readDeviceSignals()))
+ applyEngineQuality(engine, quality)
+ applySceneQuality(scene, quality)
+ environment.setAntialias(quality.antialias)
+ }
// Touch on a coarse pointer, keyboard otherwise; a device with both
// Touch on a coarse pointer, keyboard otherwise; a device with both
// switches to whichever was used last, carrying the view angle across.
// switches to whichever was used last, carrying the view angle across.
@@ -123,6 +123,14 @@ export class Environment {
@@ -123,6 +123,14 @@ export class Environment {
}
}
}
}
+ /** Edge smoothing (FXAA in the post pipeline): follows the graphics quality tier, kept across camera rebuilds. */
+ private fxaa = true
+
+ setAntialias(on: boolean): void {
+ this.fxaa = on
+ if (this.pipeline) this.pipeline.fxaaEnabled = on
+ }
+
/** Attach the HDR tonemapping pipeline to the active gameplay camera. */
/** Attach the HDR tonemapping pipeline to the active gameplay camera. */
attachCamera(camera: Camera): void {
attachCamera(camera: Camera): void {
this.pipeline?.dispose()
this.pipeline?.dispose()
@@ -131,7 +139,7 @@ export class Environment {
@@ -131,7 +139,7 @@ export class Environment {
this.pipeline.imageProcessing.toneMappingEnabled = true
this.pipeline.imageProcessing.toneMappingEnabled = true
this.pipeline.imageProcessing.ditheringEnabled = true
this.pipeline.imageProcessing.ditheringEnabled = true
this.pipeline.imageProcessing.exposure = 1.1
this.pipeline.imageProcessing.exposure = 1.1
- this.pipeline.fxaaEnabled = true
+ this.pipeline.fxaaEnabled = this.fxaa
}
}
/** Sync toward the server's shared day fraction (smoothed, no sun jumps). */
/** Sync toward the server's shared day fraction (smoothed, no sun jumps). */
new file mode 100644
new file mode 100644
@@ -0,0 +1,257 @@
@@ -0,0 +1,257 @@
+/**
+ * Quality-tier resolution and persistence, with no DOM and no GPU: the whole
+ * point of keeping those two pure is that the browser bootstrap is the only
+ * place allowed to make device-specific mistakes.
+ */
+import { describe, expect, it, vi } from 'vitest'
+import type { AbstractEngine } from '@babylonjs/core/Engines/abstractEngine.js'
+import {
+ applyEngineQuality,
+ applySceneQuality,
+ getQualityOverride,
+ isLowEnd,
+ parseQualityOverride,
+ QUALITY_STORAGE_KEY,
+ qualityForTier,
+ readDeviceSignals,
+ resolveQualityTier,
+ resolveStartupQuality,
+ setQualityOverride,
+ type DeviceSignals,
+ type QualityStorage,
+} from './quality.js'
+
+/** In-memory Storage stand-in — the tests must not need jsdom. */
+class MemoryStorage implements QualityStorage {
+ private readonly map = new Map<string, string>()
+ getItem(key: string): string | null {
+ return this.map.get(key) ?? null
+ }
+ setItem(key: string, value: string): void {
+ this.map.set(key, value)
+ }
+ removeItem(key: string): void {
+ this.map.delete(key)
+ }
+}
+
+/** Ordinary desktop defaults; override one field per test. */
+const signals = (over: Partial<DeviceSignals> = {}): DeviceSignals => ({
+ coarsePointer: false,
+ hardwareConcurrency: 8,
+ deviceMemory: 8,
+ ...over,
+})
+
+describe('resolveQualityTier', () => {
+ it('lets the player override win over every device signal', () => {
+ const weakTouch = signals({ coarsePointer: true, hardwareConcurrency: 2, deviceMemory: 2 })
+ expect(resolveQualityTier('high', weakTouch)).toBe('high')
+ expect(resolveQualityTier('medium', signals({ hardwareConcurrency: 16 }))).toBe('medium')
+ expect(resolveQualityTier('low', signals())).toBe('low')
+ })
+
+ it('defaults a touch device with no other evidence to medium', () => {
+ // A phone is not a "weak desktop": medium, not high.
+ expect(resolveQualityTier('auto', signals({ coarsePointer: true }))).toBe('medium')
+ })
+
+ it('drops genuinely weak hardware to low', () => {
+ expect(resolveQualityTier('auto', signals({ hardwareConcurrency: 2 }))).toBe('low')
+ expect(resolveQualityTier('auto', signals({ deviceMemory: 2 }))).toBe('low')
+ })
+
+ it('treats a weak touch device as low, not medium', () => {
+ // The touch default is a default: concrete low-end evidence overrides it,
+ // otherwise the weakest devices — mostly phones — would never reach low.
+ expect(resolveQualityTier('auto', signals({ coarsePointer: true, deviceMemory: 2 }))).toBe(
+ 'low',
+ )
+ })
+
+ it('leaves an ordinary machine on high', () => {
+ expect(resolveQualityTier('auto', signals())).toBe('high')
+ })
+
+ it('does not mistake an ordinary 4-core / 4 GB machine for low-end', () => {
+ expect(resolveQualityTier('auto', signals({ hardwareConcurrency: 4, deviceMemory: 4 }))).toBe(
+ 'high',
+ )
+ })
+
+ it('treats unknown device facts as capable rather than weak', () => {
+ const unknown = signals({ hardwareConcurrency: undefined, deviceMemory: undefined })
+ expect(resolveQualityTier('auto', unknown)).toBe('high')
+ })
+})
+
+describe('isLowEnd', () => {
+ it('ignores absent or nonsensical values instead of failing into low', () => {
+ expect(isLowEnd({ hardwareConcurrency: undefined, deviceMemory: undefined })).toBe(false)
+ expect(isLowEnd({ hardwareConcurrency: 0, deviceMemory: 0 })).toBe(false)
+ expect(isLowEnd({ hardwareConcurrency: Number.NaN, deviceMemory: Number.NaN })).toBe(false)
+ })
+})
+
+describe('parseQualityOverride', () => {
+ it('accepts exactly the four valid values', () => {
+ expect(parseQualityOverride('high')).toBe('high')
+ expect(parseQualityOverride('medium')).toBe('medium')
+ expect(parseQualityOverride('low')).toBe('low')
+ expect(parseQualityOverride('auto')).toBe('auto')
+ })
+
+ it('falls back to auto for anything else', () => {
+ expect(parseQualityOverride(null)).toBe('auto')
+ expect(parseQualityOverride('')).toBe('auto')
+ expect(parseQualityOverride('ultra')).toBe('auto')
+ expect(parseQualityOverride('HIGH')).toBe('auto')
+ expect(parseQualityOverride('{"tier":"low"}')).toBe('auto')
+ })
+})
+
+describe('quality persistence', () => {
+ it('defaults to auto with nothing stored', () => {
+ expect(getQualityOverride(new MemoryStorage())).toBe('auto')
+ })
+
+ it('round-trips each tier through storage', () => {
+ const storage = new MemoryStorage()
+ for (const tier of ['high', 'medium', 'low'] as const) {
+ setQualityOverride(tier, storage)
+ expect(storage.getItem(QUALITY_STORAGE_KEY)).toBe(tier)
+ expect(getQualityOverride(storage)).toBe(tier)
+ }
+ })
+
+ it('clears the key when set back to auto', () => {
+ const storage = new MemoryStorage()
+ setQualityOverride('low', storage)
+ setQualityOverride('auto', storage)
+ expect(storage.getItem(QUALITY_STORAGE_KEY)).toBeNull()
+ expect(getQualityOverride(storage)).toBe('auto')
+ })
+
+ it('ignores corrupt stored values rather than breaking boot', () => {
+ const storage = new MemoryStorage()
+ storage.setItem(QUALITY_STORAGE_KEY, '{not json')
+ expect(getQualityOverride(storage)).toBe('auto')
+ storage.setItem(QUALITY_STORAGE_KEY, 'ultra')
+ expect(getQualityOverride(storage)).toBe('auto')
+ })
+
+ it('survives a storage that throws on every access', () => {
+ const hostile: QualityStorage = {
+ getItem: () => {
+ throw new Error('denied')
+ },
+ setItem: () => {
+ throw new Error('denied')
+ },
+ removeItem: () => {
+ throw new Error('denied')
+ },
+ }
+ expect(getQualityOverride(hostile)).toBe('auto')
+ expect(() => setQualityOverride('low', hostile)).not.toThrow()
+ })
+ it('keeps the choice for the session when the default storage cannot hold it', () => {
+ // No localStorage here (Node), as in a browser that blocks it: the choice still sticks until reload.
+ setQualityOverride('medium')
+ expect(getQualityOverride()).toBe('medium')
+ setQualityOverride('auto')
+ expect(getQualityOverride()).toBe('auto')
+ })
+})
+
+describe('resolveStartupQuality', () => {
+ it('uses the persisted override over the device heuristic', () => {
+ const storage = new MemoryStorage()
+ setQualityOverride('high', storage)
+ const settings = resolveStartupQuality(
+ storage,
+ signals({ coarsePointer: true, deviceMemory: 2 }),
+ )
+ expect(settings.tier).toBe('high')
+ expect(settings.antialias).toBe(true)
+ })
+
+ it('defaults a touch device to medium settings', () => {
+ const settings = resolveStartupQuality(new MemoryStorage(), signals({ coarsePointer: true }))
+ expect(settings.tier).toBe('medium')
+ expect(settings.antialias).toBe(false)
+ expect(settings.shadows).toBe(true)
+ })
+
+ it('drops a low-end desktop to low settings with shadows off', () => {
+ const settings = resolveStartupQuality(new MemoryStorage(), signals({ hardwareConcurrency: 2 }))
+ expect(settings.tier).toBe('low')
+ expect(settings.shadows).toBe(false)
+ })
+
+ it('leaves a capable desktop at full resolution with AA and shadows', () => {
+ const settings = resolveStartupQuality(new MemoryStorage(), signals())
+ expect(settings.tier).toBe('high')
+ expect(settings.hardwareScalingLevel).toBe(1)
+ expect(settings.antialias).toBe(true)
+ expect(settings.shadows).toBe(true)
+ })
+})
+
+describe('qualityForTier', () => {
+ it('renders progressively fewer pixels as the tier falls', () => {
+ const { high, medium, low } = {
+ high: qualityForTier('high'),
+ medium: qualityForTier('medium'),
+ low: qualityForTier('low'),
+ }
+ expect(high.hardwareScalingLevel).toBe(1)
+ expect(medium.hardwareScalingLevel).toBeGreaterThan(high.hardwareScalingLevel)
+ expect(low.hardwareScalingLevel).toBeGreaterThan(medium.hardwareScalingLevel)
+ })
+
+ it('turns antialiasing off below high and shadows off at low', () => {
+ expect(qualityForTier('high')).toMatchObject({ antialias: true, shadows: true })
+ expect(qualityForTier('medium')).toMatchObject({ antialias: false, shadows: true })
+ expect(qualityForTier('low')).toMatchObject({ antialias: false, shadows: false })
+ })
+})
+
+describe('readDeviceSignals', () => {
+ it('reads the coarse-pointer query and the navigator facts', () => {
+ const matchMedia = vi.fn((query: string) => ({ matches: query === '(pointer: coarse)' }))
+ const result = readDeviceSignals({
+ matchMedia,
+ navigator: { hardwareConcurrency: 4, deviceMemory: 2 },
+ })
+ expect(result).toEqual({
+ coarsePointer: true,
+ hardwareConcurrency: 4,
+ deviceMemory: 2,
+ })
+ expect(matchMedia).toHaveBeenCalledWith('(pointer: coarse)')
+ })
+
+ it('tolerates a browser missing matchMedia or navigator entirely', () => {
+ expect(readDeviceSignals({})).toEqual({
+ coarsePointer: false,
+ hardwareConcurrency: undefined,
+ deviceMemory: undefined,
+ })
+ })
+})
+
+describe('applying settings', () => {
+ it('sets the engine hardware scaling level', () => {
+ const setHardwareScalingLevel = vi.fn()
+ const engine = { setHardwareScalingLevel } as unknown as AbstractEngine
+ applyEngineQuality(engine, qualityForTier('medium'))
+ expect(setHardwareScalingLevel).toHaveBeenCalledWith(1.25)
+ })
+
+ it('toggles the scene shadow switch', () => {
+ const scene = { shadowsEnabled: true }
+ applySceneQuality(scene, qualityForTier('low'))
+ expect(scene.shadowsEnabled).toBe(false)
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,178 @@
@@ -0,0 +1,178 @@
+/**
+ * Client quality tiers and hardware scaling.
+ *
+ * The client ships one renderer for every device: WebGPU when available,
+ * WebGL otherwise. It used to be created with antialiasing on and no
+ * resolution scaling — the right choice only for the fastest half of the
+ * hardware it runs on. This module resolves a single tier (high/medium/low)
+ * from the player's persisted choice plus a coarse, conservative read of the
+ * device (touch screen, very few cores, very little memory) and maps it to the
+ * small, testable table of renderer consequences the bootstrap applies.
+ *
+ * Nothing here touches the DOM at module load: tier resolution and persistence
+ * are pure functions over plain data, so they are unit-tested without a
+ * browser. The live device read is injectable for the same reason.
+ */
+import type { AbstractEngine } from '@babylonjs/core/Engines/abstractEngine.js'
+
+export const QUALITY_TIERS = ['high', 'medium', 'low'] as const
+export type QualityTier = (typeof QUALITY_TIERS)[number]
+
+/** 'auto' = no player override, use the hardware heuristic. */
+export type QualityOverride = QualityTier | 'auto'
+
+export const QUALITY_STORAGE_KEY = 'openvibe.quality'
+
+export interface QualitySettings {
+ tier: QualityTier
+ /**
+ * Babylon hardware scaling: 1 renders at full canvas resolution, higher
+ * values render fewer pixels. This is the lever that degrades most
+ * gracefully, so every non-high tier raises it.
+ */
+ hardwareScalingLevel: number
+ antialias: boolean
+ shadows: boolean
+}
+
+/**
+ * Consequences per tier. Antialiasing and shadow maps are per-pixel costs a
+ * weak GPU cannot recover, so they go first; medium keeps shadows (the
+ * scene's authored lighting) but drops AA and renders at 80% resolution.
+ */
+const QUALITY_SETTINGS: Record<QualityTier, Omit<QualitySettings, 'tier'>> = {
+ high: { hardwareScalingLevel: 1, antialias: true, shadows: true },
+ medium: { hardwareScalingLevel: 1.25, antialias: false, shadows: true },
+ low: { hardwareScalingLevel: 1.5, antialias: false, shadows: false },
+}
+
+/** The full renderer settings for a tier. Pure — safe to unit-test. */
+export function qualityForTier(tier: QualityTier): QualitySettings {
+ return { tier, ...QUALITY_SETTINGS[tier] }
+}
+
+/** Device facts the heuristic cares about, already read from the browser. */
+export interface DeviceSignals {
+ coarsePointer: boolean
+ hardwareConcurrency: number | undefined
+ deviceMemory: number | undefined
+}
+
+/** The slice of `window`/`navigator` this module reads; injectable for tests. */
+export interface SignalSource {
+ matchMedia?: (query: string) => { matches: boolean }
+ navigator?: { hardwareConcurrency?: number; deviceMemory?: number }
+}
+
+/** Read live device signals, tolerating a browser that lacks any of them. */
+export function readDeviceSignals(
+ source: SignalSource = globalThis as unknown as SignalSource,
+): DeviceSignals {
+ const nav = source.navigator
+ return {
+ coarsePointer: source.matchMedia?.('(pointer: coarse)')?.matches ?? false,
+ hardwareConcurrency:
+ typeof nav?.hardwareConcurrency === 'number' ? nav.hardwareConcurrency : undefined,
+ deviceMemory: typeof nav?.deviceMemory === 'number' ? nav.deviceMemory : undefined,
+ }
+}
+
+// Deliberately conservative: a device has to be genuinely small to be called
+// low-end. `hardwareConcurrency` counts logical cores and 4 is an ordinary
+// laptop or phone, so the bar is 2 cores / 2 GB — "below average" is not
+// evidence that medium settings are unaffordable.
+const LOW_END_MAX_CORES = 2
+const LOW_END_MAX_MEMORY_GB = 2
+
+/** Conservative low-end read: only clearly small devices qualify. */
+export function isLowEnd(
+ signals: Pick<DeviceSignals, 'hardwareConcurrency' | 'deviceMemory'>,
+): boolean {
+ const cores = signals.hardwareConcurrency
+ const memory = signals.deviceMemory
+ return (
+ (typeof cores === 'number' && cores > 0 && cores <= LOW_END_MAX_CORES) ||
+ (typeof memory === 'number' && memory > 0 && memory <= LOW_END_MAX_MEMORY_GB)
+ )
+}
+
+/**
+ * The player's override always wins. Otherwise weak hardware goes to low —
+ * including a weak touch device, where medium would still be too much — and a
+ * touch device with no such evidence defaults to medium rather than high.
+ */
+export function resolveQualityTier(override: QualityOverride, signals: DeviceSignals): QualityTier {
+ if (override !== 'auto') return override
+ if (isLowEnd(signals)) return 'low'
+ if (signals.coarsePointer) return 'medium'
+ return 'high'
+}
+
+/**
+ * Minimal storage surface. Structural on purpose: tests pass an in-memory
+ * object and boot survives a hostile `localStorage` without a DOM.
+ */
+export interface QualityStorage {
+ getItem(key: string): string | null
+ setItem(key: string, value: string): void
+ removeItem(key: string): void
+}
+
+/** Tolerant of anything on disk: only an exact tier (or 'auto') is valid. */
+export function parseQualityOverride(raw: string | null): QualityOverride {
+ return raw === 'high' || raw === 'medium' || raw === 'low' || raw === 'auto' ? raw : 'auto'
+}
+
+/**
+ * The choice made this session (default storage only): it wins over what storage says, so a browser that cannot
+ * store it (private mode, a blocked localStorage) still shows and applies the player's choice until reload.
+ */
+let sessionOverride: QualityOverride | null = null
+
+/** The persisted player override; 'auto' when unset, corrupt or unreadable. */
+export function getQualityOverride(storage?: QualityStorage): QualityOverride {
+ if (!storage && sessionOverride) return sessionOverride
+ try {
+ return parseQualityOverride((storage ?? localStorage).getItem(QUALITY_STORAGE_KEY))
+ } catch {
+ return 'auto'
+ }
+}
+
+/** Persist the player's choice; 'auto' clears it, returning to the heuristic. */
+export function setQualityOverride(override: QualityOverride, storage?: QualityStorage): void {
+ if (!storage) sessionOverride = override
+ try {
+ const store = storage ?? localStorage
+ if (override === 'auto') store.removeItem(QUALITY_STORAGE_KEY)
+ else store.setItem(QUALITY_STORAGE_KEY, override)
+ } catch {
+ // Private mode / quota: a quality preference is a convenience, never a
+ // blocker. The next resolution simply falls back to the device heuristic.
+ }
+}
+
+/** Resolve the settings to boot with: player override first, device second. */
+export function resolveStartupQuality(
+ storage?: QualityStorage,
+ signals: DeviceSignals = readDeviceSignals(),
+): QualitySettings {
+ return qualityForTier(resolveQualityTier(getQualityOverride(storage), signals))
+}
+
+/**
+ * Apply the parts of a tier an existing engine can change. Resolution scaling
+ * takes effect immediately; antialiasing is fixed at construction, so a change
+ * to it only lands on the next load. Shadow maps go through the Scene below.
+ */
+export function applyEngineQuality(engine: AbstractEngine, settings: QualitySettings): void {
+ engine.setHardwareScalingLevel(settings.hardwareScalingLevel)
+}
+
+/** Shadow maps are a Scene concern, not an engine one. */
+export function applySceneQuality(
+ scene: { shadowsEnabled: boolean },
+ settings: QualitySettings,
+): void {
+ scene.shadowsEnabled = settings.shadows
+}
@@ -32,6 +32,12 @@ import {
@@ -32,6 +32,12 @@ import {
type SurfaceMaterialData,
type SurfaceMaterialData,
type StaticObjectV2,
type StaticObjectV2,
} from '@openvibe/content'
} from '@openvibe/content'
+import {
+ applyEngineQuality,
+ applySceneQuality,
+ resolveStartupQuality,
+ type QualitySettings,
+} from './quality.js'
import { Water } from './water.js'
import { Water } from './water.js'
import {
import {
applyPatchTexture,
applyPatchTexture,
@@ -46,13 +52,26 @@ import {
@@ -46,13 +52,26 @@ import {
* which one is active.
* which one is active.
*/
*/
+/**
+ * The quality profile an engine was booted with. Shadow maps live on Scene,
+ * not on the engine, so createScene needs the profile without re-reading the
+ * device (which could disagree with the engine's antialiasing decision).
+ */
+const engineQuality = new WeakMap<AbstractEngine, QualitySettings>()
+
export async function createEngine(canvas: HTMLCanvasElement): Promise<AbstractEngine> {
export async function createEngine(canvas: HTMLCanvasElement): Promise<AbstractEngine> {
+ const quality = resolveStartupQuality()
+ let engine: AbstractEngine
if (await WebGPUEngine.IsSupportedAsync) {
if (await WebGPUEngine.IsSupportedAsync) {
- const engine = new WebGPUEngine(canvas, { antialias: true })
- await engine.initAsync()
- return engine
+ const webgpu = new WebGPUEngine(canvas, { antialias: quality.antialias })
+ await webgpu.initAsync()
+ engine = webgpu
+ } else {
+ engine = new Engine(canvas, quality.antialias)
}
}
- return new Engine(canvas, true)
+ applyEngineQuality(engine, quality)
+ engineQuality.set(engine, quality)
+ return engine
}
}
export function createScene(engine: AbstractEngine): Scene {
export function createScene(engine: AbstractEngine): Scene {
@@ -60,6 +79,8 @@ export function createScene(engine: AbstractEngine): Scene {
@@ -60,6 +79,8 @@ export function createScene(engine: AbstractEngine): Scene {
// Fallback sky color for engines without the atmosphere addon; the
// Fallback sky color for engines without the atmosphere addon; the
// Environment module owns all lights.
// Environment module owns all lights.
scene.clearColor = new Color4(0.45, 0.62, 0.82, 1)
scene.clearColor = new Color4(0.45, 0.62, 0.82, 1)
+ const quality = engineQuality.get(engine)
+ if (quality) applySceneQuality(scene, quality)
return scene
return scene
}
}
@@ -4,6 +4,14 @@ import type { Connection } from '../net/connection.js'
@@ -4,6 +4,14 @@ import type { Connection } from '../net/connection.js'
import type { ClientState } from '../state/clientState.js'
import type { ClientState } from '../state/clientState.js'
import type { IconFactory } from './iconFactory.js'
import type { IconFactory } from './iconFactory.js'
import { weaponModuleFor, type WeaponSettings } from '../weapons/registry.js'
import { weaponModuleFor, type WeaponSettings } from '../weapons/registry.js'
+import {
+ getQualityOverride,
+ readDeviceSignals,
+ resolveQualityTier,
+ setQualityOverride,
+ type QualityOverride,
+ type QualityTier,
+} from '../render/quality.js'
/**
/**
* HUD: crosshair/prompt/toasts, the always-visible hotbar, and a single
* HUD: crosshair/prompt/toasts, the always-visible hotbar, and a single
@@ -15,7 +23,8 @@ import { weaponModuleFor, type WeaponSettings } from '../weapons/registry.js'
@@ -15,7 +23,8 @@ import { weaponModuleFor, type WeaponSettings } from '../weapons/registry.js'
* 3D models. All mutations round-trip through the server.
* 3D models. All mutations round-trip through the server.
*/
*/
-type MenuTab = 'inventory' | 'crafting' | 'equipment' | 'skills' | 'standing' | 'players'
+type MenuTab =
+ 'inventory' | 'crafting' | 'equipment' | 'skills' | 'standing' | 'players' | 'settings'
export class Hud {
export class Hud {
private root: HTMLElement
private root: HTMLElement
@@ -30,6 +39,8 @@ export class Hud {
@@ -30,6 +39,8 @@ export class Hud {
private activeTab: MenuTab = 'inventory'
private activeTab: MenuTab = 'inventory'
private dragFrom: number | null = null
private dragFrom: number | null = null
onUiCaptureChange: ((captured: boolean) => void) | null = null
onUiCaptureChange: ((captured: boolean) => void) | null = null
+ /** Applies a new graphics quality to the running engine and scene (wired in main.ts). */
+ onQualityChange: ((override: QualityOverride) => void) | null = null
constructor(
constructor(
root: HTMLElement,
root: HTMLElement,
@@ -523,6 +534,7 @@ export class Hud {
@@ -523,6 +534,7 @@ export class Hud {
['equipment', 'Equipment'],
['equipment', 'Equipment'],
['skills', 'Skills'],
['skills', 'Skills'],
['players', 'Players'],
['players', 'Players'],
+ ['settings', 'Settings'],
]
]
for (const [tab, label] of defs) {
for (const [tab, label] of defs) {
const b = document.createElement('button')
const b = document.createElement('button')
@@ -561,9 +573,72 @@ export class Hud {
@@ -561,9 +573,72 @@ export class Hud {
else if (this.activeTab === 'equipment') this.renderEquipment()
else if (this.activeTab === 'equipment') this.renderEquipment()
else if (this.activeTab === 'skills') this.renderSkills()
else if (this.activeTab === 'skills') this.renderSkills()
else if (this.activeTab === 'standing') this.renderStanding()
else if (this.activeTab === 'standing') this.renderStanding()
+ else if (this.activeTab === 'settings') this.renderSettings()
else this.renderPlayers()
else this.renderPlayers()
}
}
+ /** Settings: graphics quality (Auto follows the device; resolution and shadows apply at once). */
+ private renderSettings(): void {
+ const tierLabel: Record<QualityTier, string> = { high: 'High', medium: 'Medium', low: 'Low' }
+ const wrap = document.createElement('div')
+ wrap.className = 'settings-list'
+ const row = document.createElement('div')
+ row.className = 'skill-row'
+ const head = document.createElement('div')
+ head.className = 'skill-head'
+ const title = document.createElement('span')
+ title.id = 'quality-label'
+ title.textContent = 'Graphics quality'
+ const now = document.createElement('span')
+ now.className = 'skill-level'
+ head.append(title, now)
+ // A button group (Tab + Enter/Space), not a radiogroup: each option is its own stop and says whether it is pressed.
+ const seg = document.createElement('div')
+ seg.className = 'segmented'
+ seg.setAttribute('role', 'group')
+ seg.setAttribute('aria-labelledby', 'quality-label')
+ const hint = document.createElement('div')
+ hint.className = 'hint-line'
+ const options: [QualityOverride, string][] = [
+ ['auto', 'Auto'],
+ ['high', 'High'],
+ ['medium', 'Medium'],
+ ['low', 'Low'],
+ ]
+ const buttons: [QualityOverride, HTMLButtonElement][] = []
+ // Updated in place, so the pressed button keeps keyboard focus.
+ const sync = (): void => {
+ const override = getQualityOverride()
+ const effective = resolveQualityTier(override, readDeviceSignals())
+ now.textContent = tierLabel[effective]
+ for (const [value, b] of buttons) {
+ const on = value === override
+ b.className = on ? 'segmented-option active' : 'segmented-option'
+ b.setAttribute('aria-pressed', String(on))
+ }
+ hint.textContent =
+ (override === 'auto' ? `Auto picks ${tierLabel[effective]} for this device. ` : '') +
+ 'Changes apply right away.'
+ }
+ for (const [value, label] of options) {
+ const b = document.createElement('button')
+ b.type = 'button'
+ b.textContent = label
+ b.addEventListener('click', () => {
+ if (value === getQualityOverride()) return
+ setQualityOverride(value)
+ this.onQualityChange?.(value)
+ sync()
+ })
+ buttons.push([value, b])
+ seg.appendChild(b)
+ }
+ sync()
+ row.append(head, seg, hint)
+ wrap.appendChild(row)
+ this.menuBodyEl.appendChild(wrap)
+ }
+
/** Faction standings: who likes you, who wants you dead. */
/** Faction standings: who likes you, who wants you dead. */
private renderStanding(): void {
private renderStanding(): void {
const wrap = document.createElement('div')
const wrap = document.createElement('div')
@@ -559,7 +559,7 @@ body.ovg-frame-hidden #ov-footer {
@@ -559,7 +559,7 @@ body.ovg-frame-hidden #ov-footer {
left: 50%;
left: 50%;
bottom: 92px;
bottom: 92px;
transform: translateX(-50%);
transform: translateX(-50%);
- width: 460px;
+ width: min(460px, calc(100vw - 16px));
max-height: 62vh;
max-height: 62vh;
display: none;
display: none;
flex-direction: column;
flex-direction: column;
@@ -573,16 +573,19 @@ body.ovg-frame-hidden #ov-footer {
@@ -573,16 +573,19 @@ body.ovg-frame-hidden #ov-footer {
.menu-tabs {
.menu-tabs {
display: flex;
display: flex;
+ overflow-x: auto;
+ scrollbar-width: none;
border-bottom: 1px solid #2a333d;
border-bottom: 1px solid #2a333d;
background: rgba(16, 21, 27, 0.9);
background: rgba(16, 21, 27, 0.9);
}
}
.menu-tab {
.menu-tab {
- flex: 1;
+ flex: 1 0 auto;
background: none;
background: none;
border: 0;
border: 0;
color: #8fa0b0;
color: #8fa0b0;
- padding: 11px 0;
+ padding: 11px 10px;
+ white-space: nowrap;
font-size: 13px;
font-size: 13px;
letter-spacing: 0.05em;
letter-spacing: 0.05em;
cursor: pointer;
cursor: pointer;
@@ -689,6 +692,52 @@ body.ovg-frame-hidden #ov-footer {
@@ -689,6 +692,52 @@ body.ovg-frame-hidden #ov-footer {
gap: 4px;
gap: 4px;
}
}
+.settings-list {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+/* A segmented choice in the menu's own palette: the selected option takes the tab accent. */
+.segmented {
+ display: flex;
+ gap: 4px;
+ padding: 3px;
+ margin: 6px 0 8px;
+ background: #1d242c;
+ border: 1px solid #2a333d;
+ border-radius: 8px;
+}
+
+.segmented-option {
+ flex: 1;
+ min-height: 40px;
+ background: none;
+ border: 0;
+ border-radius: 6px;
+ color: #8fa0b0;
+ font-size: 13px;
+ letter-spacing: 0.03em;
+ cursor: pointer;
+ transition:
+ background 0.12s,
+ color 0.12s;
+}
+
+.segmented-option:hover {
+ color: #cfd8e0;
+}
+
+.segmented-option.active {
+ background: #2a333d;
+ color: #f0b040;
+}
+
+.segmented-option:focus-visible {
+ outline: 2px solid #f0b040;
+ outline-offset: 1px;
+}
+
.weapon-title {
.weapon-title {
font-size: 15px;
font-size: 15px;
color: #f0b040;
color: #f0b040;
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+// file: 'apps/client/src/render/quality.test.ts'
+// covers: 'apps/client/src/render'
+// Generated by node test/run.js --index; do not edit.