Resolve render settings before renderer construction (#4271)

## What

The client now resolves render settings (defaults + user overrides) **up
front** and passes the result into the renderer, instead of the renderer
constructing defaults itself and the client re-applying overrides
afterward.

```
before:  new GPURenderer(...)         // this.settings = createRenderSettings()  (defaults)
         view.getSettings() → deepAssign(defaults) → applyGraphicsOverrides(...)  // patch after the fact

after:   const settings = createRenderSettings(); applyGraphicsOverrides(settings, ...); applyDarkModeOverride(settings, ...)
         new GPURenderer(..., settings)   // built with the final values
```

## Why

- **Removes the construct-with-defaults / re-apply-overrides dance.**
Every pass — including texture-baking ones like terrain that read their
settings *once* at build time rather than every frame — is now built
with the final values on the first try. (This is the cleanup that
motivated the change, surfaced while adding a terrain color override in
a separate PR.)
- **Fixes a latent context-restore bug.** On WebGL context loss/restore
the renderer was rebuilt via `createRenderSettings()` → fresh
**defaults**, silently dropping any user overrides until the next
settings change. `MapRenderer` now holds the resolved settings object
and hands the same one to the recreated `GPURenderer`, so overrides
survive a restore.

Live setting changes still work exactly as before:
`regenerateRenderSettings()` re-resolves and `deepAssign`s onto the
renderer's live settings object in place (passes hold a reference, so
they pick it up next frame).

## Changes
- `Renderer.ts` (`GPURenderer`) — constructor takes a `settings:
RenderSettings`; drops the internal `createRenderSettings()` call.
- `MapRenderer.ts` — holds the resolved settings and passes it through
on construction and on context-restore re-init.
- `ClientGameRunner.ts` — new `resolveRenderSettings()` helper used both
at construction and by `regenerateRenderSettings()`; `createWebGLView`
takes the resolved settings; the now-redundant initial
`regenerateRenderSettings()` call is removed.

## Testing
Verified live in a headless singleplayer game:
- A saved `nameScaleFactor` override is present in `getSettings()`
immediately after game start, with no settings-change event fired
(construction path).
- A mid-game override change is reflected in the live settings
(regenerate/in-place path).
- The map renders correctly through spawn phase.

