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)

OP OpenVibers committed 5 Oct 2026 · 4cb806be1a · 8 files, +615 −9

* Client quality tiers (high/medium/low) from the device or the player's choice, with a Settings tab: resolution and shadows apply live

## Changes
- `apps/client/src/render/quality.ts` (new) — tier types + `QUALITY_TIERS`, `qualityForTier` (:50), `readDeviceSignals` (:68), `isLowEnd` (:88), `resolveQualityTier` (:104), `parseQualityOverride` (:122), `getQualityOverride` (:127), `setQualityOverride` (:136), `resolveStartupQuality` (:148), `applyEngineQuality` (:160, wraps `engine.setHardwareScalingLevel`), `applySceneQuality` (:165, shadow switch). Key `openvibe.quality`.
- `apps/client/src/render/sceneSetup.ts:30-40` — import quality helpers; `:60` weak-map of engine→profile; `:63` resolve at boot; `:66` WebGPU `antialias: quality.antialias`; `:70` `new Engine(canvas, quality.antialias)`; `:72` hardware scaling; `:82-83` `createScene` applies the shadow switch.
- `apps/client/src/render/quality.test.ts` (new) — 25 DOM-free tests: override precedence, touch→medium, low-end→low, 4-core/4GB not low-end, persistence round-trip/clearing/corrupt/hostile storage, tier table, device-signal reading, engine/scene application.

## Line numbers vs. the brief
No drift: `createEngine` sceneSetup.ts:49, WebGPU :51, Engine :55, main.ts:85 all matched. One structural correction: shadows live on `Scene`, not `AbstractEngine`, so antialiasing + hardware scaling are applied in the two engine branches and the shadow reduction in `createScene` (via the weak-map), not at :51/:55.

## What a settings control calls / where the UI lives
- Call `setQualityOverride(tier)` (`quality.ts:136`) to persist; read `getQualityOverride()` (`:127`) to show the current value. For immediate effect after a change, also call `applyEngineQuality(engine, qualityForTier(tier))` (resolution is live) and `applySceneQuality(scene, qualityForTier(tier))` (shadows are live); antialiasing is fixed at construction, so its change lands on the next load.
- The client's existing settings UI is the in-game Tab menu: `Hud` in `apps/client/src/ui/hud.ts` — `toggleMenu()`/`renderTabs()` (:497/:517), with `renderEquipment()` (:784) rendering per-tool settings panels via `module.buildPanel(panel, this.weaponSettings)` (:843), backed by the `WeaponSettings` localStorage store in `apps/client/src/weapons/registry.ts:12`. There is no dedicated graphics/settings tab; UI work was explicitly out of scope here, so nothing there was touched.

## Checks
- `nice -n 15 ov test --no-cache apps/client/src/render/quality.test.ts` → `0 of 106 test files cover it / nothing to run`: `ov` only indexes test files present in the base commit, so it cannot discover a brand-new untracked `*.test.ts` before commit (same result with the files intent-to-added).
- Fallback single-file run: `nice -n 15 npx vitest run --no-cache apps/client/src/render/quality.test.ts` → **1 file, 25 tests passed**.
- `nice -n 15 npx eslint` on the three files → clean; `prettier --check` → clean.
- Not committed or pushed.

* Quality: FXAA follows the tier live, the settings control is a pressed-button group updated in place (focus kept), the menu fits phones with scrolling tabs, and a blocked storage keeps the choice for the session (review)

Changes

ddiff --git a/apps/client/src/main.ts b/apps/client/src/main.ts
iindex 1527ff5..2fb5a71 100644
---- a/apps/client/src/main.ts
++++ b/apps/client/src/main.ts
@@@ -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.
ddiff --git a/apps/client/src/render/environment.ts b/apps/client/src/render/environment.ts
iindex ddd561d..121a880 100644
---- a/apps/client/src/render/environment.ts
++++ b/apps/client/src/render/environment.ts
@@@ -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 = true
this.pipeline.imageProcessing.ditheringEnabled = true
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). */
ddiff --git a/apps/client/src/render/quality.test.ts b/apps/client/src/render/quality.test.ts
nnew file mode 100644
iindex 0000000..a2c178c
---- /dev/null
++++ b/apps/client/src/render/quality.test.ts
@@@ -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)
++ })
++})
ddiff --git a/apps/client/src/render/quality.ts b/apps/client/src/render/quality.ts
nnew file mode 100644
iindex 0000000..ee7fd37
---- /dev/null
++++ b/apps/client/src/render/quality.ts
@@@ -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
++}
ddiff --git a/apps/client/src/render/sceneSetup.ts b/apps/client/src/render/sceneSetup.ts
iindex c64591c..860c783 100644
---- a/apps/client/src/render/sceneSetup.ts
++++ b/apps/client/src/render/sceneSetup.ts
@@@ -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: AbstractEngine
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 {
@@@ -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
}
ddiff --git a/apps/client/src/ui/hud.ts b/apps/client/src/ui/hud.ts
iindex ba80096..10e3fc3 100644
---- a/apps/client/src/ui/hud.ts
++++ b/apps/client/src/ui/hud.ts
@@@ -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 = 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(
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')
ddiff --git a/apps/client/src/ui/styles.css b/apps/client/src/ui/styles.css
iindex e68d645..3179edd 100644
---- a/apps/client/src/ui/styles.css
++++ b/apps/client/src/ui/styles.css
@@@ -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;
ddiff --git a/test/apps__client__src__render__quality.test.js b/test/apps__client__src__render__quality.test.js
nnew file mode 100644
iindex 0000000..8efa786
---- /dev/null
++++ b/test/apps__client__src__render__quality.test.js
@@@ -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.