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
},
}))
}