bench-terrain.ts 3.4 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283
  1. // Terrain budget benchmark under plain Node (readme §5: an op + flood fill + colliders in 2 ms).
  2. // Usage: npm run bench:node
  3. // The vitest benchmarks run through the test runner's module transform, which slows hot loops
  4. // that read imported bindings; this script measures what the server actually runs.
  5. import { World } from 'planck';
  6. import { loadCharacterStats, loadMap, loadMaterials, resolveAssetsDir } from '../src/server/assets';
  7. import { Game } from '../src/server/game';
  8. import { TerrainColliders } from '../src/server/physics/terrainColliders';
  9. import { Rng } from '../src/shared/rng';
  10. import { DetachFinder } from '../src/shared/terrain/detach';
  11. import { applyCells } from '../src/shared/terrain/events';
  12. import { applyOp } from '../src/shared/terrain/ops';
  13. const assets = resolveAssetsDir();
  14. const materials = loadMaterials(assets);
  15. const WARMUP = 3;
  16. function report(label: string, samples: number[]): void {
  17. const warm = samples.slice(WARMUP).sort((a, b) => a - b);
  18. const pick = (q: number): string => warm[Math.min(warm.length - 1, Math.floor(warm.length * q))].toFixed(2);
  19. const mean = warm.reduce((a, b) => a + b, 0) / warm.length;
  20. console.log(`${label.padEnd(44)} mean ${mean.toFixed(2)} median ${pick(0.5)} p99 ${pick(0.99)} max ${pick(1)} ms`);
  21. }
  22. function newGame(): Game {
  23. const { definition, terrain } = loadMap(assets, 'gateau-etages', materials);
  24. return new Game({ map: definition, terrain, materials, seed: 1, loadCharacter: (id) => loadCharacterStats(assets, id) });
  25. }
  26. // 1. Ordinary ops on a cake turning into Swiss cheese: op + detach search + colliders + step.
  27. {
  28. const { terrain } = loadMap(assets, 'gateau-etages', materials);
  29. const world = new World();
  30. const colliders = new TerrainColliders(world, terrain);
  31. colliders.rebuildDirty();
  32. const finder = new DetachFinder(terrain);
  33. const rng = new Rng(7);
  34. const samples: number[] = [];
  35. for (let i = 0; i < 1500; i++) {
  36. const t = performance.now();
  37. const { changed } = applyOp(terrain, { kind: 'carve', x: rng.int(300, 1700), y: rng.int(300, 900), r: 40, power: 99 }, materials);
  38. if (changed) {
  39. for (const c of finder.find(changed)) {
  40. const { x0, y0, x1, y1 } = c.bounds;
  41. applyCells(terrain, 'detach', x0, y0, x1 - x0, y1 - y0, c.mask);
  42. }
  43. }
  44. colliders.rebuildDirty();
  45. world.step(1 / 60);
  46. samples.push(performance.now() - t);
  47. }
  48. report('carve r=40 (op + detach + colliders + step)', samples.slice(200 - WARMUP));
  49. }
  50. // 2. One op severing the last link of the top tier (~90k px come loose): the worst single op.
  51. {
  52. const samples: number[] = [];
  53. for (let round = 0; round < WARMUP + 10; round++) {
  54. const game = newGame();
  55. for (let x = 760; x <= 1250; x += 12) {
  56. game.queueOp({ kind: 'carve', x, y: 462, r: 14, power: 99 });
  57. game.step();
  58. }
  59. game.queueOp({ kind: 'carve', x: 1268, y: 462, r: 16, power: 99 });
  60. const t = performance.now();
  61. game.step();
  62. samples.push(performance.now() - t);
  63. }
  64. report('single op severing a whole tier (full tick)', samples);
  65. }
  66. // 3. A 44-op cut in a single tick (e.g. several players at once).
  67. {
  68. const samples: number[] = [];
  69. for (let round = 0; round < WARMUP + 10; round++) {
  70. const game = newGame();
  71. for (let x = 760; x <= 1278; x += 12) game.queueOp({ kind: 'carve', x, y: 462, r: 14, power: 99 });
  72. const t = performance.now();
  73. game.step();
  74. samples.push(performance.now() - t);
  75. }
  76. report('44-op cut in one tick (full tick)', samples);
  77. }