This commit is contained in:
evanpelle
2026-06-10 19:49:17 -07:00
parent 9189aac687
commit 579adfd22d
15 changed files with 162 additions and 13 deletions
+3
View File
@@ -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"
+2 -1
View File
@@ -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 +
@@ -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<GraphicsOverrides["display"]>) {
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`
<div
@@ -650,6 +676,37 @@ export class GraphicsSettingsModal extends LitElement implements Controller {
</div>
</div>
<div
class="px-3 py-1 text-xs font-semibold text-slate-400 uppercase tracking-wider mt-2"
>
${translateText("graphics_setting.section_display")}
</div>
<div
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
>
<div class="flex-1">
<div class="font-medium">
${translateText("graphics_setting.dpr_scale_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("graphics_setting.dpr_scale_desc")}
</div>
<input
type="range"
min=${DPR_SCALE_MIN}
max=${DPR_SCALE_MAX}
step=${DPR_SCALE_STEP}
.value=${String(dprScale)}
@input=${this.onDprScaleChange}
class="w-full border border-slate-500 rounded-lg"
/>
</div>
<div class="text-sm text-slate-400 w-12 text-right">
${dprScale.toFixed(2)}
</div>
</div>
<div
class="px-3 py-1 text-xs font-semibold text-slate-400 uppercase tracking-wider mt-2"
>
+11 -4
View File
@@ -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),
@@ -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(),
+3
View File
@@ -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;
}
+7
View File
@@ -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;
+19 -3
View File
@@ -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<number, string>(); // 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();
+4
View File
@@ -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),
+1
View File
@@ -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";
@@ -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;
+3 -2
View File
@@ -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,
@@ -102,6 +102,9 @@
"enemyG": 0,
"enemyB": 0
},
"display": {
"dprScale": 1
},
"railroad": {
"railMinZoom": 4,
"railFadeRange": 2,
+12
View File
@@ -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);
}
+24
View File
@@ -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 },