initial implementation that should work already!

This commit is contained in:
Vitaly Turovsky 2024-06-27 09:25:57 +03:00
commit e9aa0e9891
14 changed files with 253 additions and 52 deletions

41
.github/workflows/benchmark.yml vendored Normal file
View file

@ -0,0 +1,41 @@
name: Vercel Deploy Preview
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
on:
issue_comment:
types: [created]
push:
branches:
- perf-test
jobs:
deploy:
runs-on: ubuntu-latest
# todo skip already created deploys on that commit
if: >-
github.event.issue.pull_request != '' &&
(
contains(github.event.comment.body, '/benchmark')
)
permissions:
pull-requests: write
steps:
- 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: 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 }}

View file

@ -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__/*'],
},
})

View file

@ -0,0 +1,37 @@
/// <reference types="cypress" />
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 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`,
]
for (const message of messages) {
cy.log(message)
}
cy.writeFile('benchmark.txt', messages.join('\n'))
})
})
})

View file

@ -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",
@ -63,7 +64,7 @@
"esbuild-plugin-polyfill-node": "^0.3.0",
"express": "^4.18.2",
"filesize": "^10.0.12",
"flying-squid": "npm:@zardoy/flying-squid@^0.0.29",
"flying-squid": "npm:@zardoy/flying-squid@^0.0.32",
"fs-extra": "^11.1.1",
"google-drive-browserfs": "github:zardoy/browserfs#google-drive",
"iconify-icon": "^1.0.8",

33
pnpm-lock.yaml generated
View file

@ -107,8 +107,8 @@ importers:
specifier: ^10.0.12
version: 10.0.12
flying-squid:
specifier: npm:@zardoy/flying-squid@^0.0.29
version: '@zardoy/flying-squid@0.0.29(encoding@0.1.13)'
specifier: npm:@zardoy/flying-squid@^0.0.32
version: '@zardoy/flying-squid@0.0.32(encoding@0.1.13)'
fs-extra:
specifier: ^11.1.1
version: 11.1.1
@ -3078,8 +3078,8 @@ packages:
resolution: {integrity: sha512-6xm38yGVIa6mKm/DUCF2zFFJhERh/QWp1ufm4cNUvxsONBmfPg8uZ9pZBdOmF6qFGr/HlT6ABBkCSx/dlEtvWg==}
engines: {node: '>=12 <14 || 14.2 - 14.9 || >14.10.0'}
'@zardoy/flying-squid@0.0.29':
resolution: {integrity: sha512-E5Nk1gMeH+fAHM5aJY8kIxjBS/zuPtPD6QPeZg+laPV5H58Jx3Et17clF1zC9MT2wyFQ5wi5uTnfdGBTpSEqHw==}
'@zardoy/flying-squid@0.0.32':
resolution: {integrity: sha512-Ifj8XrnsE3j3+lCeyUQ426LzsOzU/Z+qKG+aZNf90VstBhCvjmVAOmG7J5N74ivvujx+x6eXCgjjw6gcd/XKNQ==}
engines: {node: '>=8'}
hasBin: true
@ -6750,6 +6750,11 @@ packages:
version: 1.35.0
engines: {node: '>=14'}
prismarine-chunk@https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/cea0b6c792d7dcbb69dfd20fa48be5fd60ce83ef:
resolution: {tarball: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/cea0b6c792d7dcbb69dfd20fa48be5fd60ce83ef}
version: 1.35.0
engines: {node: '>=14'}
prismarine-entity@2.3.1:
resolution: {integrity: sha512-HOv8l7IetHNf4hwZ7V/W4vM3GNl+e6VCtKDkH9h02TRq7jWngsggKtJV+VanCce/sNwtJUhJDjORGs728ep4MA==}
@ -11974,7 +11979,7 @@ snapshots:
'@types/emscripten': 1.39.8
tslib: 1.14.1
'@zardoy/flying-squid@0.0.29(encoding@0.1.13)':
'@zardoy/flying-squid@0.0.32(encoding@0.1.13)':
dependencies:
'@tootallnate/once': 2.0.0
change-case: 4.1.2
@ -11989,7 +11994,7 @@ snapshots:
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/9662306deea57d8d0ba0a2a3f3f7adb95f0131e3(minecraft-data@3.65.0)
prismarine-chunk: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/cea0b6c792d7dcbb69dfd20fa48be5fd60ce83ef(minecraft-data@3.65.0)
prismarine-entity: 2.3.1
prismarine-item: 1.14.0
prismarine-nbt: 2.5.0
@ -12000,6 +12005,7 @@ snapshots:
random-seed: 0.3.0
range: 0.0.3
readline: 1.3.0
sanitize-filename: 1.6.3
typed-emitter: 1.4.0
uuid-1345: 1.0.2
vec3: 0.1.8
@ -13263,7 +13269,7 @@ snapshots:
diamond-square@https://codeload.github.com/zardoy/diamond-square/tar.gz/4bbe28dcad35403abaa925055e91f601a61b9015:
dependencies:
minecraft-data: 3.65.0
prismarine-chunk: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/9662306deea57d8d0ba0a2a3f3f7adb95f0131e3(minecraft-data@3.65.0)
prismarine-chunk: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/cea0b6c792d7dcbb69dfd20fa48be5fd60ce83ef(minecraft-data@3.65.0)
prismarine-registry: 1.7.0
random-seed: 0.3.0
vec3: 0.1.8
@ -16592,6 +16598,19 @@ snapshots:
transitivePeerDependencies:
- minecraft-data
prismarine-chunk@https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/cea0b6c792d7dcbb69dfd20fa48be5fd60ce83ef(minecraft-data@3.65.0):
dependencies:
prismarine-biome: 1.3.0(minecraft-data@3.65.0)(prismarine-registry@1.7.0)
prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/dd4954fff3b334f8ce063d18e39b2e9414ece5b8
prismarine-nbt: 2.5.0
prismarine-registry: 1.7.0
smart-buffer: 4.2.0
uint4: 0.1.2
vec3: 0.1.8
xxhash-wasm: 0.4.2
transitivePeerDependencies:
- minecraft-data
prismarine-entity@2.3.1:
dependencies:
prismarine-chat: 1.10.1

View file

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

View file

@ -17,6 +17,8 @@ export class WorldRendererThree extends WorldRendererCommon {
signsCache = new Map<string, any>()
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) {

73
src/benchmark.ts Normal file
View file

@ -0,0 +1,73 @@
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
}
}
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()
}
})
}

7
src/benchmarkAdapter.ts Normal file
View file

@ -0,0 +1,7 @@
export interface BenchmarkAdapter {
worldLoadTime: number
averageRenderTime: number
worstRenderTime: number
memoryUsageAverage: number
memoryUsageWorst: number
}

View file

@ -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<ConnectOptions>) => {
await new Promise<void>(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
}

View file

@ -1,15 +1,24 @@
export type ConnectOptions = {
server?: string;
singleplayer?: any;
username: string;
password?: any;
proxy?: any;
botVersion?: any;
serverOverrides?;
serverOverridesFlat?;
peerId?: string;
ignoreQs?: boolean;
server?: string
singleplayer?: any
username: string
password?: any
proxy?: any
botVersion?: any
serverOverrides?
serverOverridesFlat?
peerId?: string
ignoreQs?: boolean
onSuccessfulPlay?: () => void
autoLoginPassword?: string
serverIndex?: string
connectEvents?: {
serverCreated?: () => void
// connect: () => void;
// disconnect: () => void;
// error: (err: any) => void;
// ready: () => void;
// end: () => void;
}
}

View file

@ -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<ConnectOptions>) => {
// 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

View file

@ -42,6 +42,7 @@ import debug from 'debug'
import { defaultsDeep } from 'lodash-es'
import { initVR } from './vr'
import { registerOpenBenchmarkListener } from './benchmark'
import {
AppConfig,
activeModalStack,
@ -218,8 +219,14 @@ function hideCurrentScreens () {
insertActiveModalStack('', [])
}
const loadSingleplayer = (serverOverrides = {}, flattenedServerOverrides = {}) => {
void connect({ singleplayer: true, username: options.localUsername, password: '', serverOverrides, serverOverridesFlat: flattenedServerOverrides })
const loadSingleplayer = (serverOverrides = {}, flattenedServerOverrides = {}, otherOptions: Partial<ConnectOptions> = {}) => {
void connect({
singleplayer: true,
username: options.localUsername,
serverOverrides,
serverOverridesFlat: flattenedServerOverrides,
...otherOptions
})
}
function listenGlobalEvents () {
window.addEventListener('connect', e => {
@ -227,7 +234,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)
})
}
@ -406,6 +415,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
@ -578,6 +588,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!)
@ -805,7 +816,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'))
})
@ -944,7 +957,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
@ -956,3 +969,4 @@ if (initialLoader) {
window.pageLoaded = true
void possiblyHandleStateVariable()
registerOpenBenchmarkListener()

View file

@ -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<string, any> }
}
export const loadSave = async (root = '/world') => {
export const loadSave = async (root = '/world', connectOptions?: Partial<ConnectOptions>) => {
// todo test
if (miscUiState.gameLoaded) {
await disconnect()
@ -189,7 +190,8 @@ export const loadSave = async (root = '/world') => {
} : {},
...root === '/world' ? {} : {
'worldFolder': root
}
},
connectOptions
},
}))
}