Files

277 lines
8.9 KiB
JavaScript

// Renderer benchmark. It builds the same generated world twice — once with the
// classic DOM/chunk renderer and once with the WebGL renderer — and measures how
// long each takes to build and to pan. It can be driven from the browser console
// (window.__benchPromise) or headlessly over WebDriver BiDi.
//
// bench.html?size=120&dpr=1 full comparison
// bench.html?render=webgl render one map, no measurement
//
// The numbers count only work the page can time synchronously. Pan steps force
// layout for the DOM renderer (getBoundingClientRect) and gl.finish() for WebGL,
// so both include the CPU/GPU work a frame cannot avoid; compositing itself is
// still asynchronous and is what the rAF frame-rate pass captures.
import { GameState } from "../../shared/game_state.js";
import { CIVILISATIONS, PROTO_UNITS, MAP_CONFIG } from "../../shared/data.js";
import { MapGenerator } from "../../shared/map_generator.js";
import { MapTopology } from "../../shared/hex.js";
import { MapView } from "./map_view.js";
const MARKUP = `
<div id="map-viewport">
<div id="map-world">
<div class="layer" id="layer-terrain"></div>
<div class="layer" id="layer-roads"></div>
<div class="layer" id="layer-borders"></div>
<div class="layer" id="layer-fog"></div>
<div class="layer" id="layer-highlight"></div>
<div class="layer" id="layer-paths"></div>
<div class="layer" id="layer-targets"></div>
<div class="layer" id="layer-entities"></div>
<div class="layer city-label-layer" id="layer-labels"></div>
</div>
</div>`;
function mapLayers($) {
return {
terrain: $("#layer-terrain"),
roads: $("#layer-roads"),
borders: $("#layer-borders"),
fog: $("#layer-fog"),
highlight: $("#layer-highlight"),
paths: $("#layer-paths"),
targets: $("#layer-targets"),
entities: $("#layer-entities"),
labels: $("#layer-labels"),
};
}
function mount(width, height) {
document.getElementById("bench-root").innerHTML = MARKUP;
if (width && height) {
const viewport = document.getElementById("map-viewport");
viewport.style.inset = "auto";
viewport.style.left = "0";
viewport.style.top = "0";
viewport.style.width = `${width}px`;
viewport.style.height = `${height}px`;
}
}
function median(values) {
const sorted = values.slice().sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
}
function percentile(values, p) {
const sorted = values.slice().sort((a, b) => a - b);
return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))];
}
function stats(values) {
const total = values.reduce((sum, v) => sum + v, 0);
return {
mean: +(total / values.length).toFixed(3),
median: +median(values).toFixed(3),
p95: +percentile(values, 0.95).toFixed(3),
max: +Math.max(...values).toFixed(3),
};
}
function waitFor(predicate, timeoutMs) {
return new Promise((resolve) => {
const start = performance.now();
const tick = () => {
if (predicate() || performance.now() - start > timeoutMs) resolve();
else requestAnimationFrame(tick);
};
tick();
});
}
function flush(map) {
if (map.glRenderer) {
map.glRenderer.gl.finish();
} else {
// Forces style recalculation and layout, the DOM renderer's dominant cost.
map.$world[0].getBoundingClientRect();
}
}
// Panning in fixed steps, timed synchronously per step.
function measurePan(map, frames, dx) {
const times = [];
for (let i = 0; i < frames; i++) {
map.camera.x += dx;
const start = performance.now();
map._applyCamera();
flush(map);
times.push(performance.now() - start);
}
return stats(times);
}
// The same pan, but paced by the compositor: this is where a slow frame shows
// up as a dropped one.
function measureRafPan(map, durationMs, dx) {
return new Promise((resolve) => {
const frameTimes = [];
const start = performance.now();
let last = start;
let frames = 0;
const step = (now) => {
map.camera.x += dx;
map._applyCamera();
frames += 1;
frameTimes.push(now - last);
last = now;
if (now - start < durationMs) {
requestAnimationFrame(step);
} else {
const elapsed = now - start;
resolve({
frames,
elapsedMs: +elapsed.toFixed(1),
fps: +(frames / (elapsed / 1000)).toFixed(1),
frameMs: stats(frameTimes),
});
}
};
requestAnimationFrame(step);
});
}
function buildMap(mode, state, snapshot, width, height, zoom) {
mount(width, height);
const $ = window.jQuery;
const map = new MapView($("#map-viewport"), $("#map-world"), mapLayers($), { renderer: mode });
map.setCatalogue(
CIVILISATIONS.filter((c) => snapshot.civs.some((s) => s.id === c.id)),
PROTO_UNITS
);
const start = performance.now();
map.applySnapshot(snapshot);
if (zoom) {
map.camera.zoom = zoom;
map._applyCamera();
}
flush(map);
const buildMs = performance.now() - start;
return { map, buildMs };
}
function mapStats(map) {
const out = { renderer: map.glRenderer ? "webgl" : "dom" };
if (map.glRenderer) {
out.gpu = map.glRenderer.info();
out.vertices = {
terrain: map.glRenderer.terrain.count,
roads: map.glRenderer.roads.count,
borders: map.glRenderer.borders.count,
fog: map.glRenderer.fog.count,
};
} else {
let hexes = 0;
for (const rec of map._chunks.values()) hexes += rec.tileKeys.length;
out.chunks = map._chunks.size;
out.visibleHexes = hexes;
out.domNodes = document.getElementById("bench-root").querySelectorAll("*").length;
}
return out;
}
async function run() {
const params = new URLSearchParams(location.search);
const size = Number(params.get("size") || 120);
const vw = Number(params.get("vw") || window.innerWidth);
const vh = Number(params.get("vh") || window.innerHeight);
const zoom = Number(params.get("zoom") || 1);
const config = { ...MAP_CONFIG, mapSize: { x: size, y: size } };
const seed = 987654321;
const state = new GameState();
state.mapConfig = config;
const civs = ["france", "britain"].map((id) => CIVILISATIONS.find((c) => c.id === id));
state.configure(civs, seed);
const snapshot = state.snapshot(0);
// Generation is identical for both renderers and dominates a cold build, so
// time it once and report it separately.
const topology = new MapTopology(config.mapSize, config.topology === "cylindrical");
const genStart = performance.now();
const generator = new MapGenerator(config, topology);
generator.generate(seed);
const generationMs = performance.now() - genStart;
// Warm up any shared caches (settlements, terrain stats) so the second
// renderer is not punished for being second.
{
const warm = new MapGenerator(config, topology);
warm.generate(seed);
}
const report = {
mapSize: size,
tiles: size * size,
viewport: { width: vw, height: vh, dpr: window.devicePixelRatio, zoom },
generationMs: +generationMs.toFixed(1),
};
// Render-only mode: draw one frame so a screenshot can be taken.
const renderMode = params.get("render");
if (renderMode) {
mount(vw, vh);
const $ = window.jQuery;
const map = new MapView($("#map-viewport"), $("#map-world"), mapLayers($), { renderer: renderMode });
map.setCatalogue(CIVILISATIONS, PROTO_UNITS);
map.applySnapshot(snapshot);
if (zoom) {
map.camera.zoom = zoom;
map._applyCamera();
}
await waitFor(() => !map.glRenderer || map.glRenderer.texReady, 5000);
map._glRender();
flush(map);
report.renderOnly = mapStats(map);
return report;
}
// First the WebGL pass, then the DOM pass (the DOM pass is the one whose
// layout could be affected by a large live canvas, so it goes last).
const gl = buildMap("webgl", state, snapshot, vw, vh, zoom);
if (gl.map.glRenderer) {
report.webglBuildMs = +gl.buildMs.toFixed(1);
report.webgl = mapStats(gl.map);
}
const glPan = gl.map.glRenderer ? measurePan(gl.map, 240, 3) : null;
const glRaf = gl.map.glRenderer ? await measureRafPan(gl.map, 2000, 3) : null;
const dom = buildMap("dom", state, snapshot, vw, vh, zoom);
report.domBuildMs = +dom.buildMs.toFixed(1);
report.dom = mapStats(dom.map);
const domPan = measurePan(dom.map, 240, 3);
const domRaf = await measureRafPan(dom.map, 2000, 3);
if (glPan) report.webglPanStepMs = glPan;
report.domPanStepMs = domPan;
if (glRaf) report.webglRaf = glRaf;
report.domRaf = domRaf;
return report;
}
async function main() {
const report = await run();
window.__benchResult = report;
document.getElementById("bench-report").textContent = JSON.stringify(report, null, 2);
return report;
}
window.__benchResult = null;
window.__benchPromise = main().catch((error) => {
window.__benchResult = { error: String((error && error.stack) || error) };
document.getElementById("bench-report").textContent = window.__benchResult.error;
throw error;
});