From 579adfd22d374af5939598f72987f775f576e39d Mon Sep 17 00:00:00 2001 From: evanpelle Date: Wed, 10 Jun 2026 19:49:17 -0700 Subject: [PATCH] dpr --- resources/lang/en.json | 3 + src/client/ClientGameRunner.ts | 3 +- .../hud/layers/GraphicsSettingsModal.ts | 57 +++++++++++++++++++ src/client/render/gl/Camera.ts | 15 +++-- src/client/render/gl/GraphicsOverrides.ts | 5 ++ src/client/render/gl/RenderOverrides.ts | 3 + src/client/render/gl/RenderSettings.ts | 7 +++ src/client/render/gl/Renderer.ts | 22 ++++++- src/client/render/gl/debug/Layout.ts | 4 ++ src/client/render/gl/index.ts | 1 + src/client/render/gl/passes/RadialMenuPass.ts | 11 +++- src/client/render/gl/passes/WorldTextPass.ts | 5 +- src/client/render/gl/render-settings.json | 3 + src/client/render/gl/utils/Dpr.ts | 12 ++++ tests/GraphicsOverrides.test.ts | 24 ++++++++ 15 files changed, 162 insertions(+), 13 deletions(-) create mode 100644 src/client/render/gl/utils/Dpr.ts diff --git a/resources/lang/en.json b/resources/lang/en.json index 3e725c55f..cdceac53f 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -960,6 +960,9 @@ "rail_distance_desc": "How far zoomed out train tracks remain visible", "rail_thickness_label": "Train track thickness", "rail_thickness_desc": "How wide train tracks are drawn", + "section_display": "Display", + "dpr_scale_label": "Render resolution", + "dpr_scale_desc": "Scales the render resolution — lower improves performance", "section_effects": "Effects", "reset_label": "Reset to defaults", "reset_desc": "Clear all graphics overrides" diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index eb806dccf..66e334594 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -74,6 +74,7 @@ import { createDebugGui, createRenderSettings, deepAssign, + getDpr, GameView as WebGLGameView, } from "./render/gl"; import { ALL_UNIT_TYPES, UnitState } from "./render/types"; @@ -351,7 +352,7 @@ function mountWebGLFrameLoop( const syncCamera = (): void => { const scale = transformHandler.scale; - const dpr = window.devicePixelRatio || 1; + const dpr = getDpr(view.getSettings()); const centerX = transformHandler.offsetX + mapWidth / 2 + diff --git a/src/client/hud/layers/GraphicsSettingsModal.ts b/src/client/hud/layers/GraphicsSettingsModal.ts index 5ffd316c1..f558a13b8 100644 --- a/src/client/hud/layers/GraphicsSettingsModal.ts +++ b/src/client/hud/layers/GraphicsSettingsModal.ts @@ -50,6 +50,10 @@ const RAIL_THICKNESS_MIN = 0.5; const RAIL_THICKNESS_MAX = 3; const RAIL_THICKNESS_STEP = 0.1; +const DPR_SCALE_MIN = 0.25; +const DPR_SCALE_MAX = 1; +const DPR_SCALE_STEP = 0.05; + export class ShowGraphicsSettingsModalEvent { constructor( public readonly isVisible: boolean = true, @@ -233,6 +237,27 @@ export class GraphicsSettingsModal extends LitElement implements Controller { ); } + private currentDprScale(): number { + return ( + this.userSettings.graphicsOverrides().display?.dprScale ?? + renderDefaults.display.dprScale + ); + } + + private patchDisplay(patch: Partial) { + const current = this.userSettings.graphicsOverrides(); + this.userSettings.setGraphicsOverrides({ + ...current, + display: { ...current.display, ...patch }, + }); + this.requestUpdate(); + } + + private onDprScaleChange(event: Event) { + const value = parseFloat((event.target as HTMLInputElement).value); + this.patchDisplay({ dprScale: value }); + } + private onHighlightFillChange(event: Event) { const value = parseFloat((event.target as HTMLInputElement).value); this.patchMapOverlay({ highlightFillBrighten: value }); @@ -339,6 +364,7 @@ export class GraphicsSettingsModal extends LitElement implements Controller { const territoryAlpha = this.currentTerritoryAlpha(); const railDrawDistance = RAIL_ZOOM_MAX - this.currentRailMinZoom(); const railThickness = this.currentRailThickness(); + const dprScale = this.currentDprScale(); return html`
+
+ ${translateText("graphics_setting.section_display")} +
+ +
+
+
+ ${translateText("graphics_setting.dpr_scale_label")} +
+
+ ${translateText("graphics_setting.dpr_scale_desc")} +
+ +
+
+ ${dprScale.toFixed(2)} +
+
+
diff --git a/src/client/render/gl/Camera.ts b/src/client/render/gl/Camera.ts index 1bd4cbf8d..fa32d14d8 100644 --- a/src/client/render/gl/Camera.ts +++ b/src/client/render/gl/Camera.ts @@ -15,6 +15,9 @@ * ty = -offsetY * sy */ +import type { RenderSettings } from "./RenderSettings"; +import { getDpr } from "./utils/Dpr"; + const MIN_ZOOM = 0.2; const MAX_ZOOM = 20; const DBLCLICK_MIN_ZOOM = 0.7; @@ -34,7 +37,11 @@ export class Camera { /** True until fitMap() has been called with valid canvas dimensions. */ private needsInitialFit = true; - constructor(mapWidth: number, mapHeight: number) { + constructor( + mapWidth: number, + mapHeight: number, + private settings: RenderSettings, + ) { this.mapW = mapWidth; this.mapH = mapHeight; this.offsetX = mapWidth / 2; @@ -44,7 +51,7 @@ export class Camera { /** Update canvas pixel dimensions. Triggers initial fitMap on first call. */ resize(cssWidth: number, cssHeight: number): void { - const dpr = window.devicePixelRatio || 1; + const dpr = getDpr(this.settings); this.canvasW = Math.round(cssWidth * dpr); this.canvasH = Math.round(cssHeight * dpr); if (this.needsInitialFit) { @@ -163,7 +170,7 @@ export class Camera { /** Convert screen pixel position to world coordinates. */ screenToWorld(screenX: number, screenY: number): { x: number; y: number } { - const dpr = window.devicePixelRatio || 1; + const dpr = getDpr(this.settings); const ndcX = ((screenX * dpr) / this.canvasW) * 2 - 1; const ndcY = -(((screenY * dpr) / this.canvasH) * 2 - 1); const sx = (this.zoom * 2) / this.canvasW; @@ -176,7 +183,7 @@ export class Camera { /** Convert world coordinates to screen pixel position (CSS pixels). */ worldToScreen(worldX: number, worldY: number): { x: number; y: number } { - const dpr = window.devicePixelRatio || 1; + const dpr = getDpr(this.settings); return { x: (this.zoom * (worldX - this.offsetX)) / dpr + this.canvasW / (2 * dpr), y: (this.zoom * (worldY - this.offsetY)) / dpr + this.canvasH / (2 * dpr), diff --git a/src/client/render/gl/GraphicsOverrides.ts b/src/client/render/gl/GraphicsOverrides.ts index 518f9583e..fc97205b1 100644 --- a/src/client/render/gl/GraphicsOverrides.ts +++ b/src/client/render/gl/GraphicsOverrides.ts @@ -29,6 +29,11 @@ export const GraphicsOverridesSchema = z railThickness: z.number(), }) .partial(), + display: z + .object({ + dprScale: z.number(), + }) + .partial(), passEnabled: z .object({ fx: z.boolean(), diff --git a/src/client/render/gl/RenderOverrides.ts b/src/client/render/gl/RenderOverrides.ts index 505a97188..320b464c4 100644 --- a/src/client/render/gl/RenderOverrides.ts +++ b/src/client/render/gl/RenderOverrides.ts @@ -48,6 +48,9 @@ export function applyGraphicsOverrides( if (overrides.railroad?.railThickness !== undefined) { settings.railroad.railThickness = overrides.railroad.railThickness; } + if (overrides.display?.dprScale !== undefined) { + settings.display.dprScale = overrides.display.dprScale; + } if (overrides.passEnabled?.fx !== undefined) { settings.passEnabled.fx = overrides.passEnabled.fx; } diff --git a/src/client/render/gl/RenderSettings.ts b/src/client/render/gl/RenderSettings.ts index 984d176fb..75c6c6d07 100644 --- a/src/client/render/gl/RenderSettings.ts +++ b/src/client/render/gl/RenderSettings.ts @@ -107,6 +107,13 @@ export interface RenderSettings { enemyG: number; enemyB: number; }; + display: { + /** + * Multiplier on the native devicePixelRatio for the canvas backing + * store. Below 1 renders fewer pixels (faster); above 1 supersamples. + */ + dprScale: number; + }; railroad: { railMinZoom: number; railFadeRange: number; diff --git a/src/client/render/gl/Renderer.ts b/src/client/render/gl/Renderer.ts index 27ca7c1fd..d11463b72 100644 --- a/src/client/render/gl/Renderer.ts +++ b/src/client/render/gl/Renderer.ts @@ -62,6 +62,7 @@ import { WorldTextPass } from "./passes/WorldTextPass"; import { createRenderSettings, type RenderSettings } from "./RenderSettings"; import { AffiliationPalette } from "./utils/Affiliation"; import { buildTerrainRGBA, getPaletteSize } from "./utils/ColorUtils"; +import { getDpr } from "./utils/Dpr"; import { createTexture2D, toScreen, @@ -168,6 +169,12 @@ export class GPURenderer { private localPlayerID = 0; private playerTeams = new Map(); // smallID → team + // Last CSS size pushed via resize(), kept so a dprScale settings change can + // re-create the canvas backing store at the new resolution mid-game. + private cssWidth = 0; + private cssHeight = 0; + private appliedDprScale = 1; + // Alt-view: affiliation recoloring (space hold) private altView = false; // Grid-view: coordinate grid overlay (M toggle) @@ -215,7 +222,7 @@ export class GPURenderer { this.mapW = mapW; this.mapH = mapH; - this.camera = new Camera(mapW, mapH); + this.camera = new Camera(mapW, mapH, this.settings); // --- Terrain (static) --- const terrainRGBA = buildTerrainRGBA(terrainBytes, mapW, mapH); @@ -471,7 +478,7 @@ export class GPURenderer { this.barPass = new BarPass(gl, header, this.settings, config); this.worldTextPass = new WorldTextPass(gl, this.settings, config); this.worldTextPass.setMapWidth(this.mapW); - this.radialMenuPass = new RadialMenuPass(gl); + this.radialMenuPass = new RadialMenuPass(gl, this.settings); this.selectionBoxPass = new SelectionBoxPass(gl); this.moveIndicatorPass = new MoveIndicatorPass(gl, this.settings); this.nukeTrajectoryPass = new NukeTrajectoryPass(gl, this.settings); @@ -552,7 +559,10 @@ export class GPURenderer { // --------------------------------------------------------------------------- resize(cssWidth: number, cssHeight: number): void { - const dpr = window.devicePixelRatio || 1; + this.cssWidth = cssWidth; + this.cssHeight = cssHeight; + this.appliedDprScale = this.settings.display.dprScale; + const dpr = getDpr(this.settings); this.canvas.width = Math.round(cssWidth * dpr); this.canvas.height = Math.round(cssHeight * dpr); this.camera.resize(cssWidth, cssHeight); @@ -1201,6 +1211,12 @@ export class GPURenderer { draw(): void { const now = performance.now(); this.trackFps(now); + if ( + this.appliedDprScale !== this.settings.display.dprScale && + this.cssWidth > 0 + ) { + this.resize(this.cssWidth, this.cssHeight); + } this.uploadTextures(); this.computeTextures(); this.renderFrame(); diff --git a/src/client/render/gl/debug/Layout.ts b/src/client/render/gl/debug/Layout.ts index 49df28965..80a3afeb1 100644 --- a/src/client/render/gl/debug/Layout.ts +++ b/src/client/render/gl/debug/Layout.ts @@ -7,6 +7,10 @@ import { toggle } from "./props/Toggle"; export function buildTree(s: RenderSettings, d: RenderSettings): DebugNode[] { return [ + folder("Display", [ + slider(s.display, "dprScale", d.display, 0.25, 1, 0.05, "DPR Scale"), + ]), + folder("Pass Enables", [ toggle(s.passEnabled, "terrain", d.passEnabled), toggle(s.passEnabled, "territory", d.passEnabled), diff --git a/src/client/render/gl/index.ts b/src/client/render/gl/index.ts index 034cb05e2..25909a1cc 100644 --- a/src/client/render/gl/index.ts +++ b/src/client/render/gl/index.ts @@ -20,6 +20,7 @@ export { createRenderSettings, dumpSettings } from "./RenderSettings"; export type { RenderSettings } from "./RenderSettings"; export { deepAssign, deepDiff } from "./SettingsUtils"; export { buildTerrainRGBA, getPaletteSize } from "./utils/ColorUtils"; +export { getDpr } from "./utils/Dpr"; export { buildNukeTrajectory, samRange } from "./utils/NukeTrajectory"; export type { SAMInfo } from "./utils/NukeTrajectory"; diff --git a/src/client/render/gl/passes/RadialMenuPass.ts b/src/client/render/gl/passes/RadialMenuPass.ts index 2518298ec..17653d3e4 100644 --- a/src/client/render/gl/passes/RadialMenuPass.ts +++ b/src/client/render/gl/passes/RadialMenuPass.ts @@ -13,6 +13,8 @@ */ import type { RadialMenuItem } from "../Events"; +import type { RenderSettings } from "../RenderSettings"; +import { getDpr } from "../utils/Dpr"; import { createProgram } from "../utils/GlUtils"; import arcFragSrc from "../shaders/radial-menu/arcs.frag.glsl?raw"; @@ -150,7 +152,10 @@ export class RadialMenuPass { private savedItems: RadialMenuItem[] = []; private savedCenterItem: RadialMenuItem | null = null; - constructor(gl: WebGL2RenderingContext) { + constructor( + gl: WebGL2RenderingContext, + private settings: RenderSettings, + ) { this.gl = gl; this.emojiMap = buildEmojiMap(); @@ -437,7 +442,7 @@ export class RadialMenuPass { if (this.items.length === 0 && !this.centerItem) return; const gl = this.gl; - const dpr = window.devicePixelRatio || 1; + const dpr = getDpr(this.settings); const vw = gl.drawingBufferWidth; const vh = gl.drawingBufferHeight; const ax = this.anchorX * dpr; @@ -491,7 +496,7 @@ export class RadialMenuPass { centerHovered: boolean, ): void { const gl = this.gl; - const dpr = window.devicePixelRatio || 1; + const dpr = getDpr(this.settings); const n = items.length; const hasCenter = centerItem !== null; const outerR = cfg.outerR * dpr; diff --git a/src/client/render/gl/passes/WorldTextPass.ts b/src/client/render/gl/passes/WorldTextPass.ts index 0a6b43540..e4a9da663 100644 --- a/src/client/render/gl/passes/WorldTextPass.ts +++ b/src/client/render/gl/passes/WorldTextPass.ts @@ -10,6 +10,7 @@ import type { Config } from "../../../../core/configuration/Config"; import type { BonusEvent, ConquestFx } from "../../types"; import type { RenderSettings } from "../RenderSettings"; +import { getDpr } from "../utils/Dpr"; import { createProgram } from "../utils/GlUtils"; import type { GlyphTables } from "./name-pass/AtlasData"; import { buildGlyphTables, parseAtlasData } from "./name-pass/AtlasData"; @@ -399,7 +400,7 @@ export class WorldTextPass { let count = 0; // canvasW in Camera is cssWidth*dpr, so `zoom` is device-px-per-world-unit. // Multiply screen-relative scales by dpr to keep a constant CSS-pixel size. - const dpr = window.devicePixelRatio || 1; + const dpr = getDpr(this.settings); for (const popup of this.active) { const elapsed = now - popup.startMs; @@ -541,7 +542,7 @@ export class WorldTextPass { gl.useProgram(this.program); gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix); gl.uniform1f(this.uZoom, zoom); - const dpr = window.devicePixelRatio || 1; + const dpr = getDpr(this.settings); gl.uniform1f( this.uMinScreenScale, this.settings.bonusPopup.minScreenScale * dpr, diff --git a/src/client/render/gl/render-settings.json b/src/client/render/gl/render-settings.json index 7b757fe34..46b8534fc 100644 --- a/src/client/render/gl/render-settings.json +++ b/src/client/render/gl/render-settings.json @@ -102,6 +102,9 @@ "enemyG": 0, "enemyB": 0 }, + "display": { + "dprScale": 1 + }, "railroad": { "railMinZoom": 4, "railFadeRange": 2, diff --git a/src/client/render/gl/utils/Dpr.ts b/src/client/render/gl/utils/Dpr.ts new file mode 100644 index 000000000..9f726031d --- /dev/null +++ b/src/client/render/gl/utils/Dpr.ts @@ -0,0 +1,12 @@ +import type { RenderSettings } from "../RenderSettings"; + +/** + * Effective device pixel ratio: the native devicePixelRatio scaled by the + * `display.dprScale` render setting (lower = cheaper rendering, higher = + * supersampling). Everything that converts between CSS pixels and canvas + * pixels must use this so the camera, hit-testing, and screen-anchored + * sprites stay consistent. + */ +export function getDpr(settings: RenderSettings): number { + return (window.devicePixelRatio || 1) * (settings.display?.dprScale ?? 1); +} diff --git a/tests/GraphicsOverrides.test.ts b/tests/GraphicsOverrides.test.ts index a92ea9b0a..25fa340f7 100644 --- a/tests/GraphicsOverrides.test.ts +++ b/tests/GraphicsOverrides.test.ts @@ -66,6 +66,17 @@ describe("GraphicsOverridesSchema", () => { } }); + test("accepts partial display overrides", () => { + const cases = [ + { display: {} }, + { display: { dprScale: 0.5 } }, + { display: { dprScale: 2 } }, + ]; + for (const c of cases) { + expect(GraphicsOverridesSchema.safeParse(c).success).toBe(true); + } + }); + test("rejects wrong field types", () => { expect( GraphicsOverridesSchema.safeParse({ name: { nameScaleFactor: "big" } }) @@ -94,6 +105,11 @@ describe("GraphicsOverridesSchema", () => { railroad: { railThickness: "wide" }, }).success, ).toBe(false); + expect( + GraphicsOverridesSchema.safeParse({ + display: { dprScale: "retina" }, + }).success, + ).toBe(false); }); }); @@ -261,6 +277,14 @@ describe("applyGraphicsOverrides", () => { expect(z.railThickness).toBe(defaults.railThickness); }); + test("applies dprScale override (including values below 1)", () => { + expect(gen({ display: { dprScale: 0.5 } }).display.dprScale).toBe(0.5); + expect(gen({ display: { dprScale: 2 } }).display.dprScale).toBe(2); + expect(gen({ display: {} }).display.dprScale).toBe( + createRenderSettings().display.dprScale, + ); + }); + test("classicIcons + name overrides compose independently", () => { const s = gen({ name: { darkNames: true, nameScaleFactor: 0.9 },