diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml new file mode 100644 index 000000000..b2822270d --- /dev/null +++ b/.github/workflows/benchmark.yml @@ -0,0 +1,39 @@ +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 + # read benchmark results from stdout + - run: echo "BENCHMARK_RESULT=$(cat benchmark.txt)" >> $GITHUB_ENV + - 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 f9bd94783..7c4098a9f 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, @@ -31,7 +33,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/**/*.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 000000000..03b91cb89 --- /dev/null +++ b/cypress/e2e/rendering_performance.spec.ts @@ -0,0 +1,39 @@ +/// +import { BenchmarkAdapter } from '../../src/benchmarkAdapter' +import { setOptions, cleanVisit, visit } from './shared' + +it('Benchmark rendering performance', () => { + cleanVisit('/?openBenchmark=true&renderDistance=5') + // wait for render end event + return cy.document().then({ timeout: 120_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 BenchmarkAdapter + const renderTimeWorst = adapter.worstRenderTime + const renderTimeAvg = adapter.averageRenderTime + const fpsWorst = 1000 / renderTimeWorst + const fpsAvg = 1000 / renderTimeAvg + const totalTime = adapter.worldLoadTime + const { gpuInfo } = adapter + + const messages = [ + `Worst FPS: ${fpsWorst.toFixed(2)}`, + `Average FPS: ${fpsAvg.toFixed(2)}`, + `Total time: ${totalTime.toFixed(2)}s`, + `Memory usage average: ${adapter.memoryUsageAverage.toFixed(2)}MB`, + `Memory usage worst: ${adapter.memoryUsageWorst.toFixed(2)}MB`, + `GPU info: ${gpuInfo}`, + ] + for (const message of messages) { + cy.log(message) + } + cy.writeFile('benchmark.txt', messages.join('\n')) + }) + }) +}) diff --git a/package.json b/package.json index 07e9ff0b4..46d6f1ed1 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "build": "node scripts/build.js copyFiles && node scripts/prepareData.mjs -f && node esbuild.mjs --minify --prod", "check-build": "tsc && pnpm build", "test:cypress": "cypress run", + "test:benchmark": "PERFORMANCE_TEST=true cypress run", "test-unit": "vitest", "test:e2e": "start-test http-get://localhost:8080 test:cypress", "prod-start": "node server.js", diff --git a/prismarine-viewer/viewer/lib/viewerWrapper.ts b/prismarine-viewer/viewer/lib/viewerWrapper.ts index 57317f422..3b042f4a6 100644 --- a/prismarine-viewer/viewer/lib/viewerWrapper.ts +++ b/prismarine-viewer/viewer/lib/viewerWrapper.ts @@ -52,9 +52,11 @@ export class ViewerWrapper { windowFocused = true trackWindowFocus () { window.addEventListener('focus', () => { + console.log('window focused') this.windowFocused = true }) window.addEventListener('blur', () => { + console.log('window blurred') this.windowFocused = false }) } diff --git a/prismarine-viewer/viewer/lib/worldrendererThree.ts b/prismarine-viewer/viewer/lib/worldrendererThree.ts index cc89a8238..9f5b94b53 100644 --- a/prismarine-viewer/viewer/lib/worldrendererThree.ts +++ b/prismarine-viewer/viewer/lib/worldrendererThree.ts @@ -17,6 +17,8 @@ export class WorldRendererThree extends WorldRendererCommon { signsCache = new Map() starField: StarField cameraSectionPos: Vec3 = new Vec3(0, 0, 0) + worstRenderTime = 0 + avgRenderTime = 0 get tilesRendered () { return Object.values(this.sectionObjects).reduce((acc, obj) => acc + (obj as any).tilesCount, 0) @@ -72,20 +74,6 @@ export class WorldRendererThree extends WorldRendererCommon { const chunkCoords = data.key.split(',') if (!this.loadedChunks[chunkCoords[0] + ',' + chunkCoords[2]] || !data.geometry.positions.length || !this.active) return - // if (!this.initialChunksLoad && this.enableChunksLoadDelay) { - // const newPromise = new Promise(resolve => { - // if (this.droppedFpsPercentage > 0.5) { - // setTimeout(resolve, 1000 / 50 * this.droppedFpsPercentage) - // } else { - // setTimeout(resolve) - // } - // }) - // this.promisesQueue.push(newPromise) - // for (const promise of this.promisesQueue) { - // await promise - // } - // } - const geometry = new THREE.BufferGeometry() geometry.setAttribute('position', new THREE.BufferAttribute(data.geometry.positions, 3)) geometry.setAttribute('normal', new THREE.BufferAttribute(data.geometry.normals, 3)) @@ -163,7 +151,11 @@ export class WorldRendererThree extends WorldRendererCommon { render () { tweenJs.update() const cam = this.camera instanceof THREE.Group ? this.camera.children.find(child => child instanceof THREE.PerspectiveCamera) as THREE.PerspectiveCamera : this.camera + const start = performance.now() this.renderer.render(this.scene, cam) + const totalTime = performance.now() - start + this.avgRenderTime = this.avgRenderTime * 0.9 + totalTime * 0.1 // exponential moving average + this.worstRenderTime = Math.max(this.worstRenderTime, totalTime) } renderSign (position: Vec3, rotation: number, isWall: boolean, isHanging: boolean, blockEntity) { diff --git a/src/benchmark.ts b/src/benchmark.ts new file mode 100644 index 000000000..78e5e0273 --- /dev/null +++ b/src/benchmark.ts @@ -0,0 +1,77 @@ +import { Vec3 } from 'vec3' +import { downloadAndOpenFileFromUrl } from './downloadAndOpenFile' +import { activeModalStack, miscUiState } from './globalState' +import { options } from './optionsStorage' +import { BenchmarkAdapter } from './benchmarkAdapter' + +const testWorldFixtureUrl = 'https://bucket.mcraft.fun/Future CITY 4.4-slim.zip' +const testWorldFixtureSpawn = [-133, 87, 309] as const + +export const openBenchmark = async (renderDistance = 8) => { + 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) + + const benchmarkAdapter: BenchmarkAdapter = { + get worldLoadTime () { + return window.worldLoadTime + }, + get averageRenderTime () { + return window.viewer.world.avgRenderTime + }, + get worstRenderTime () { + return window.viewer.world.worstRenderTime + }, + get memoryUsageAverage () { + return memoryUsageAverage + }, + get memoryUsageWorst () { + return memoryUsageWorst + }, + get gpuInfo () { + const gl = window.viewer.renderer.getContext() + return gl.getParameter(gl.getExtension('WEBGL_debug_renderer_info')!.UNMASKED_RENDERER_WEBGL) + } + } + window.benchmarkAdapter = benchmarkAdapter + + options.renderDistance = renderDistance + void downloadAndOpenFileFromUrl(testWorldFixtureUrl, undefined, { + connectEvents: { + serverCreated () { + if (testWorldFixtureSpawn) { + localServer!.spawnPoint = new Vec3(...testWorldFixtureSpawn) + localServer!.on('newPlayer', (player) => { + player.on('dataLoaded', () => { + player.position = new Vec3(...testWorldFixtureSpawn) + }) + }) + } + }, + } + }) +} + +export const registerOpenBenchmarkListener = () => { + const params = new URLSearchParams(window.location.search) + if (params.get('openBenchmark')) { + void openBenchmark(params.has('renderDistance') ? +params.get('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 000000000..327af715a --- /dev/null +++ b/src/benchmarkAdapter.ts @@ -0,0 +1,8 @@ +export interface BenchmarkAdapter { + worldLoadTime: number + averageRenderTime: number + worstRenderTime: number + memoryUsageAverage: number + memoryUsageWorst: number + gpuInfo: string +} diff --git a/src/browserfs.ts b/src/browserfs.ts index ebe8acfdd..aa2c0e9e9 100644 --- a/src/browserfs.ts +++ b/src/browserfs.ts @@ -10,6 +10,7 @@ import { fsState, loadSave } from './loadSave' import { installTexturePack, installTexturePackFromHandle, updateTexturePackInstalledState } from './texturePack' import { miscUiState } from './globalState' import { setLoadingScreenStatus } from './utils' +import { ConnectOptions } from './connect' const { GoogleDriveFileSystem } = require('google-drive-browserfs/src/backends/GoogleDrive') // disable type checking browserfs.install(window) @@ -434,7 +435,7 @@ export const copyFilesAsync = async (pathSrc: string, pathDest: string, fileCopi } // 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 @@ -478,7 +479,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 12a1fc5bf..5e2d8b43f 100644 --- a/src/connect.ts +++ b/src/connect.ts @@ -5,7 +5,7 @@ export type ConnectOptions = { singleplayer?: any username: string proxy?: string - botVersion?: any + botVersion?: string serverOverrides? serverOverridesFlat? peerId?: string @@ -13,6 +13,14 @@ export type ConnectOptions = { onSuccessfulPlay?: () => void autoLoginPassword?: string serverIndex?: string - /** If true, will show a UI to authenticate with a new account */ authenticatedAccount?: AuthenticatedAccount | true + + connectEvents?: { + serverCreated?: () => void + // connect: () => void; + // disconnect: () => void; + // error: (err: any) => void; + // ready: () => void; + // end: () => void; + } } diff --git a/src/downloadAndOpenFile.ts b/src/downloadAndOpenFile.ts index 7ac154fcb..ad0f831c4 100644 --- a/src/downloadAndOpenFile.ts +++ b/src/downloadAndOpenFile.ts @@ -2,27 +2,25 @@ import prettyBytes from 'pretty-bytes' import { openWorldZip } from './browserfs' import { getResourcePackName, installTexturePack, resourcePackState, updateTexturePackInstalledState } from './texturePack' import { setLoadingScreenStatus } from './utils' +import { ConnectOptions } from './connect' export const getFixedFilesize = (bytes: number) => { return prettyBytes(bytes, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) } -const inner = async () => { - const qs = new URLSearchParams(window.location.search) - let mapUrl = qs.get('map') - const texturepack = qs.get('texturepack') +export const downloadAndOpenFileFromUrl = 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 getResourcePackName()} 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) @@ -63,17 +61,20 @@ const inner = async () => { }, }) ).arrayBuffer() - if (texturepack) { + if (texturepackUrl) { const name = mapUrl.slice(mapUrl.lastIndexOf('/') + 1).slice(-30) await installTexturePack(buffer, name) } else { - await openWorldZip(buffer) + await openWorldZip(buffer, undefined, connectOptions) } } export default async () => { try { - return await inner() + const qs = new URLSearchParams(window.location.search) + const mapUrl = qs.get('map') + const texturepack = qs.get('texturepack') + return await downloadAndOpenFileFromUrl(mapUrl ?? undefined, texturepack ?? undefined) } catch (err) { setLoadingScreenStatus(`Failed to download. 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 e34e81ab7..b48941cd1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,6 +44,7 @@ import { defaultsDeep } from 'lodash-es' import initializePacketsReplay from './packetsReplay' import { initVR } from './vr' +import { registerOpenBenchmarkListener } from './benchmark' import { AppConfig, activeModalStack, @@ -226,8 +227,14 @@ function hideCurrentScreens () { insertActiveModalStack('', []) } -const loadSingleplayer = (serverOverrides = {}, flattenedServerOverrides = {}) => { - void connect({ singleplayer: true, username: options.localUsername, serverOverrides, serverOverridesFlat: flattenedServerOverrides }) +const loadSingleplayer = (serverOverrides = {}, flattenedServerOverrides = {}, otherOptions: Partial = {}) => { + void connect({ + singleplayer: true, + username: options.localUsername, + serverOverrides, + serverOverridesFlat: flattenedServerOverrides, + ...otherOptions + }) } function listenGlobalEvents () { window.addEventListener('connect', e => { @@ -235,7 +242,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) }) } @@ -420,6 +429,7 @@ async function connect (connectOptions: ConnectOptions) { setLoadingScreenStatus('Starting local server') 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 @@ -652,6 +662,7 @@ async function connect (connectOptions: ConnectOptions) { const spawnEarlier = !singleplayer && !p2pMultiplayer // don't use spawn event, player can be dead bot.once(spawnEarlier ? 'forcedMove' : 'health', () => { + window.worldStartLoad = Date.now() errorAbortController.abort() const mcData = MinecraftData(bot.version) window.PrismarineBlock = PrismarineBlock(mcData.version.minecraftVersion!) @@ -880,7 +891,9 @@ async function connect (connectOptions: ConnectOptions) { // todo might not emit as servers simply don't send chunk if it's empty if (!viewer.world.allChunksFinished || done) return done = true - console.log('All done and ready! In', (Date.now() - start) / 1000, 's') + const worldLoadTime = (Date.now() - start) / 1000 + window.worldLoadTime = worldLoadTime + console.log('All done and ready! In', worldLoadTime, 's') viewer.render() // ensure the last state is rendered document.dispatchEvent(new Event('cypress-world-ready')) }) @@ -1019,7 +1032,7 @@ downloadAndOpenFile().then((downloadAction) => { }) }, (err) => { console.error(err) - alert(`Failed to download file: ${err}`) + alert(`Somethin went wrong: ${err}`) }) // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion @@ -1031,3 +1044,4 @@ if (initialLoader) { window.pageLoaded = true void possiblyHandleStateVariable() +registerOpenBenchmarkListener() diff --git a/src/loadSave.ts b/src/loadSave.ts index 7ca454ff6..5f3241df1 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' // todo include name of opened handle (zip)! // additional fs metadata @@ -46,7 +47,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() @@ -189,7 +190,8 @@ export const loadSave = async (root = '/world') => { } : {}, ...root === '/world' ? {} : { 'worldFolder': root - } + }, + connectOptions }, })) }