diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml new file mode 100644 index 00000000..1bee9e5c --- /dev/null +++ b/.github/workflows/benchmark.yml @@ -0,0 +1,50 @@ +name: Benchmark +on: + issue_comment: + types: [created] + push: + branches: + - perf-test +jobs: + deploy: + runs-on: ubuntu-latest + # if: >- + # github.event.issue.pull_request != '' && + # ( + # contains(github.event.comment.body, '/benchmark') + # ) + permissions: + pull-requests: write + steps: + - run: lscpu + - name: Checkout + uses: actions/checkout@v2 + # with: + # ref: refs/pull/${{ github.event.issue.number }}/head + - run: npm i -g pnpm@9.0.4 + - uses: actions/setup-node@v4 + with: + node-version: 18 + cache: "pnpm" + - run: pnpm install + - run: pnpm build + - run: nohup pnpm prod-start & + - run: pnpm test:benchmark + id: benchmark + continue-on-error: true + # read benchmark results from stdout + - run: | + if [ -f benchmark.txt ]; then + # Format the benchmark results for GitHub comment + BENCHMARK_RESULT=$(cat benchmark.txt | sed 's/^/- /') + echo "BENCHMARK_RESULT<> $GITHUB_ENV + echo "$BENCHMARK_RESULT" >> $GITHUB_ENV + echo "EOF" >> $GITHUB_ENV + else + echo "BENCHMARK_RESULT=Benchmark failed to run or produce results" >> $GITHUB_ENV + fi + - uses: mshick/add-pr-comment@v2 + with: + allow-repeats: true + message: | + Benchmark result: ${{ env.BENCHMARK_RESULT }} diff --git a/cypress.config.ts b/cypress.config.ts index 861931e3..3bf2c720 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -1,5 +1,7 @@ import { defineConfig } from 'cypress' +const isPerformanceTest = process.env.PERFORMANCE_TEST === 'true' + export default defineConfig({ video: false, chromeWebSecurity: false, @@ -32,7 +34,7 @@ export default defineConfig({ return require('./cypress/plugins/index.js')(on, config) }, baseUrl: 'http://localhost:8080', - specPattern: 'cypress/e2e/**/*.spec.ts', + specPattern: !isPerformanceTest ? 'cypress/e2e/smoke.spec.ts' : 'cypress/e2e/rendering_performance.spec.ts', excludeSpecPattern: ['**/__snapshots__/*', '**/__image_snapshots__/*'], }, }) diff --git a/cypress/e2e/rendering_performance.spec.ts b/cypress/e2e/rendering_performance.spec.ts new file mode 100644 index 00000000..2ca85329 --- /dev/null +++ b/cypress/e2e/rendering_performance.spec.ts @@ -0,0 +1,32 @@ +/// +import { BenchmarkAdapterInfo, getAllInfoLines } from '../../src/benchmarkAdapter' +import { cleanVisit } from './shared' + +it('Benchmark rendering performance', () => { + cleanVisit('/?openBenchmark=true&renderDistance=5') + // wait for render end event + return cy.document().then({ timeout: 180_000 }, doc => { + return new Cypress.Promise(resolve => { + cy.log('Waiting for world to load') + doc.addEventListener('cypress-world-ready', resolve) + }).then(() => { + cy.log('World loaded') + }) + }).then(() => { + cy.window().then(win => { + const adapter = win.benchmarkAdapter as BenchmarkAdapterInfo + + const messages = getAllInfoLines(adapter) + // wait for 10 seconds + cy.wait(10_000) + const messages2 = getAllInfoLines(adapter, true) + for (const message of messages) { + cy.log(message) + } + for (const message of messages2) { + cy.log(message) + } + cy.writeFile('benchmark.txt', [...messages, ...messages2].join('\n')) + }) + }) +}) diff --git a/package.json b/package.json index 94bcedf5..c72e591f 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "prepare-project": "tsx scripts/genShims.ts && tsx scripts/makeOptimizedMcData.mjs && tsx scripts/genLargeDataAliases.ts", "check-build": "pnpm prepare-project && tsc && pnpm build", "test:cypress": "cypress run", + "test:benchmark": "PERFORMANCE_TEST=true cypress run", "test:cypress:open": "cypress open", "test-unit": "vitest", "test:e2e": "start-test http-get://localhost:8080 test:cypress", @@ -75,7 +76,7 @@ "esbuild-plugin-polyfill-node": "^0.3.0", "express": "^4.18.2", "filesize": "^10.0.12", - "flying-squid": "npm:@zardoy/flying-squid@^0.0.51", + "flying-squid": "npm:@zardoy/flying-squid@^0.0.58", "fs-extra": "^11.1.1", "google-drive-browserfs": "github:zardoy/browserfs#google-drive", "jszip": "^3.10.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24c4e332..933acaa3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -120,8 +120,8 @@ importers: specifier: ^10.0.12 version: 10.0.12 flying-squid: - specifier: npm:@zardoy/flying-squid@^0.0.51 - version: '@zardoy/flying-squid@0.0.51(encoding@0.1.13)' + specifier: npm:@zardoy/flying-squid@^0.0.58 + version: '@zardoy/flying-squid@0.0.58(encoding@0.1.13)' fs-extra: specifier: ^11.1.1 version: 11.1.1 @@ -438,7 +438,7 @@ importers: version: 1.3.6 prismarine-block: specifier: github:zardoy/prismarine-block#next-era - version: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 + version: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9(prismarine-registry@1.11.0) prismarine-chunk: specifier: github:zardoy/prismarine-chunk#master version: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/e68e9a423b5b1907535878fb636f12c28a1a9374(minecraft-data@3.83.1) @@ -3541,8 +3541,8 @@ packages: engines: {node: '>=8'} hasBin: true - '@zardoy/flying-squid@0.0.51': - resolution: {integrity: sha512-HHZ79H9NkS44lL9vk6gVEuJDJqj88gpiBt9Ihh5p4rHXTVbRid95riiNK5dD0kHI94P5/DXdtNalvmJDPU86oQ==} + '@zardoy/flying-squid@0.0.58': + resolution: {integrity: sha512-qkSoaYRpVQaAvcVgZDTe0i4PxaK2l2B6i7GfRCEsyYFl3UaNQYBwwocXqLrIwhsc63bwXa0XQe8UNUubz+A4eA==} engines: {node: '>=8'} hasBin: true @@ -6926,6 +6926,11 @@ packages: version: 1.54.0 engines: {node: '>=22'} + minecraft-protocol@https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/9e116c3dd4682b17c4e2c80249a2447a093d9284: + resolution: {tarball: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/9e116c3dd4682b17c4e2c80249a2447a093d9284} + version: 1.57.0 + engines: {node: '>=22'} + minecraft-wrap@1.5.1: resolution: {integrity: sha512-7DZ2WhrcRD3fUMau84l9Va0KWzV92SHNdB7mnNdNhgXID2aW6pjWuYPZi8MepEBemA4XKKdnDx7HmhTbkoiR8A==} hasBin: true @@ -13652,7 +13657,7 @@ snapshots: - encoding - supports-color - '@zardoy/flying-squid@0.0.51(encoding@0.1.13)': + '@zardoy/flying-squid@0.0.58(encoding@0.1.13)': dependencies: '@tootallnate/once': 2.0.0 chalk: 5.3.0 @@ -13663,14 +13668,14 @@ snapshots: flatmap: 0.0.3 long: 5.2.3 minecraft-data: 3.83.1 - minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/5ec3dd4b367fcc039fbcb3edd214fe3cf8178a6d(patch_hash=dkeyukcqlupmk563gwxsmjr3yu)(encoding@0.1.13) + minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/9e116c3dd4682b17c4e2c80249a2447a093d9284(encoding@0.1.13) mkdirp: 2.1.6 node-gzip: 1.1.2 node-rsa: 1.1.1 prismarine-chunk: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/e68e9a423b5b1907535878fb636f12c28a1a9374(minecraft-data@3.83.1) prismarine-entity: 2.3.1 prismarine-item: 1.16.0 - prismarine-nbt: 2.5.0 + prismarine-nbt: 2.7.0 prismarine-provider-anvil: https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/1d548fac63fe977c8281f0a9a522b37e4d92d0b7(minecraft-data@3.83.1) prismarine-windows: 2.9.0 prismarine-world: https://codeload.github.com/zardoy/prismarine-world/tar.gz/ab2146c9933eef3247c3f64446de4ccc2c484c7c @@ -17981,6 +17986,31 @@ snapshots: - encoding - supports-color + minecraft-protocol@https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/9e116c3dd4682b17c4e2c80249a2447a093d9284(encoding@0.1.13): + dependencies: + '@types/node-rsa': 1.1.4 + '@types/readable-stream': 4.0.12 + aes-js: 3.1.2 + buffer-equal: 1.0.1 + debug: 4.4.0(supports-color@8.1.1) + endian-toggle: 0.0.0 + lodash.merge: 4.6.2 + minecraft-data: 3.83.1 + minecraft-folder-path: 1.2.0 + node-fetch: 2.7.0(encoding@0.1.13) + node-rsa: 0.4.2 + prismarine-auth: 2.4.2(encoding@0.1.13) + prismarine-chat: 1.10.1 + prismarine-nbt: 2.7.0 + prismarine-realms: 1.3.2(encoding@0.1.13) + protodef: 1.18.0 + readable-stream: 4.5.2 + uuid-1345: 1.0.2 + yggdrasil: 1.7.0(encoding@0.1.13) + transitivePeerDependencies: + - encoding + - supports-color + minecraft-wrap@1.5.1(encoding@0.1.13): dependencies: debug: 4.4.0(supports-color@8.1.1) @@ -18043,7 +18073,7 @@ snapshots: mineflayer-pathfinder@2.4.4: dependencies: minecraft-data: 3.83.1 - prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 + prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9(prismarine-registry@1.11.0) prismarine-entity: 2.3.1 prismarine-item: 1.16.0 prismarine-nbt: 2.5.0 @@ -18055,7 +18085,7 @@ snapshots: minecraft-data: 3.83.1 minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/5ec3dd4b367fcc039fbcb3edd214fe3cf8178a6d(patch_hash=dkeyukcqlupmk563gwxsmjr3yu)(encoding@0.1.13) prismarine-biome: 1.3.0(minecraft-data@3.83.1)(prismarine-registry@1.11.0) - prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 + prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9(prismarine-registry@1.11.0) prismarine-chat: 1.10.1 prismarine-chunk: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/e68e9a423b5b1907535878fb636f12c28a1a9374(minecraft-data@3.83.1) prismarine-entity: 2.3.1 @@ -18078,7 +18108,7 @@ snapshots: minecraft-data: 3.83.1 minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/5ec3dd4b367fcc039fbcb3edd214fe3cf8178a6d(patch_hash=dkeyukcqlupmk563gwxsmjr3yu)(encoding@0.1.13) prismarine-biome: 1.3.0(minecraft-data@3.83.1)(prismarine-registry@1.11.0) - prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 + prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9(prismarine-registry@1.11.0) prismarine-chat: 1.10.1 prismarine-chunk: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/e68e9a423b5b1907535878fb636f12c28a1a9374(minecraft-data@3.83.1) prismarine-entity: 2.3.1 @@ -18867,7 +18897,7 @@ snapshots: minecraft-data: 3.83.1 prismarine-registry: 1.11.0 - prismarine-block@https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9: + prismarine-block@https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9(prismarine-registry@1.11.0): dependencies: minecraft-data: 3.83.1 prismarine-biome: 1.3.0(minecraft-data@3.83.1)(prismarine-registry@1.11.0) @@ -18875,6 +18905,8 @@ snapshots: prismarine-item: 1.16.0 prismarine-nbt: 2.5.0 prismarine-registry: 1.11.0 + transitivePeerDependencies: + - prismarine-registry prismarine-chat@1.10.1: dependencies: @@ -18885,7 +18917,7 @@ snapshots: prismarine-chunk@https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/e68e9a423b5b1907535878fb636f12c28a1a9374(minecraft-data@3.83.1): dependencies: prismarine-biome: 1.3.0(minecraft-data@3.83.1)(prismarine-registry@1.11.0) - prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 + prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9(prismarine-registry@1.11.0) prismarine-nbt: 2.5.0 prismarine-registry: 1.11.0 smart-buffer: 4.2.0 @@ -18923,7 +18955,7 @@ snapshots: prismarine-provider-anvil@https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/1d548fac63fe977c8281f0a9a522b37e4d92d0b7(minecraft-data@3.83.1): dependencies: - prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 + prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9(prismarine-registry@1.11.0) prismarine-chunk: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/e68e9a423b5b1907535878fb636f12c28a1a9374(minecraft-data@3.83.1) prismarine-nbt: 2.5.0 prismarine-world: https://codeload.github.com/zardoy/prismarine-world/tar.gz/ab2146c9933eef3247c3f64446de4ccc2c484c7c @@ -18947,13 +18979,13 @@ snapshots: prismarine-registry@1.11.0: dependencies: minecraft-data: 3.83.1 - prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 + prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9(prismarine-registry@1.11.0) prismarine-nbt: 2.7.0 prismarine-schematic@1.2.3: dependencies: minecraft-data: 3.83.1 - prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 + prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9(prismarine-registry@1.11.0) prismarine-nbt: 2.5.0 prismarine-world: https://codeload.github.com/zardoy/prismarine-world/tar.gz/ab2146c9933eef3247c3f64446de4ccc2c484c7c vec3: 0.1.10 diff --git a/renderer/viewer/lib/worldrendererCommon.ts b/renderer/viewer/lib/worldrendererCommon.ts index 0225bb87..2e3ff5d2 100644 --- a/renderer/viewer/lib/worldrendererCommon.ts +++ b/renderer/viewer/lib/worldrendererCommon.ts @@ -109,7 +109,9 @@ export abstract class WorldRendererCommon geometryReceiveCount = {} as Record allLoadedIn: undefined | number onWorldSwitched = [] as Array<() => void> - + renderTimeMax = 0 + renderTimeAvg = 0 + renderTimeAvgCount = 0 edgeChunks = {} as Record lastAddChunk = null as null | { timeout: any diff --git a/renderer/viewer/three/worldrendererThree.ts b/renderer/viewer/three/worldrendererThree.ts index 4e800776..7b82953b 100644 --- a/renderer/viewer/three/worldrendererThree.ts +++ b/renderer/viewer/three/worldrendererThree.ts @@ -50,6 +50,7 @@ export class WorldRendererThree extends WorldRendererCommon { media: ThreeJsMedia waitingChunksToDisplay = {} as { [chunkKey: string]: SectionKey[] } camera: THREE.PerspectiveCamera + renderTimeAvg = 0 get tilesRendered () { return Object.values(this.sectionObjects).reduce((acc, obj) => acc + (obj as any).tilesCount, 0) @@ -403,6 +404,7 @@ export class WorldRendererThree extends WorldRendererCommon { } render (sizeChanged = false) { + const start = performance.now() this.lastRendered = performance.now() this.cursorBlock.render() @@ -427,6 +429,11 @@ export class WorldRendererThree extends WorldRendererCommon { for (const onRender of this.onRender) { onRender() } + const end = performance.now() + const totalTime = end - start + this.renderTimeAvgCount++ + this.renderTimeAvg = ((this.renderTimeAvg * (this.renderTimeAvgCount - 1)) + totalTime) / this.renderTimeAvgCount + this.renderTimeMax = Math.max(this.renderTimeMax, totalTime) } renderHead (position: Vec3, rotation: number, isWall: boolean, blockEntity) { diff --git a/src/appParams.ts b/src/appParams.ts index b550ea02..994a5e16 100644 --- a/src/appParams.ts +++ b/src/appParams.ts @@ -51,6 +51,13 @@ export type AppQsParams = { replayStopOnError?: string replaySkipMissingOnTimeout?: string replayPacketsSenderDelay?: string + + // Benchmark params + openBenchmark?: string + renderDistance?: string + downloadBenchmark?: string + benchmarkMapZipUrl?: string + benchmarkPosition?: string } export type AppQsParamsArray = { diff --git a/src/benchmark.ts b/src/benchmark.ts new file mode 100644 index 00000000..b3efa307 --- /dev/null +++ b/src/benchmark.ts @@ -0,0 +1,179 @@ +import { Vec3 } from 'vec3' +import { WorldRendererCommon } from 'renderer/viewer/lib/worldrendererCommon' +import prettyBytes from 'pretty-bytes' +import { subscribe } from 'valtio' +import { downloadAndOpenMapFromUrl } from './downloadAndOpenFile' +import { activeModalStack, miscUiState } from './globalState' +import { disabledSettings, options } from './optionsStorage' +import { BenchmarkAdapterInfo, getAllInfoLines } from './benchmarkAdapter' +import { appQueryParams } from './appParams' + +const DEFAULT_RENDER_DISTANCE = 8 + +const fixtures = { + default: { + url: 'https://bucket.mcraft.fun/Future CITY 4.4-slim.zip', + spawn: [-133, 87, 309] as [number, number, number], + }, +} + +Error.stackTraceLimit = Error.stackTraceLimit < 30 ? 30 : Error.stackTraceLimit + +export const openBenchmark = async (renderDistance = DEFAULT_RENDER_DISTANCE) => { + const fixture: { + url: string + spawn?: [number, number, number] + } = appQueryParams.benchmarkMapZipUrl ? { + url: appQueryParams.benchmarkMapZipUrl, + spawn: appQueryParams.benchmarkPosition ? appQueryParams.benchmarkPosition.split(',').map(Number) as [number, number, number] : fixtures.default.spawn, + } : fixtures.default + + let memoryUsageAverage = 0 + let memoryUsageSamples = 0 + let memoryUsageWorst = 0 + setInterval(() => { + const memoryUsage = (window.performance as any)?.memory?.usedJSHeapSize + if (memoryUsage) { + memoryUsageAverage = (memoryUsageAverage * memoryUsageSamples + memoryUsage) / (memoryUsageSamples + 1) + memoryUsageSamples++ + if (memoryUsage > memoryUsageWorst) { + memoryUsageWorst = memoryUsage + } + } + }, 200) + + let benchmarkName = `${fixture.url}` + if (fixture.spawn) { + benchmarkName += ` - ${fixture.spawn.join(',')}` + } + benchmarkName += ` - ${renderDistance}` + const benchmarkAdapter: BenchmarkAdapterInfo = { + get benchmarkName () { + return benchmarkName + }, + get worldLoadTimeSeconds () { + return window.worldLoadTime + }, + get mesherWorkersCount () { + return (window.world as WorldRendererCommon).worldRendererConfig.mesherWorkers + }, + get mesherProcessAvgMs () { + return (window.world as WorldRendererCommon).workersProcessAverageTime + }, + get mesherProcessWorstMs () { + return (window.world as WorldRendererCommon).maxWorkersProcessTime + }, + get averageRenderTimeMs () { + return (window.world as WorldRendererCommon).renderTimeAvg + }, + get worstRenderTimeMs () { + return (window.world as WorldRendererCommon).renderTimeMax + }, + get fpsAveragePrediction () { + const avgRenderTime = (window.world as WorldRendererCommon).renderTimeAvg + return 1000 / avgRenderTime + }, + get fpsWorstPrediction () { + const maxRenderTime = (window.world as WorldRendererCommon).renderTimeMax + return 1000 / maxRenderTime + }, + get fpsAverageReal () { + return -1 + }, + get fpsWorstReal () { + return -1 + }, + get memoryUsageAverage () { + return prettyBytes(memoryUsageAverage) + }, + get memoryUsageWorst () { + return prettyBytes(memoryUsageWorst) + }, + get gpuInfo () { + return appViewer.rendererState.renderer + }, + get hardwareConcurrency () { + return navigator.hardwareConcurrency + }, + get userAgent () { + return navigator.userAgent + }, + } + window.benchmarkAdapter = benchmarkAdapter + + disabledSettings.value.add('renderDistance') + options.renderDistance = renderDistance + void downloadAndOpenMapFromUrl(fixture.url, undefined, { + connectEvents: { + serverCreated () { + if (fixture.spawn) { + localServer!.spawnPoint = new Vec3(...fixture.spawn) + localServer!.on('newPlayer', (player) => { + player.on('dataLoaded', () => { + player.position = new Vec3(...fixture.spawn!) + }) + }) + } + }, + } + }) + document.addEventListener('cypress-world-ready', () => { + let stats = getAllInfoLines(window.benchmarkAdapter) + if (appQueryParams.downloadBenchmark) { + const a = document.createElement('a') + a.href = 'data:text/plain;charset=utf-8,' + encodeURIComponent(stats.join('\n')) + a.download = `benchmark-${appViewer.backend?.id}.txt` + a.click() + } + + const panel = document.createElement('div') + panel.style.position = 'fixed' + panel.style.top = '10px' + panel.style.right = '10px' + panel.style.backgroundColor = 'rgba(0,0,0,0.8)' + panel.style.color = 'white' + panel.style.padding = '10px' + panel.style.zIndex = '1000' + panel.style.fontFamily = 'monospace' + panel.id = 'benchmark-panel' + + const pre = document.createElement('pre') + panel.appendChild(pre) + + pre.textContent = stats.join('\n') + const updateStats = () => { + stats = getAllInfoLines(window.benchmarkAdapter) + pre.textContent = stats.join('\n') + } + + document.body.appendChild(panel) + // setInterval(updateStats, 100) + }) +} + +document.addEventListener('pointerlockchange', (e) => { + const panel = document.querySelector('#benchmark-panel') + if (panel) { + panel.hidden = !!document.pointerLockElement + } +}) + +subscribe(activeModalStack, () => { + const panel = document.querySelector('#benchmark-panel') + if (panel && activeModalStack.length > 1) { + panel.hidden = true + } +}) + +export const registerOpenBenchmarkListener = () => { + if (appQueryParams.openBenchmark) { + void openBenchmark(appQueryParams.renderDistance ? +appQueryParams.renderDistance : undefined) + } + + window.addEventListener('keydown', (e) => { + if (e.code === 'KeyB' && e.shiftKey && !miscUiState.gameLoaded && activeModalStack.length === 0) { + e.preventDefault() + void openBenchmark() + } + }) +} diff --git a/src/benchmarkAdapter.ts b/src/benchmarkAdapter.ts new file mode 100644 index 00000000..85fb360b --- /dev/null +++ b/src/benchmarkAdapter.ts @@ -0,0 +1,58 @@ +import { noCase } from 'change-case' + +export interface BenchmarkAdapterInfo { + benchmarkName: string + // general load info + worldLoadTimeSeconds: number + + // mesher + mesherWorkersCount: number + mesherProcessAvgMs: number + mesherProcessWorstMs: number + + // rendering backend + averageRenderTimeMs: number + worstRenderTimeMs: number + fpsAveragePrediction: number + fpsWorstPrediction: number + fpsAverageReal: number + fpsWorstReal: number + + // memory total + memoryUsageAverage: string + memoryUsageWorst: string + + // context info + gpuInfo: string + hardwareConcurrency: number + userAgent: string +} + +export const getAllInfo = (adapter: BenchmarkAdapterInfo) => { + return Object.fromEntries( + Object.entries(adapter).map(([key, value]) => { + if (typeof value === 'function') { + value = (value as () => any)() + } + if (typeof value === 'number') { + value = value.toFixed(2) + } + return [noCase(key), value] + }) + ) +} + +export const getAllInfoLines = (adapter: BenchmarkAdapterInfo, delayed = false) => { + const info = getAllInfo(adapter) + if (delayed) { + for (const key in info) { + if (key !== 'fpsAveragePrediction' && key !== 'fpsAverageReal') { + delete info[key] + } + } + } + + return Object.entries(info).map(([key, value]) => { + return `${key}${delayed ? ' (delayed)' : ''}: ${value}` + }) +} diff --git a/src/browserfs.ts b/src/browserfs.ts index 9fb0771f..a4ae96cc 100644 --- a/src/browserfs.ts +++ b/src/browserfs.ts @@ -16,6 +16,7 @@ import { packetsReplayState } from './react/state/packetsReplayState' import { createFullScreenProgressReporter } from './core/progressReporter' import { showNotification } from './react/NotificationProvider' import { resetAppStorage } from './react/appStorageProvider' +import { ConnectOptions } from './connect' const { GoogleDriveFileSystem } = require('google-drive-browserfs/src/backends/GoogleDrive') browserfs.install(window) @@ -558,7 +559,7 @@ export const openWorldFromHttpDir = async (fileDescriptorUrls: string[]/* | und } // todo rename method -const openWorldZipInner = async (file: File | ArrayBuffer, name = file['name']) => { +const openWorldZipInner = async (file: File | ArrayBuffer, name = file['name'], connectOptions?: Partial) => { await new Promise(async resolve => { browserfs.configure({ // todo @@ -603,7 +604,7 @@ const openWorldZipInner = async (file: File | ArrayBuffer, name = file['name']) } if (availableWorlds.length === 1) { - await loadSave(`/world/${availableWorlds[0]}`) + await loadSave(`/world/${availableWorlds[0]}`, connectOptions) return } diff --git a/src/connect.ts b/src/connect.ts index 5afdb0f6..134b86da 100644 --- a/src/connect.ts +++ b/src/connect.ts @@ -15,7 +15,7 @@ export type ConnectOptions = { singleplayer?: any username: string proxy?: string - botVersion?: any + botVersion?: string serverOverrides? serverOverridesFlat? peerId?: string @@ -23,7 +23,6 @@ export type ConnectOptions = { onSuccessfulPlay?: () => void autoLoginPassword?: string serverIndex?: string - /** If true, will show a UI to authenticate with a new account */ authenticatedAccount?: AuthenticatedAccount | true peerOptions?: any viewerWsConnect?: string @@ -31,6 +30,15 @@ export type ConnectOptions = { /** Will enable local replay server */ worldStateFileContents?: string + + connectEvents?: { + serverCreated?: () => void + // connect: () => void; + // disconnect: () => void; + // error: (err: any) => void; + // ready: () => void; + // end: () => void; + } } export const getVersionAutoSelect = (autoVersionSelect = options.serversAutoVersionSelect) => { diff --git a/src/downloadAndOpenFile.ts b/src/downloadAndOpenFile.ts index 78cd6984..32d5ae10 100644 --- a/src/downloadAndOpenFile.ts +++ b/src/downloadAndOpenFile.ts @@ -5,6 +5,7 @@ import { setLoadingScreenStatus } from './appStatus' import { appQueryParams, appQueryParamsArray } from './appParams' import { VALID_REPLAY_EXTENSIONS, openFile } from './packetsReplay/replayPackets' import { createFullScreenProgressReporter } from './core/progressReporter' +import { ConnectOptions } from './connect' export const getFixedFilesize = (bytes: number) => { return prettyBytes(bytes, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) @@ -65,24 +66,29 @@ const inner = async () => { await openWorldFromHttpDir(mapUrlDir, mapUrlDirBaseUrl ?? undefined) return true } + if (mapUrlDirGuess) { // await openWorldFromHttpDir(undefined, mapUrlDirGuess) return true } - let mapUrl = appQueryParams.map - const { texturepack } = appQueryParams + + const { map, texturepack } = appQueryParams + return downloadAndOpenMapFromUrl(map, texturepack) +} + +export const downloadAndOpenMapFromUrl = async (mapUrl: string | undefined, texturepackUrl: string | undefined, connectOptions?: Partial) => { // fixme - if (texturepack) mapUrl = texturepack + if (texturepackUrl) mapUrl = texturepackUrl if (!mapUrl) return false - if (texturepack) { + if (texturepackUrl) { await updateTexturePackInstalledState() if (resourcePackState.resourcePackInstalled) { if (!confirm(`You are going to install a new resource pack, which will REPLACE the current one: ${await getResourcePackNames()[0]} Continue?`)) return } } const name = mapUrl.slice(mapUrl.lastIndexOf('/') + 1).slice(-25) - const downloadThing = texturepack ? 'texturepack' : 'world' + const downloadThing = texturepackUrl ? 'texturepack' : 'world' setLoadingScreenStatus(`Downloading ${downloadThing} ${name}...`) const response = await fetch(mapUrl) @@ -115,25 +121,25 @@ const inner = async () => { const progress = contentLength ? (downloadedBytes / contentLength) * 100 : undefined setLoadingScreenStatus(`Download ${downloadThing} progress: ${progress === undefined ? '?' : Math.floor(progress)}% (${getFixedFilesize(downloadedBytes)} / ${contentLength && getFixedFilesize(contentLength)})`, false, true) - // Pass the received data to the controller controller.enqueue(value) } }, })).arrayBuffer() - if (texturepack) { + if (texturepackUrl) { const name = mapUrl.slice(mapUrl.lastIndexOf('/') + 1).slice(-30) await installResourcepackPack(buffer, createFullScreenProgressReporter(), name) } else { - await openWorldZip(buffer) + await openWorldZip(buffer, undefined, connectOptions) } + return true } export default async () => { try { return await inner() } catch (err) { - setLoadingScreenStatus(`Failed to download. Either refresh page or remove map param from URL. Reason: ${err.message}`) + setLoadingScreenStatus(`Failed to download/open. Either refresh page or remove map param from URL. Reason: ${err.message}`) return true } } diff --git a/src/index.ts b/src/index.ts index b8a52a49..fca2d8c1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -96,6 +96,7 @@ import { localRelayServerPlugin } from './mineflayer/plugins/packetsRecording' import { createConsoleLogProgressReporter, createFullScreenProgressReporter, ProgressReporter } from './core/progressReporter' import { appViewer } from './appViewer' import './appViewerLoad' +import { registerOpenBenchmarkListener } from './benchmark' window.debug = debug window.beforeRenderFrame = [] @@ -119,13 +120,22 @@ function hideCurrentScreens () { insertActiveModalStack('', []) } -const loadSingleplayer = (serverOverrides = {}, flattenedServerOverrides = {}) => { +const loadSingleplayer = (serverOverrides = {}, flattenedServerOverrides = {}, connectOptions?: Partial) => { const serverSettingsQsRaw = appQueryParamsArray.serverSetting ?? [] const serverSettingsQs = serverSettingsQsRaw.map(x => x.split(':')).reduce>((acc, [key, value]) => { acc[key] = JSON.parse(value) return acc }, {}) - void connect({ singleplayer: true, username: options.localUsername, serverOverrides, serverOverridesFlat: { ...flattenedServerOverrides, ...serverSettingsQs } }) + void connect({ + singleplayer: true, + username: options.localUsername, + serverOverrides, + serverOverridesFlat: { + ...flattenedServerOverrides, + ...serverSettingsQs + }, + ...connectOptions + }) } function listenGlobalEvents () { window.addEventListener('connect', e => { @@ -133,7 +143,9 @@ function listenGlobalEvents () { void connect(options) }) window.addEventListener('singleplayer', (e) => { - loadSingleplayer((e as CustomEvent).detail) + const { detail } = (e as CustomEvent) + const { connectOptions, ...rest } = detail + loadSingleplayer(rest, {}, connectOptions) }) } @@ -348,6 +360,7 @@ export async function connect (connectOptions: ConnectOptions) { // flying-squid: 'login' -> player.login -> now sends 'login' event to the client (handled in many plugins in mineflayer) -> then 'update_health' is sent which emits 'spawn' in mineflayer localServer = window.localServer = window.server = startLocalServer(serverOptions) + connectOptions?.connectEvents?.serverCreated?.() // todo need just to call quit if started // loadingScreen.maybeRecoverable = false // init world, todo: do it for any async plugins @@ -644,13 +657,15 @@ export async function connect (connectOptions: ConnectOptions) { setLoadingScreenStatus('Loading world') }) - const loadStart = Date.now() let worldWasReady = false const waitForChunksToLoad = async (progress?: ProgressReporter) => { await new Promise(resolve => { + if (worldWasReady) { + resolve() + return + } const unsub = subscribe(appViewer.rendererState, () => { - if (worldWasReady) return - if (appViewer.rendererState.world.allChunksLoaded) { + if (appViewer.rendererState.world.allChunksLoaded && appViewer.nonReactiveState.world.chunksTotalNumber) { worldWasReady = true resolve() unsub() @@ -662,11 +677,6 @@ export async function connect (connectOptions: ConnectOptions) { }) } - void waitForChunksToLoad().then(() => { - console.log('All chunks done and ready! Time from renderer connect to ready', (Date.now() - loadStart) / 1000, 's') - document.dispatchEvent(new Event('cypress-world-ready')) - }) - const spawnEarlier = !singleplayer && !p2pMultiplayer const displayWorld = async () => { if (resourcePackState.isServerInstalling) { @@ -679,11 +689,18 @@ export async function connect (connectOptions: ConnectOptions) { }) await appViewer.resourcesManager.promiseAssetsReady } - console.log('try to focus window') - window.focus?.() errorAbortController.abort() if (appStatusState.isError) return + const loadWorldStart = Date.now() + console.log('try to focus window') + window.focus?.() + void waitForChunksToLoad().then(() => { + window.worldLoadTime = (Date.now() - loadWorldStart) / 1000 + console.log('All chunks done and ready! Time from renderer connect to ready', (Date.now() - loadWorldStart) / 1000, 's') + document.dispatchEvent(new Event('cypress-world-ready')) + }) + try { if (p2pConnectTimeout) clearTimeout(p2pConnectTimeout) playerState.onlineMode = !!connectOptions.authenticatedAccount @@ -932,7 +949,7 @@ if (!reconnectOptions) { } }, (err) => { console.error(err) - alert(`Failed to download file: ${err}`) + alert(`Something went wrong: ${err}`) }) } @@ -945,3 +962,4 @@ if (initialLoader) { window.pageLoaded = true void possiblyHandleStateVariable() +registerOpenBenchmarkListener() diff --git a/src/loadSave.ts b/src/loadSave.ts index c672193c..7c9f7277 100644 --- a/src/loadSave.ts +++ b/src/loadSave.ts @@ -11,6 +11,7 @@ import { isMajorVersionGreater } from './utils' import { activeModalStacks, insertActiveModalStack, miscUiState } from './globalState' import supportedVersions from './supportedVersions.mjs' +import { ConnectOptions } from './connect' import { appQueryParams } from './appParams' // todo include name of opened handle (zip)! @@ -49,7 +50,7 @@ export const readLevelDat = async (path) => { return { levelDat, dataRaw: parsed.value.Data!.value as Record } } -export const loadSave = async (root = '/world') => { +export const loadSave = async (root = '/world', connectOptions?: Partial) => { // todo test if (miscUiState.gameLoaded) { await disconnect() @@ -194,7 +195,8 @@ export const loadSave = async (root = '/world') => { } : {}, ...root === '/world' ? {} : { 'worldFolder': root - } + }, + connectOptions }, })) } diff --git a/src/mineflayer/timers.ts b/src/mineflayer/timers.ts index 698b624e..99110718 100644 --- a/src/mineflayer/timers.ts +++ b/src/mineflayer/timers.ts @@ -1,9 +1,71 @@ +import { subscribeKey } from 'valtio/utils' import { preventThrottlingWithSound } from '../core/timers' import { options } from '../optionsStorage' customEvents.on('mineflayerBotCreated', () => { - if (options.preventBackgroundTimeoutKick) { - const unsub = preventThrottlingWithSound() - bot.on('end', unsub) + const abortController = new AbortController() + + const maybeGoBackgroundKickPrevention = () => { + if (options.preventBackgroundTimeoutKick && !bot.backgroundKickPrevention) { + const unsub = preventThrottlingWithSound() + bot.on('end', unsub) + bot.backgroundKickPrevention = true + } } + maybeGoBackgroundKickPrevention() + subscribeKey(options, 'preventBackgroundTimeoutKick', (value) => { + maybeGoBackgroundKickPrevention() + }) + + // wake lock + const requestWakeLock = async () => { + if (!('wakeLock' in navigator)) { + console.warn('Wake Lock API is not supported in this browser') + return + } + + if (options.preventSleep && !bot.wakeLock && !bot.lockRequested) { + bot.lockRequested = true + bot.wakeLock = await navigator.wakeLock.request('screen').finally(() => { + bot.lockRequested = false + }) + + bot.wakeLock.addEventListener('release', () => { + bot.wakeLock = undefined + }, { + once: true, + }) + } + + if (!options.preventSleep && bot.wakeLock) { + void bot.wakeLock.release() + } + } + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + // we are back to the tab, request wake lock again + void requestWakeLock() + } + }, { + signal: abortController.signal, + }) + void requestWakeLock() + subscribeKey(options, 'preventSleep', (value) => { + void requestWakeLock() + }) + + bot.on('end', () => { + if (bot.wakeLock) { + void bot.wakeLock.release() + } + abortController.abort() + }) }) + +declare module 'mineflayer' { + interface Bot { + backgroundKickPrevention?: boolean + wakeLock?: WakeLockSentinel + lockRequested?: boolean + } +} diff --git a/src/optionsGuiScheme.tsx b/src/optionsGuiScheme.tsx index 5b83922f..ef8d9a8e 100644 --- a/src/optionsGuiScheme.tsx +++ b/src/optionsGuiScheme.tsx @@ -526,7 +526,11 @@ export const guiOptionsScheme: { }, }, { - preventBackgroundTimeoutKick: {} + preventBackgroundTimeoutKick: {}, + preventSleep: { + disabledReason: navigator.wakeLock ? undefined : 'Your browser does not support wake lock API', + enableWarning: 'When connected to a server, prevent PC from sleeping or screen dimming. Useful for purpusely staying AFK for long time. Some events might still prevent this like loosing tab focus or going low power mode.', + }, }, { custom () { diff --git a/src/optionsStorage.ts b/src/optionsStorage.ts index 3d021903..4d76ba0c 100644 --- a/src/optionsStorage.ts +++ b/src/optionsStorage.ts @@ -65,6 +65,7 @@ const defaultOptions = { waitForChunksRender: 'sp-only' as 'sp-only' | boolean, jeiEnabled: true as boolean | Array<'creative' | 'survival' | 'adventure' | 'spectator'>, preventBackgroundTimeoutKick: false, + preventSleep: false, // antiAliasing: false, diff --git a/src/react/IndicatorEffects.tsx b/src/react/IndicatorEffects.tsx index 18ab0785..5b05290f 100644 --- a/src/react/IndicatorEffects.tsx +++ b/src/react/IndicatorEffects.tsx @@ -46,7 +46,8 @@ export const defaultIndicatorsState = { readonlyFiles: false, writingFiles: false, // saving appHasErrors: false, - connectionIssues: 0 + connectionIssues: 0, + preventSleep: false, } const indicatorIcons: Record = { @@ -56,6 +57,7 @@ const indicatorIcons: Record = { appHasErrors: 'alert', readonlyFiles: 'file-off', connectionIssues: pixelartIcons['cellular-signal-off'], + preventSleep: pixelartIcons.moon, } const colorOverrides = { diff --git a/src/react/IndicatorEffectsProvider.tsx b/src/react/IndicatorEffectsProvider.tsx index 83b321ee..1913fb7b 100644 --- a/src/react/IndicatorEffectsProvider.tsx +++ b/src/react/IndicatorEffectsProvider.tsx @@ -1,5 +1,5 @@ import { proxy, subscribe, useSnapshot } from 'valtio' -import { useEffect, useMemo } from 'react' +import { useEffect, useMemo, useState } from 'react' import { subscribeKey } from 'valtio/utils' import { inGameError } from '../utils' import { fsState } from '../loadSave' @@ -51,6 +51,7 @@ const getEffectIndex = (newEffect: EffectType) => { } export default () => { + const [dummyState, setDummyState] = useState(false) const stateIndicators = useSnapshot(state.indicators) const chunksLoading = !useSnapshot(appViewer.rendererState).world.allChunksLoaded const { mesherWork } = useSnapshot(appViewer.rendererState).world @@ -66,12 +67,21 @@ export default () => { appHasErrors: hasErrors, connectionIssues: poorConnection ? 1 : noConnection ? 2 : 0, chunksLoading, + preventSleep: !!bot.wakeLock, // mesherWork, ...stateIndicators, } const effects = useSnapshot(state.effects) + useEffect(() => { + // update bot related states + const interval = setInterval(() => { + setDummyState(s => !s) + }, 1000) + return () => clearInterval(interval) + }, []) + useMemo(() => { const effectsImages = Object.fromEntries(loadedData.effectsArray.map((effect) => { const nameKebab = effect.name.replaceAll(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`).slice(1)