277 lines
8.9 KiB
JavaScript
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;
|
|
});
|