`tsc` and ESLint clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Evan
2026-06-13 20:22:08 -07:00
committed by GitHub
parent 3a8249dfd1
commit 54a7042303
3 changed files with 31 additions and 7 deletions
+20 -5
View File
@@ -74,6 +74,7 @@ import {
deepAssign, deepAssign,
MapRenderer, MapRenderer,
preloadAtlasData, preloadAtlasData,
type RenderSettings,
} from "./render/gl"; } from "./render/gl";
import { ALL_UNIT_TYPES, UnitState } from "./render/types"; import { ALL_UNIT_TYPES, UnitState } from "./render/types";
import { SoundManager } from "./sound/SoundManager"; import { SoundManager } from "./sound/SoundManager";
@@ -256,6 +257,7 @@ export function joinLobby(
function createWebGLView( function createWebGLView(
terrainMap: TerrainMapData, terrainMap: TerrainMapData,
config: Config, config: Config,
settings: RenderSettings,
): { ): {
view: MapRenderer; view: MapRenderer;
glCanvas: HTMLCanvasElement; glCanvas: HTMLCanvasElement;
@@ -311,6 +313,7 @@ function createWebGLView(
terrainBytes, terrainBytes,
palette, palette,
config, config,
settings,
captureRaf, captureRaf,
captureCaf, captureCaf,
); );
@@ -485,9 +488,21 @@ async function createClientGame(
const soundManager = new SoundManager(eventBus, userSettings); const soundManager = new SoundManager(eventBus, userSettings);
try { try {
// Resolve render settings (defaults + user overrides) up front so the
// renderer is built with the final values — no construct-with-defaults,
// re-apply-overrides dance, and texture-baking passes (terrain) get the
// right colors on the first build.
const resolveRenderSettings = (): RenderSettings => {
const settings = createRenderSettings();
applyGraphicsOverrides(settings, userSettings.graphicsOverrides());
applyDarkModeOverride(settings, userSettings.darkMode());
return settings;
};
const { view, glCanvas, cachedWebGLFrameCallback } = createWebGLView( const { view, glCanvas, cachedWebGLFrameCallback } = createWebGLView(
gameMap, gameMap,
config, config,
resolveRenderSettings(),
); );
view.setShowPatterns(userSettings.territoryPatterns()); view.setShowPatterns(userSettings.territoryPatterns());
@@ -497,11 +512,10 @@ async function createClientGame(
); );
const graphicsListenerAbort = new AbortController(); const graphicsListenerAbort = new AbortController();
// Re-resolve settings and copy them onto the renderer's live object in
// place (passes hold a reference to it, so they pick the change up).
const regenerateRenderSettings = (): void => { const regenerateRenderSettings = (): void => {
const live = view.getSettings(); deepAssign(view.getSettings(), resolveRenderSettings());
deepAssign(live, createRenderSettings());
applyGraphicsOverrides(live, userSettings.graphicsOverrides());
applyDarkModeOverride(live, userSettings.darkMode());
}; };
// Re-apply render settings, then re-theme and recolor players, on a // Re-apply render settings, then re-theme and recolor players, on a
// graphics-override change (covers a theme switch such as colorblind mode). // graphics-override change (covers a theme switch such as colorblind mode).
@@ -513,7 +527,8 @@ async function createClientGame(
gameView.refreshPlayerColors(); gameView.refreshPlayerColors();
webglBuilder.refreshPalette(gameView); webglBuilder.refreshPalette(gameView);
}; };
regenerateRenderSettings(); // No initial regenerate needed — the renderer was constructed with the
// resolved settings above.
globalThis.addEventListener( globalThis.addEventListener(
`${USER_SETTINGS_CHANGED_EVENT}:${GRAPHICS_KEY}`, `${USER_SETTINGS_CHANGED_EVENT}:${GRAPHICS_KEY}`,
onGraphicsChanged, onGraphicsChanged,
+5
View File
@@ -50,6 +50,10 @@ export class MapRenderer {
private terrainBytes: Uint8Array, private terrainBytes: Uint8Array,
private paletteData: Float32Array, private paletteData: Float32Array,
private config: Config, private config: Config,
// Resolved render settings (defaults + overrides). Held so the same object
// is re-used when a GPURenderer is recreated after a context restore,
// preserving any user overrides that were applied to it.
private settings: RenderSettings,
private raf?: typeof requestAnimationFrame, private raf?: typeof requestAnimationFrame,
private caf?: typeof cancelAnimationFrame, private caf?: typeof cancelAnimationFrame,
) { ) {
@@ -78,6 +82,7 @@ export class MapRenderer {
this.terrainBytes, this.terrainBytes,
this.paletteData, this.paletteData,
this.config, this.config,
this.settings,
this.raf, this.raf,
this.caf, this.caf,
); );
+6 -2
View File
@@ -57,7 +57,7 @@ import { TerritoryPass } from "./passes/TerritoryPass";
import { TrailPass } from "./passes/TrailPass"; import { TrailPass } from "./passes/TrailPass";
import { UnitPass } from "./passes/UnitPass"; import { UnitPass } from "./passes/UnitPass";
import { WorldTextPass } from "./passes/WorldTextPass"; import { WorldTextPass } from "./passes/WorldTextPass";
import { createRenderSettings, type RenderSettings } from "./RenderSettings"; import type { RenderSettings } from "./RenderSettings";
import { AffiliationPalette } from "./utils/Affiliation"; import { AffiliationPalette } from "./utils/Affiliation";
import { buildTerrainRGBA, getPaletteSize } from "./utils/ColorUtils"; import { buildTerrainRGBA, getPaletteSize } from "./utils/ColorUtils";
import { import {
@@ -180,11 +180,15 @@ export class GPURenderer {
terrainBytes: Uint8Array, terrainBytes: Uint8Array,
paletteData: Float32Array, paletteData: Float32Array,
config: Config, config: Config,
settings: RenderSettings,
raf: typeof requestAnimationFrame = requestAnimationFrame.bind(window), raf: typeof requestAnimationFrame = requestAnimationFrame.bind(window),
caf: typeof cancelAnimationFrame = cancelAnimationFrame.bind(window), caf: typeof cancelAnimationFrame = cancelAnimationFrame.bind(window),
) { ) {
this.canvas = canvas; this.canvas = canvas;
this.settings = createRenderSettings(); // Settings are resolved (defaults + user overrides) by the caller and
// passed in, so every pass — including texture-baking ones like terrain —
// is built with the final values. Live changes mutate this object in place.
this.settings = settings;
this.raf = raf; this.raf = raf;
this.caf = caf; this.caf = caf;