diff --git a/.eslintrc.json b/.eslintrc.json index 8b2225b56..9add49ea9 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -10,6 +10,7 @@ "@stylistic" ], "rules": { + "unicorn/no-typeof-undefined": "off", // style "@stylistic/space-infix-ops": "error", "@stylistic/no-multi-spaces": "error", diff --git a/.vscode/launch.json b/.vscode/launch.json index dec881630..e15432385 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -35,7 +35,25 @@ "outFiles": [ "${workspaceFolder}/dist/**/*.js", // "!${workspaceFolder}/dist/**/*vendors*", - "!${workspaceFolder}/dist/**/*mc-data*", + "!**/node_modules/**" + ], + "skipFiles": [ + // "/**/*vendors*" + "/**/*mc-data*" + ], + }, + { + // not recommended as in most cases it will slower as it launches from extension host so it slows down extension host, not sure why + "type": "chrome", + "name": "Launch Chrome (playground)", + "request": "launch", + "url": "http://localhost:9090/", + "pathMapping": { + "/": "${workspaceFolder}/prismarine-viewer/dist" + }, + "outFiles": [ + "${workspaceFolder}/prismarine-viewer/dist/**/*.js", + // "!${workspaceFolder}/dist/**/*vendors*", "!**/node_modules/**" ], "skipFiles": [ diff --git a/.vscode/tasks.json b/.vscode/tasks.json index ca74a57d3..6c66309eb 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -25,10 +25,20 @@ }, }, { - "label": "viewer server+esbuild", + "label": "webgl-worker", + "type": "shell", + "command": "node buildWorkers.mjs -w", + "problemMatcher": "$esbuild-watch", + "presentation": { + "reveal": "silent" + }, + }, + { + "label": "viewer server+esbuild+workers", "dependsOn": [ "viewer-server", - "viewer-esbuild" + "viewer-esbuild", + "webgl-worker" ], "dependsOrder": "parallel", } diff --git a/buildWorkers.mjs b/buildWorkers.mjs new file mode 100644 index 000000000..85885b286 --- /dev/null +++ b/buildWorkers.mjs @@ -0,0 +1,275 @@ +//@ts-check +// main worker file intended for computing world geometry is built using prismarine-viewer/buildWorker.mjs +import { build, context } from 'esbuild' +import fs from 'fs' +import path, { join } from 'path' +import { polyfillNode } from 'esbuild-plugin-polyfill-node' +import { mesherSharedPlugins } from './scripts/esbuildPlugins.mjs' +import { fileURLToPath } from 'url' +import { dynamicMcDataFiles } from './src/integratedServer/workerMcData.mjs' + +const watch = process.argv.includes('-w') + +const sharedAliases = { + 'three': './node_modules/three/src/Three.js', + events: 'events', // make explicit + buffer: 'buffer', + 'fs': './src/shims/fs.js', + http: './src/shims/empty.ts', + perf_hooks: './src/shims/perf_hooks_replacement.js', + crypto: './src/shims/crypto.js', + stream: 'stream-browserify', + net: './src/shims/empty.ts', + assert: 'assert', + dns: './src/shims/empty.ts', + '@azure/msal-node': './src/shims/empty.ts', +} + +const result = await (watch ? context : build)({ + bundle: true, + platform: 'browser', + // entryPoints: ['prismarine-viewer/examples/webgpuRendererWorker.ts', 'src/worldSaveWorker.ts'], + entryPoints: ['prismarine-viewer/examples/webgpuRendererWorker.ts'], + outdir: 'prismarine-viewer/dist/', + sourcemap: watch ? 'inline' : 'external', + minify: !watch, + treeShaking: true, + logLevel: 'info', + alias: sharedAliases, + plugins: [ + { + name: 'writeOutput', + setup (build) { + build.onEnd(({ outputFiles }) => { + fs.mkdirSync('prismarine-viewer/public', { recursive: true }) + fs.mkdirSync('dist', { recursive: true }) + for (const file of outputFiles) { + for (const dir of ['prismarine-viewer/dist', 'dist']) { + const baseName = path.basename(file.path) + fs.mkdirSync(dir, { recursive: true }) + fs.writeFileSync(path.join(dir, baseName), file.contents) + } + } + }) + } + }, + { + name: 'fix-dynamic-require', + setup (build) { + build.onResolve({ + filter: /1\.14\/chunk/, + }, async ({ resolveDir, path }) => { + if (!resolveDir.includes('prismarine-provider-anvil')) return + return { + namespace: 'fix-dynamic-require', + path, + pluginData: { + resolvedPath: `${join(resolveDir, path)}.js`, + resolveDir + }, + } + }) + build.onLoad({ + filter: /.+/, + namespace: 'fix-dynamic-require', + }, async ({ pluginData: { resolvedPath, resolveDir } }) => { + const resolvedFile = await fs.promises.readFile(resolvedPath, 'utf8') + return { + contents: resolvedFile.replace("require(`prismarine-chunk/src/pc/common/BitArray${noSpan ? 'NoSpan' : ''}`)", "noSpan ? require(`prismarine-chunk/src/pc/common/BitArray`) : require(`prismarine-chunk/src/pc/common/BitArrayNoSpan`)"), + resolveDir, + loader: 'js', + } + }) + } + }, + polyfillNode({ + polyfills: { + fs: false, + dns: false, + crypto: false, + events: false, + http: false, + stream: false, + buffer: false, + perf_hooks: false, + net: false, + assert: false, + }, + }) + ], + loader: { + '.vert': 'text', + '.frag': 'text', + '.wgsl': 'text', + }, + mainFields: [ + 'browser', 'module', 'main' + ], + keepNames: true, + write: false, +}) + +if (watch) { + //@ts-ignore + await result.watch() +} + +const allowedBundleFiles = ['legacy', 'versions', 'protocolVersions', 'features'] + +const __dirname = path.dirname(fileURLToPath(new URL(import.meta.url))) + + +/** @type {import('esbuild').BuildOptions} */ +const integratedServerBuildOptions = { + bundle: true, + banner: { + js: `globalThis.global = globalThis;process = {env: {}, versions: {} };`, + }, + platform: 'browser', + entryPoints: [path.join(__dirname, './src/integratedServer/worker.ts')], + minify: !watch, + logLevel: 'info', + drop: !watch ? [ + 'debugger' + ] : [], + sourcemap: 'linked', + // write: false, + // metafile: true, + // outdir: path.join(__dirname, './dist'), + outfile: './dist/integratedServer.js', + define: { + 'process.env.BROWSER': '"true"', + 'process.versions.node': '"50.0.0"', + }, + alias: sharedAliases, + plugins: [ + ...mesherSharedPlugins, + { + name: 'custom-plugins', + setup (build) { + build.onResolve({ filter: /\.json$/ }, args => { + const fileName = args.path.split('/').pop().replace('.json', '') + if (args.resolveDir.includes('minecraft-data')) { + if (args.path.replaceAll('\\', '/').endsWith('bedrock/common/protocolVersions.json')) { + return + } + if (args.path.includes('bedrock')) { + return { path: args.path, namespace: 'empty-file', } + } + if (dynamicMcDataFiles.includes(fileName)) { + return { + path: args.path, + namespace: 'mc-data', + } + } + if (!allowedBundleFiles.includes(fileName)) { + return { path: args.path, namespace: 'empty-file', } + } + } + }) + build.onResolve({ + filter: /external/, + }, ({ path, importer }) => { + importer = importer.split('\\').join('/') + if (importer.endsWith('flying-squid/dist/lib/modules/index.js')) { + return { + path, + namespace: 'empty-file-object', + } + } + }) + build.onLoad({ + filter: /.*/, + namespace: 'empty-file', + }, () => { + return { contents: 'module.exports = undefined', loader: 'js' } + }) + build.onLoad({ + filter: /.*/, + namespace: 'empty-file-object', + }, () => { + return { contents: 'module.exports = {}', loader: 'js' } + }) + build.onLoad({ + namespace: 'mc-data', + filter: /.*/, + }, async ({ path }) => { + const fileName = path.split(/[\\\/]/).pop().replace('.json', '') + return { + contents: `module.exports = globalThis.mcData["${fileName}"]`, + loader: 'js', + resolveDir: process.cwd(), + } + }) + build.onResolve({ + filter: /^esbuild-data$/, + }, () => { + return { + path: 'esbuild-data', + namespace: 'esbuild-data', + } + }) + build.onLoad({ + filter: /.*/, + namespace: 'esbuild-data', + }, () => { + const data = { + // todo always use latest + tints: 'require("minecraft-data/minecraft-data/data/pc/1.16.2/tints.json")' + } + return { + contents: `module.exports = {${Object.entries(data).map(([key, code]) => `${key}: ${code}`).join(', ')}}`, + loader: 'js', + resolveDir: process.cwd(), + } + }) + + build.onResolve({ + filter: /minecraft-protocol$/, + }, async (args) => { + return { + ...await build.resolve('minecraft-protocol/src/index.js', { kind: args.kind, importer: args.importer, resolveDir: args.resolveDir }), + } + }) + + // build.onEnd(({ metafile, outputFiles }) => { + // if (!metafile) return + // fs.mkdirSync(path.join(__dirname, './dist'), { recursive: true }) + // fs.writeFileSync(path.join(__dirname, './dist/metafile.json'), JSON.stringify(metafile)) + // for (const outDir of ['../dist/', './dist/']) { + // for (const outputFile of outputFiles) { + // if (outDir === '../dist/' && outputFile.path.endsWith('.map')) { + // // skip writing & browser loading sourcemap there, worker debugging should be done in playground + // // continue + // } + // const writePath = path.join(__dirname, outDir, path.basename(outputFile.path)) + // fs.mkdirSync(path.dirname(writePath), { recursive: true }) + // fs.writeFileSync(writePath, outputFile.text) + // } + // } + // }) + } + }, + polyfillNode({ + polyfills: { + fs: false, + dns: false, + crypto: false, + events: false, + http: false, + stream: false, + buffer: false, + perf_hooks: false, + net: false, + assert: false, + }, + }), + ], +} + +if (watch) { + const ctx = await context(integratedServerBuildOptions) + await ctx.watch() +} else { + await build(integratedServerBuildOptions) +} diff --git a/config.json b/config.json index 7813b5911..c96a707f5 100644 --- a/config.json +++ b/config.json @@ -2,7 +2,7 @@ "version": 1, "defaultHost": "", "defaultProxy": "proxy.mcraft.fun", - "mapsProvider": "https://maps.mcraft.fun/", + "mapsProvider": "https://maps.mcraft.fun/?label=webgpu", "peerJsServer": "", "peerJsServerFallback": "https://p2p.mcraft.fun", "promoteServers": [ diff --git a/cypress/e2e/performance.spec.ts b/cypress/e2e/performance.spec.ts new file mode 100644 index 000000000..f2fc4d46e --- /dev/null +++ b/cypress/e2e/performance.spec.ts @@ -0,0 +1,25 @@ +import { cleanVisit, setOptions } from './shared' + +it('Loads & renders singleplayer', () => { + cleanVisit('/?singleplayer=1') + setOptions({ + renderDistance: 2 + }) + // wait for .initial-loader to disappear + cy.get('.initial-loader', { timeout: 20_000 }).should('not.exist') + cy.window() + .its('performance') + .invoke('mark', 'worldLoad') + + cy.document().then({ timeout: 20_000 }, doc => { + return new Cypress.Promise(resolve => { + doc.addEventListener('cypress-world-ready', resolve) + }) + }).then(() => { + const duration = cy.window() + .its('performance') + .invoke('measure', 'modalOpen') + .its('duration') + cy.log('Duration', duration) + }) +}) diff --git a/package.json b/package.json index 4ed5a4494..b6ea1f0a6 100644 --- a/package.json +++ b/package.json @@ -5,12 +5,13 @@ "scripts": { "dev-rsbuild": "rsbuild dev", "dev-proxy": "node server.js", - "start": "run-p dev-proxy dev-rsbuild watch-mesher", - "start2": "run-p dev-rsbuild watch-mesher", - "build": "pnpm build-other-workers && rsbuild build", + "start": "run-p dev-rsbuild dev-proxy watch-mesher watch-other-workers", + "start2": "run-p dev-rsbuild watch-mesher watch-other-workers", + "build": "rsbuild build && pnpm build-other-workers", "build-analyze": "BUNDLE_ANALYZE=true rsbuild build && pnpm build-other-workers", "check-build": "tsx scripts/genShims.ts && tsc && pnpm build", "test:cypress": "cypress run", + "test:cypress:perf": "cypress run --spec cypress/e2e/perf.spec.ts --browser edge", "test-unit": "vitest", "test:e2e": "start-test http-get://localhost:8080 test:cypress", "prod-start": "node server.js --prod", @@ -19,12 +20,13 @@ "storybook": "storybook dev -p 6006", "build-storybook": "storybook build && node scripts/build.js moveStorybookFiles", "start-experiments": "vite --config experiments/vite.config.ts --host", - "watch-other-workers": "echo NOT IMPLEMENTED", - "build-other-workers": "echo NOT IMPLEMENTED", + "watch-other-workers": "node buildWorkers.mjs -w", + "build-other-workers": "node buildWorkers.mjs", "build-mesher": "node prismarine-viewer/buildMesherWorker.mjs", "watch-mesher": "pnpm build-mesher -w", "run-playground": "run-p watch-mesher watch-other-workers watch-playground", "run-all": "run-p start run-playground", + "run-all2": "run-p start2 run-playground", "build-playground": "rsbuild build --config prismarine-viewer/rsbuild.config.ts", "watch-playground": "rsbuild dev --config prismarine-viewer/rsbuild.config.ts" }, @@ -47,11 +49,13 @@ "@nxg-org/mineflayer-auto-jump": "^0.7.12", "@nxg-org/mineflayer-tracker": "1.2.1", "@react-oauth/google": "^0.12.1", + "@rsbuild/plugin-basic-ssl": "^1.1.1", "@stylistic/eslint-plugin": "^2.6.1", "@types/gapi": "^0.0.47", "@types/react": "^18.2.20", "@types/react-dom": "^18.2.7", "@types/wicg-file-system-access": "^2023.10.2", + "@webgpu/types": "^0.1.44", "@xmcl/text-component": "^2.1.3", "@zardoy/react-util": "^0.2.4", "@zardoy/utils": "^0.0.11", @@ -102,6 +106,7 @@ "stats.js": "^0.17.0", "tabbable": "^6.2.0", "title-case": "3.x", + "twgl.js": "^5.5.4", "ua-parser-js": "^1.0.37", "use-typed-event-listener": "^4.0.2", "valtio": "^1.11.1", @@ -142,7 +147,7 @@ "http-browserify": "^1.7.0", "http-server": "^14.1.1", "https-browserify": "^1.0.0", - "mc-assets": "^0.2.23", + "mc-assets": "^0.2.25", "minecraft-inventory-gui": "github:zardoy/minecraft-inventory-gui#next", "mineflayer": "github:zardoy/mineflayer", "mineflayer-pathfinder": "^2.4.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a89eb6df..bd8a88d21 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,6 +55,9 @@ importers: '@react-oauth/google': specifier: ^0.12.1 version: 0.12.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@rsbuild/plugin-basic-ssl': + specifier: ^1.1.1 + version: 1.1.1(@rsbuild/core@1.0.1-beta.9) '@stylistic/eslint-plugin': specifier: ^2.6.1 version: 2.6.1(eslint@8.50.0)(typescript@5.5.4) @@ -70,6 +73,9 @@ importers: '@types/wicg-file-system-access': specifier: ^2023.10.2 version: 2023.10.2 + '@webgpu/types': + specifier: ^0.1.44 + version: 0.1.49 '@xmcl/text-component': specifier: ^2.1.3 version: 2.1.3 @@ -220,6 +226,9 @@ importers: title-case: specifier: 3.x version: 3.0.3 + twgl.js: + specifier: ^5.5.4 + version: 5.5.4 ua-parser-js: specifier: ^1.0.37 version: 1.0.37 @@ -346,14 +355,14 @@ importers: specifier: ^1.0.0 version: 1.0.0 mc-assets: - specifier: ^0.2.23 - version: 0.2.23 + specifier: ^0.2.25 + version: 0.2.25 minecraft-inventory-gui: specifier: github:zardoy/minecraft-inventory-gui#next version: https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/75e940a4cd50d89e0ba03db3733d5d704917a3c8(@types/react@18.2.20)(react@18.2.0) mineflayer: specifier: github:zardoy/mineflayer - version: https://codeload.github.com/zardoy/mineflayer/tar.gz/390ce12c1e1f25e440a94ba422e45c874f8bbd2b(encoding@0.1.13) + version: https://codeload.github.com/zardoy/mineflayer/tar.gz/fd2280a5ed1eff5078a6224e0c6712ba0d5afc0d(encoding@0.1.13) mineflayer-pathfinder: specifier: ^2.4.4 version: 2.4.4 @@ -420,6 +429,9 @@ importers: lil-gui: specifier: ^0.18.2 version: 0.18.2 + live-server: + specifier: ^1.2.2 + version: 1.2.2 minecraft-wrap: specifier: ^1.3.0 version: 1.5.1(encoding@0.1.13) @@ -466,10 +478,6 @@ importers: node-canvas-webgl: specifier: ^0.3.0 version: 0.3.0(encoding@0.1.13) - devDependencies: - live-server: - specifier: ^1.2.2 - version: 1.2.2 prismarine-viewer/viewer/sign-renderer: dependencies: @@ -2493,6 +2501,14 @@ packages: engines: {node: '>=16.7.0'} hasBin: true + '@rsbuild/plugin-basic-ssl@1.1.1': + resolution: {integrity: sha512-q4u7H8yh/S/DHwxG85bWbGXFiVV9RMDJDupOBHJVPtevU9mLCB4n5Qbrxu/l8CCdmZcBlvfWGjkDA/YoY61dig==} + peerDependencies: + '@rsbuild/core': 0.x || 1.x || ^1.0.1-beta.0 + peerDependenciesMeta: + '@rsbuild/core': + optional: true + '@rsbuild/plugin-node-polyfill@1.0.3': resolution: {integrity: sha512-AoPIOV1pyInIz08K1ECwUjFemLLSa5OUq8sfJN1ShXrGR2qc14b1wzwZKwF4vgKnBromqfMLagVbk6KT/nLIvQ==} peerDependencies: @@ -3044,6 +3060,9 @@ packages: '@types/node-fetch@2.6.6': resolution: {integrity: sha512-95X8guJYhfqiuVVhRFxVQcf4hW/2bCuoPwDasMf/531STFoNoWTT7YDnWdXHEZKqAGUigmpG31r2FE70LwnzJw==} + '@types/node-forge@1.3.11': + resolution: {integrity: sha512-FQx220y22OKNTqaByeBGqHWYz4cl94tpcxeFdvBo3wjG6XPBuZ0BNgNZRV5J5TFmmcsJ4IzsLkmGRiQbnYsBEQ==} + '@types/node-rsa@1.1.4': resolution: {integrity: sha512-dB0ECel6JpMnq5ULvpUTunx3yNm8e/dIkv8Zu9p2c8me70xIRUUG3q+qXRwcSf9rN3oqamv4116iHy90dJGRpA==} @@ -3339,6 +3358,9 @@ packages: '@webassemblyjs/wast-printer@1.12.1': resolution: {integrity: sha512-+X4WAlOisVWQMikjbcvY2e0rwPsKQ9F688lksZhBcPycBBuii3O7m8FACbDMWDojpAqvjIncrG8J0XHKyQfVeA==} + '@webgpu/types@0.1.49': + resolution: {integrity: sha512-NMmS8/DofhH/IFeW+876XrHVWel+J/vdcFCHLDqeJgkH9x0DeiwjVd8LcBdaxdG/T7Rf8VUAYsA8X1efMzLjRQ==} + '@xboxreplay/errors@0.1.0': resolution: {integrity: sha512-Tgz1d/OIPDWPeyOvuL5+aai5VCcqObhPnlI3skQuf80GVF3k1I0lPCnGC+8Cm5PV9aLBT5m8qPcJoIUQ2U4y9g==} @@ -6582,8 +6604,8 @@ packages: peerDependencies: react: ^18.2.0 - mc-assets@0.2.23: - resolution: {integrity: sha512-sLbPhsSOYdW8nYllIyPZbVPnLu7V3bZTgIO4mI4nlG525q17NIbUNEjItHKtdi60u0vI6qLgHKjf0CoNRqa/Nw==} + mc-assets@0.2.25: + resolution: {integrity: sha512-MdtncPBC6kwIkYXsBsSEJGP+q2e+7Q4Wnb4j3FjS7gmafz50Vjp4E/S3MsM7H8R3FoDrjVIx6qR24l/rneW/Lw==} engines: {node: '>=18.0.0'} md5-file@4.0.0: @@ -6805,8 +6827,8 @@ packages: resolution: {integrity: sha512-wSchhS59hK+oPs8tFg847H82YEvxU7zYKdDKj4e5FVo3CxJ74eXJVT+JcFwEvoqFO7kXiQlhJITxEvO13GOSKA==} engines: {node: '>=18'} - mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/390ce12c1e1f25e440a94ba422e45c874f8bbd2b: - resolution: {tarball: https://codeload.github.com/zardoy/mineflayer/tar.gz/390ce12c1e1f25e440a94ba422e45c874f8bbd2b} + mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/fd2280a5ed1eff5078a6224e0c6712ba0d5afc0d: + resolution: {tarball: https://codeload.github.com/zardoy/mineflayer/tar.gz/fd2280a5ed1eff5078a6224e0c6712ba0d5afc0d} version: 4.23.0 engines: {node: '>=18'} @@ -7010,6 +7032,10 @@ packages: encoding: optional: true + node-forge@1.3.1: + resolution: {integrity: sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==} + engines: {node: '>= 6.13.0'} + node-gyp-build-optional-packages@5.1.1: resolution: {integrity: sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw==} hasBin: true @@ -8245,6 +8271,10 @@ packages: secure-compare@3.0.1: resolution: {integrity: sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==} + selfsigned@2.4.1: + resolution: {integrity: sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==} + engines: {node: '>=10'} + semver@5.7.2: resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} hasBin: true @@ -8951,6 +8981,9 @@ packages: tweetnacl@0.14.5: resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} + twgl.js@5.5.4: + resolution: {integrity: sha512-6kFOmijOpmblTN9CCwOTCxK4lPg7rCyQjLuub6EMOlEp89Ex6yUcsMjsmH7andNPL2NE3XmHdqHeP5gVKKPhxw==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -11960,6 +11993,12 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + '@rsbuild/plugin-basic-ssl@1.1.1(@rsbuild/core@1.0.1-beta.9)': + dependencies: + selfsigned: 2.4.1 + optionalDependencies: + '@rsbuild/core': 1.0.1-beta.9 + '@rsbuild/plugin-node-polyfill@1.0.3(@rsbuild/core@1.0.1-beta.9)': dependencies: assert: 2.1.0 @@ -12966,6 +13005,10 @@ snapshots: '@types/node': 22.8.1 form-data: 4.0.0 + '@types/node-forge@1.3.11': + dependencies: + '@types/node': 22.8.1 + '@types/node-rsa@1.1.4': dependencies: '@types/node': 22.8.1 @@ -13019,7 +13062,7 @@ snapshots: '@types/readable-stream@4.0.12': dependencies: - '@types/node': 20.12.8 + '@types/node': 22.8.1 safe-buffer: 5.1.2 '@types/resolve@1.17.1': @@ -13363,6 +13406,8 @@ snapshots: '@webassemblyjs/ast': 1.12.1 '@xtuc/long': 4.2.2 + '@webgpu/types@0.1.49': {} + '@xboxreplay/errors@0.1.0': {} '@xboxreplay/xboxlive-auth@3.3.3(debug@4.3.4)': @@ -17453,7 +17498,7 @@ snapshots: dependencies: react: 18.2.0 - mc-assets@0.2.23: {} + mc-assets@0.2.25: {} md5-file@4.0.0: {} @@ -17754,7 +17799,7 @@ snapshots: '@types/readable-stream': 4.0.12 aes-js: 3.1.2 buffer-equal: 1.0.1 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.7 endian-toggle: 0.0.0 lodash.get: 4.4.2 lodash.merge: 4.6.2 @@ -17838,7 +17883,7 @@ snapshots: - encoding - supports-color - mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/390ce12c1e1f25e440a94ba422e45c874f8bbd2b(encoding@0.1.13): + mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/fd2280a5ed1eff5078a6224e0c6712ba0d5afc0d(encoding@0.1.13): dependencies: minecraft-data: 3.80.0 minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/f258c76b3a15badd902e82cd892168849444d79d(patch_hash=7sh5krubuk2vjuogjioaktvwzi)(encoding@0.1.13) @@ -18086,6 +18131,8 @@ snapshots: optionalDependencies: encoding: 0.1.13 + node-forge@1.3.1: {} + node-gyp-build-optional-packages@5.1.1: dependencies: detect-libc: 2.0.2 @@ -19571,6 +19618,11 @@ snapshots: secure-compare@3.0.1: {} + selfsigned@2.4.1: + dependencies: + '@types/node-forge': 1.3.11 + node-forge: 1.3.1 + semver@5.7.2: {} semver@6.3.1: {} @@ -20478,6 +20530,8 @@ snapshots: tweetnacl@0.14.5: optional: true + twgl.js@5.5.4: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 diff --git a/prismarine-viewer/buildMesherWorker.mjs b/prismarine-viewer/buildMesherWorker.mjs index 03b952b4f..57753a07a 100644 --- a/prismarine-viewer/buildMesherWorker.mjs +++ b/prismarine-viewer/buildMesherWorker.mjs @@ -22,7 +22,7 @@ const buildOptions = { }, platform: 'browser', entryPoints: [path.join(__dirname, './viewer/lib/mesher/mesher.ts')], - minify: true, + minify: !watch, logLevel: 'info', drop: !watch ? [ 'debugger' @@ -38,7 +38,7 @@ const buildOptions = { ...mesherSharedPlugins, { name: 'external-json', - setup (build) { + setup(build) { build.onResolve({ filter: /\.json$/ }, args => { const fileName = args.path.split('/').pop().replace('.json', '') if (args.resolveDir.includes('minecraft-data')) { diff --git a/prismarine-viewer/examples/Cube.comp.wgsl b/prismarine-viewer/examples/Cube.comp.wgsl new file mode 100644 index 000000000..230e274fe --- /dev/null +++ b/prismarine-viewer/examples/Cube.comp.wgsl @@ -0,0 +1,87 @@ +struct Cube { + cube: array +} + +struct Chunk { + x: i32, + z: i32, + opacity: i32, + offset: i32, + length: i32 +} + + +struct Depth { + locks: array, 4096>, 4096> +} + +struct Uniforms { + textureSize: vec2 +} + +struct CameraPosition { + position: vec3, +} + +@group(0) @binding(0) var ViewProjectionMatrix: mat4x4; +@group(1) @binding(0) var chunks: array; +@group(0) @binding(1) var cubes: array; +@group(1) @binding(1) var occlusion : Depth; +@group(1) @binding(2) var depthAtomic : Depth; +@group(2) @binding(0) var uniforms: Uniforms; +@group(1) @binding(3) var cameraPosition: CameraPosition; +@group(0) @binding(5) var depthTexture: texture_depth_2d; +@group(0) @binding(6) var rejectZ: u32; + +fn linearize_depth_ndc(ndc_z: f32, z_near: f32, z_far: f32) -> f32 { + return z_near * z_far / (z_far - ndc_z * (z_far - z_near)); +} + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) global_id: vec3) { + let index = global_id.x; + if (index >= arrayLength(&chunks)) { + return; + } + + let chunk = chunks[index]; + let chunkPosition = vec4(f32(chunk.x * 16), 0.0, f32(chunk.z * 16), 0.0); + for (var i = chunk.offset; i < chunk.offset + chunk.length; i++) { + let cube = cubes[i]; + let positionX: f32 = f32(cube.cube[0] & 15) + 0.5; //4 bytes + let positionY: f32 = f32((cube.cube[0] >> 4) & 1023); //10 bytes + let positionZ: f32 = f32((cube.cube[0] >> 14) & 15) + 0.5; + let position = vec4f(positionX, positionY, positionZ, 1.0) + chunkPosition; + // Transform cube position to clip space + let clipPos = ViewProjectionMatrix * position; + let clipDepth = clipPos.z / clipPos.w; // Obtain depth in clip space + var clipX = clipPos.x / clipPos.w; + var clipY = clipPos.y / clipPos.w; + let textureSize = uniforms.textureSize; + + let clipped = 1 / clipPos.z * 2; + // Check if cube is within the view frustum z-range (depth within near and far planes) + if ( + clipDepth <= -clipped || clipDepth > 1 || + clipX < - 1 - clipped || clipX > 1 + clipped || + clipY < - 1 - clipped || clipY > 1 + clipped) + { + continue; + } + + clipY = clamp(clipY, -1, 1); + clipX = clamp(clipX, -1, 1); + + var pos : vec2u = vec2u(u32((clipX * 0.5 + 0.5) * f32(textureSize.x)),u32((clipY * 0.5 + 0.5) * f32(textureSize.y))); + let k = linearize_depth_ndc(clipDepth, 0.05, 10000); + if (rejectZ == 1 && k - 20 > linearize_depth_ndc(textureLoad(depthTexture, vec2u(pos.x, textureSize.y - pos.y), 0), 0.05, 10000)) { + continue; + } + + let depth = u32((10000 - k) * 1000); + if (depth > atomicMax(&depthAtomic.locks[pos.x][pos.y], depth)) { + + atomicStore(&occlusion.locks[pos.x][pos.y], u32(i) + 1); + } + } +} diff --git a/prismarine-viewer/examples/Cube.frag.wgsl b/prismarine-viewer/examples/Cube.frag.wgsl new file mode 100644 index 000000000..d498b00b6 --- /dev/null +++ b/prismarine-viewer/examples/Cube.frag.wgsl @@ -0,0 +1,28 @@ +@group(0) @binding(1) var mySampler: sampler; +@group(0) @binding(2) var myTexture: texture_2d; +@group(0) @binding(5) var tileSize: vec2; + +@fragment +fn main( + @location(0) fragUV: vec2f, + @location(1) @interpolate(flat) TextureIndex: f32, + @location(2) @interpolate(flat) ColorBlend: vec3f, + @location(3) @interpolate(flat) ChunkOpacity: f32 +) -> @location(0) vec4f { + let textureSize: vec2 = vec2(textureDimensions(myTexture)); + let tilesPerTexture: vec2 = textureSize / tileSize; + let pixelColor = textureSample(myTexture, mySampler, fragUV / tilesPerTexture + vec2f(trunc(TextureIndex % tilesPerTexture.y), trunc(TextureIndex / tilesPerTexture.x)) / tilesPerTexture); + // return vec4f(pixelColor.rgb * ColorBlend / 255, pixelColor.a); // Set alpha to 1.0 for full opacity + return vec4f(pixelColor.rgb * ColorBlend / 255, 1.0 * ChunkOpacity); // Set alpha to 1.0 for full opacity +// only gray: +// let t = textureSample(myTexture, mySampler, fragUV / tilesPerTexture + vec2f(trunc(TextureIndex % tilesPerTexture.y), trunc(TextureIndex / tilesPerTexture.x)) / tilesPerTexture); +// // return vec4f(pixelColor.rgb * ColorBlend / 255, pixelColor.a); // Set alpha to 1.0 for full opacity + +// if (abs(t.x-t.y) <=0.03 || abs(t.x-t.z)<=0.03 ||abs(t.y-t.z) <=0.03) +// { +// return vec4f(t.rgb * ColorBlend / 255, 1.0); +// } +// else { +// return vec4f(t.rgb, 1.0); +// } +} diff --git a/prismarine-viewer/examples/Cube.vert.wgsl b/prismarine-viewer/examples/Cube.vert.wgsl new file mode 100644 index 000000000..5b3915039 --- /dev/null +++ b/prismarine-viewer/examples/Cube.vert.wgsl @@ -0,0 +1,104 @@ + +struct Cube { + cube : array +} + +struct Chunk{ + x : i32, + z : i32, + opacity: i32, + offset: i32, + length: i32 +} + + +struct CubePointer { + ptr: u32 +} + +struct CubeModel { + textureIndex123: u32, + textureIndex456: u32, +} + +struct VertexOutput { + @builtin(position) Position: vec4f, + @location(0) fragUV: vec2f, + @location(1) @interpolate(flat) TextureIndex: f32, + @location(2) @interpolate(flat) ColorBlend: vec3f, + @location(3) @interpolate(flat) ChunkOpacity: f32 +} +@group(1) @binding(0) var cubes: array; +@group(0) @binding(0) var ViewProjectionMatrix: mat4x4; +@group(0) @binding(3) var models: array; +@group(1) @binding(1) var visibleCubes: array; +@group(1) @binding(2) var chunks : array; +@group(0) @binding(4) var rotatations: array, 6>; + +@vertex +fn main( + @builtin(instance_index) instanceIndex: u32, + @location(0) position: vec4, + @location(1) uv: vec2 +) -> VertexOutput { + let normalIndex = visibleCubes[instanceIndex].ptr & 7; + let cube = cubes[visibleCubes[instanceIndex].ptr >> 3]; + //let chunkIndex = (cube.cube[1] >> 24) + ((cube.cube[0] >> 27) << 8); + let chunk = chunks[cube.cube[2]]; + + var positionX : f32 = f32(i32(cube.cube[0] & 15) + chunk.x * 16); //4 bytes + var positionY : f32 = f32((cube.cube[0] >> 4) & 1023); //10 bytes + var positionZ : f32 = f32(i32((cube.cube[0] >> 14) & 15) + chunk.z * 16); // 4 bytes + let modelIndex : u32 = ((cube.cube[0] >> 18) & 16383); ///14 bits + var textureIndex : u32; + + positionX += 0.5; + positionZ += 0.5; + positionY += 0.5; + + let cube_position = vec4f(positionX, positionY, positionZ, 0.0); + + let colorBlendR : f32 = f32(cube.cube[1] & 255); + let colorBlendG : f32 = f32((cube.cube[1] >> 8) & 255); + let colorBlendB : f32 = f32((cube.cube[1] >> 16) & 255); + let colorBlend = vec3f(colorBlendR, colorBlendG, colorBlendB); + + var normal : mat4x4; + var Uv = vec2(uv.x, (1.0 - uv.y)); + normal = rotatations[normalIndex]; + switch (normalIndex) { + case 0: + { + Uv = vec2((1.0f-uv.x), (1.0 - uv.y)); + textureIndex = models[modelIndex].textureIndex123 & 1023; + } + case 1: + { + textureIndex = (models[modelIndex].textureIndex123 >> 10) & 1023; + } + case 2: + { + textureIndex = (models[modelIndex].textureIndex123 >> 20) & 1023; + } + case 3: + { + textureIndex = models[modelIndex].textureIndex456 & 1023; + } + case 4: + { + textureIndex = (models[modelIndex].textureIndex456 >> 10) & 1023; + } + case 5, default: + { + textureIndex = (models[modelIndex].textureIndex456 >> 20) & 1023; + } + } + + var output: VertexOutput; + output.Position = ViewProjectionMatrix * (position * normal + cube_position); + output.fragUV = Uv; + output.ChunkOpacity = f32(chunk.opacity) / 255; + output.TextureIndex = f32(textureIndex); + output.ColorBlend = colorBlend; + return output; +} diff --git a/prismarine-viewer/examples/CubeDef.ts b/prismarine-viewer/examples/CubeDef.ts new file mode 100644 index 000000000..cf1777ad0 --- /dev/null +++ b/prismarine-viewer/examples/CubeDef.ts @@ -0,0 +1,62 @@ +export const cubeVertexSize = 4 * 5 // Byte size of one cube vertex. +export const PositionOffset = 0 +//export const cubeColorOffset = 4 * 3 // Byte offset of cube vertex color attribute. +export const UVOffset = 4 * 3 +export const cubeVertexCount = 36 + +//@ts-format-ignore-region +export const cubeVertexArray = new Float32Array([ + -0.5, -0.5, -0.5, 0, 0, // Bottom-let + 0.5, -0.5, -0.5, 1, 0, // bottom-right + 0.5, 0.5, -0.5, 1, 1, // top-right + 0.5, 0.5, -0.5, 1, 1, // top-right + -0.5, 0.5, -0.5, 0, 1, // top-let + -0.5, -0.5, -0.5, 0, 0, // bottom-let + // ront ace + -0.5, -0.5, 0.5, 0, 0, // bottom-let + 0.5, 0.5, 0.5, 1, 1, // top-right + 0.5, -0.5, 0.5, 1, 0, // bottom-right + 0.5, 0.5, 0.5, 1, 1, // top-right + -0.5, -0.5, 0.5, 0, 0, // bottom-let + -0.5, 0.5, 0.5, 0, 1, // top-let + // Let ace + -0.5, 0.5, 0.5, 1, 0, // top-right + -0.5, -0.5, -0.5, 0, 1, // bottom-let + -0.5, 0.5, -0.5, 1, 1, // top-let + -0.5, -0.5, -0.5, 0, 1, // bottom-let + -0.5, 0.5, 0.5, 1, 0, // top-right + -0.5, -0.5, 0.5, 0, 0, // bottom-right + // Right ace + 0.5, 0.5, 0.5, 1, 0, // top-let + 0.5, 0.5, -0.5, 1, 1, // top-right + 0.5, -0.5, -0.5, 0, 1, // bottom-right + 0.5, -0.5, -0.5, 0, 1, // bottom-right + 0.5, -0.5, 0.5, 0, 0, // bottom-let + 0.5, 0.5, 0.5, 1, 0, // top-let + // Bottom ace + -0.5, -0.5, -0.5, 0, 1, // top-right + 0.5, -0.5, 0.5, 1, 0, // bottom-let + 0.5, -0.5, -0.5, 1, 1, // top-let + 0.5, -0.5, 0.5, 1, 0, // bottom-let + -0.5, -0.5, -0.5, 0, 1, // top-right + -0.5, -0.5, 0.5, 0, 0, // bottom-right + // Top ace + -0.5, 0.5, -0.5, 0, 1, // top-let + 0.5, 0.5, -0.5, 1, 1, // top-right + 0.5, 0.5, 0.5, 1, 0, // bottom-right + 0.5, 0.5, 0.5, 1, 0, // bottom-right + -0.5, 0.5, 0.5, 0, 0, // bottom-let + -0.5, 0.5, -0.5, 0, 1// top-letĖš +]) + +//export const cubeColorOffset = 4 * 3 // Byte offset of cube vertex color attribute. +export const quadVertexCount = 6 + +export const quadVertexArray = new Float32Array([ + -0.5, -0.5, 0.5, 0, 0, // bottom-let + 0.5, 0.5, 0.5, 1, 1, // top-right + 0.5, -0.5, 0.5, 1, 0, // bottom-right + 0.5, 0.5, 0.5, 1, 1, // top-right + -0.5, -0.5, 0.5, 0, 0, // bottom-let + -0.5, 0.5, 0.5, 0, 1, // top-let +]) \ No newline at end of file diff --git a/prismarine-viewer/examples/CubeSort.comp.wgsl b/prismarine-viewer/examples/CubeSort.comp.wgsl new file mode 100644 index 000000000..9a0aa0af7 --- /dev/null +++ b/prismarine-viewer/examples/CubeSort.comp.wgsl @@ -0,0 +1,98 @@ +struct IndirectDrawParams { + vertexCount: u32, + instanceCount: atomic, + firstVertex: u32, + firstInstance: u32, +} + +struct CubePointer { + ptr: u32, +} + +struct Cube { + cube: array, +} + +struct Chunk { + x: i32, + z: i32, + opacity: i32, + offset: i32, + length: i32 +} + +struct Depth { + locks: array, 4096>, +} + +struct Uniforms { + textureSize: vec2, +} + +struct CameraPosition { + position: vec3, +} + +@group(1) @binding(1) var occlusion: Depth; +@group(1) @binding(2) var depthAtomic: Depth; +@group(0) @binding(2) var visibleCubes: array; +@group(0) @binding(3) var drawParams: IndirectDrawParams; +@group(0) @binding(1) var cubes: array; +@group(1) @binding(0) var chunks: array; +@group(2) @binding(0) var uniforms: Uniforms; +@group(1) @binding(3) var cameraPosition: CameraPosition; + +@compute @workgroup_size(16, 16) +fn main(@builtin(global_invocation_id) global_id: vec3) { + let position = global_id.xy; + let textureSize = uniforms.textureSize; + if (position.x >= textureSize.x || position.y >= textureSize.y) { + return; + } + + var occlusionData: u32 = occlusion.locks[position.x][position.y]; + + if (occlusionData != 0) { + var cube = cubes[occlusionData - 1]; + var visibleSides = (cube.cube[1] >> 24) & 63; + + let chunk = chunks[cube.cube[2]]; + var positionX: f32 = f32(i32(cube.cube[0] & 15) + chunk.x * 16); //4 bytes + let positionY: f32 = f32((cube.cube[0] >> 4) & 1023); //10 bytes + var positionZ: f32 = f32(i32((cube.cube[0] >> 14) & 15) + chunk.z * 16); + let isUpper : bool = positionY > cameraPosition.position.y; + let isLeftier : bool = positionX > cameraPosition.position.x; + let isDeeper : bool = positionZ > cameraPosition.position.z; + occlusionData = (occlusionData - 1) << 3; + + if ((visibleSides & 1) != 0 && !isUpper) { + let visibleIndex = atomicAdd(&drawParams.instanceCount, 1); + visibleCubes[visibleIndex].ptr = occlusionData; + } + + if (((visibleSides >> 1) & 1) != 0 && isUpper) { + let visibleIndex = atomicAdd(&drawParams.instanceCount, 1); + visibleCubes[visibleIndex].ptr = occlusionData | 1; + } + + if (((visibleSides >> 2) & 1) != 0 && !isDeeper) { + let visibleIndex = atomicAdd(&drawParams.instanceCount, 1); + visibleCubes[visibleIndex].ptr = occlusionData | 2; + } + + if (((visibleSides >> 3) & 1) != 0&& isDeeper) { + let visibleIndex = atomicAdd(&drawParams.instanceCount, 1); + visibleCubes[visibleIndex].ptr = occlusionData | 3; + } + + if (((visibleSides >> 4) & 1) != 0 && !isLeftier) { + let visibleIndex = atomicAdd(&drawParams.instanceCount, 1); + visibleCubes[visibleIndex].ptr = occlusionData | 4; + } + + if (((visibleSides >> 5) & 1) != 0 && isLeftier) { + let visibleIndex = atomicAdd(&drawParams.instanceCount, 1); + visibleCubes[visibleIndex].ptr = occlusionData | 5; + } + } +} diff --git a/prismarine-viewer/examples/TextureAnimation.ts b/prismarine-viewer/examples/TextureAnimation.ts new file mode 100644 index 000000000..6c5835437 --- /dev/null +++ b/prismarine-viewer/examples/TextureAnimation.ts @@ -0,0 +1,69 @@ +export type AnimationControlSwitches = { + tick: number + interpolationTick: number // next one +} + +type Data = { + interpolate: boolean; + frametime: number; + frames: Array<{ + index: number; + time: number; + } | number> | undefined; +} + +export class TextureAnimation { + data: Data + frameImages: number + frameDelta: number + frameTime: number + framesToSwitch: number + frameIndex: number + + constructor (public animationControl: AnimationControlSwitches, data: Data, public framesImages: number) { + this.data = { + interpolate: false, + frametime: 1, + ...data + } + this.frameImages = 1 + this.frameDelta = 0 + this.frameTime = this.data.frametime * 50 + this.frameIndex = 0 + + this.framesToSwitch = this.frameImages + if (this.data.frames) { + this.framesToSwitch = this.data.frames.length + } + } + + step (deltaMs: number) { + this.frameDelta += deltaMs + + if (this.frameDelta > this.frameTime) { + this.frameDelta -= this.frameTime + this.frameDelta %= this.frameTime + + this.frameIndex++ + this.frameIndex %= this.framesToSwitch + + const frames = this.data.frames.map(frame => (typeof frame === 'number' ? { index: frame, time: this.data.frametime } : frame)) + if (frames) { + const frame = frames[this.frameIndex] + const nextFrame = frames[(this.frameIndex + 1) % this.framesToSwitch] + + this.animationControl.tick = frame.index + this.animationControl.interpolationTick = nextFrame.index + this.frameTime = frame.time * 50 + } else { + this.animationControl.tick = this.frameIndex + this.animationControl.interpolationTick = (this.frameIndex + 1) % this.framesToSwitch + } + } + + if (this.data.interpolate) { + this.animationControl.interpolationTick = this.frameDelta / this.frameTime + } + } + +} diff --git a/prismarine-viewer/examples/TouchControls2.tsx b/prismarine-viewer/examples/TouchControls2.tsx new file mode 100644 index 000000000..bc5a7ebd0 --- /dev/null +++ b/prismarine-viewer/examples/TouchControls2.tsx @@ -0,0 +1,81 @@ +import React, { useEffect } from 'react' +import { LeftTouchArea, RightTouchArea, useInterfaceState } from '@dimaka/interface' +import { css } from '@emotion/css' +import { renderToDom } from '@zardoy/react-util' +import { Vec3 } from 'vec3' +import * as THREE from 'three' +import { Viewer } from '../viewer/lib/viewer' + +declare const viewer: Viewer +const Controls = () => { + // todo setting + const usingTouch = navigator.maxTouchPoints > 0 + + useEffect(() => { + window.addEventListener('touchstart', (e) => { + e.preventDefault() + }) + + const pressedKeys = new Set() + useInterfaceState.setState({ + isFlying: false, + uiCustomization: { + touchButtonSize: 40, + }, + updateCoord ([coord, state]) { + const vec3 = new Vec3(0, 0, 0) + vec3[coord] = state + let key: string | undefined + if (vec3.z < 0) key = 'KeyW' + if (vec3.z > 0) key = 'KeyS' + if (vec3.y > 0) key = 'Space' + if (vec3.y < 0) key = 'ShiftLeft' + if (vec3.x < 0) key = 'KeyA' + if (vec3.x > 0) key = 'KeyD' + if (key) { + if (!pressedKeys.has(key)) { + pressedKeys.add(key) + window.dispatchEvent(new KeyboardEvent('keydown', { code: key })) + } + } + for (const k of pressedKeys) { + if (k !== key) { + window.dispatchEvent(new KeyboardEvent('keyup', { code: k })) + pressedKeys.delete(k) + } + } + } + }) + }, []) + + if (!usingTouch) return null + return ( +
div { + pointer-events: auto; + } + `} + > + +
+ +
+ ) +} + +export const renderPlayground = () => { + renderToDom(, { + // selector: 'body', + }) +} diff --git a/prismarine-viewer/examples/baseScene.ts b/prismarine-viewer/examples/baseScene.ts index 1db68eb82..bbd89af78 100644 --- a/prismarine-viewer/examples/baseScene.ts +++ b/prismarine-viewer/examples/baseScene.ts @@ -18,15 +18,18 @@ import { Viewer } from '../viewer/lib/viewer' import { BlockNames } from '../../src/mcDataTypes' import { initWithRenderer, statsEnd, statsStart } from '../../src/topRightStats' import { getSyncWorld } from './shared' +import { defaultWebgpuRendererParams, rendererParamsGui } from './webgpuRendererShared' window.THREE = THREE export class BasePlaygroundScene { + webgpuRendererParams = false continuousRender = false guiParams = {} viewDistance = 0 targetPos = new Vec3(2, 90, 2) params = {} as Record + allParamsValuesInit = {} as Record paramOptions = {} as Partial rendererParamsGui[key])), + }) + + Object.assign(this.paramOptions, { + orbit: { + reloadOnChange: true, + }, + webgpuWorker: { + reloadOnChange: true, + }, + // ...Object.fromEntries(Object.entries(rendererParamsGui)) + }) + } + const qs = new URLSearchParams(window.location.search) - for (const key of Object.keys(this.params)) { + for (const key of qs.keys()) { const value = qs.get(key) if (!value) continue const parsed = /^-?\d+$/.test(value) ? Number(value) : value === 'true' ? true : value === 'false' ? false : value - this.params[key] = parsed + this.allParamsValuesInit[key] = parsed + } + for (const key of Object.keys(this.allParamsValuesInit)) { + if (this.params[key] === undefined) continue + this.params[key] = this.allParamsValuesInit[key] } for (const param of Object.keys(this.params)) { @@ -105,6 +129,18 @@ export class BasePlaygroundScene { } this.updateQs() }) + + if (this.webgpuRendererParams) { + for (const key of Object.keys(defaultWebgpuRendererParams)) { + // eslint-disable-next-line @typescript-eslint/no-loop-func + this.onParamUpdate[key] = () => { + viewer.world.updateRendererParams(this.params) + } + } + + this.enableCameraOrbitControl = this.params.orbit + viewer.world.updateRendererParams(this.params) + } } // mainChunk: import('prismarine-chunk/types/index').PCChunk @@ -121,19 +157,36 @@ export class BasePlaygroundScene { this.world.setBlock(this.targetPos.offset(xOffset, yOffset, zOffset), block) } + lockCameraInUrl () { + this.params.camera = this.getCameraStateString() + this.updateQs() + } + resetCamera () { + this.controls?.reset() const { targetPos } = this this.controls?.target.set(targetPos.x + 0.5, targetPos.y + 0.5, targetPos.z + 0.5) const cameraPos = targetPos.offset(2, 2, 2) const pitch = THREE.MathUtils.degToRad(-45) const yaw = THREE.MathUtils.degToRad(45) - viewer.camera.rotation.set(pitch, yaw, 0, 'ZYX') - viewer.camera.lookAt(targetPos.x + 0.5, targetPos.y + 0.5, targetPos.z + 0.5) viewer.camera.position.set(cameraPos.x + 0.5, cameraPos.y + 0.5, cameraPos.z + 0.5) + // viewer.camera.rotation.set(pitch, yaw, 0, 'ZYX') + viewer.camera.lookAt(targetPos.x + 0.5, targetPos.y + 0.5, targetPos.z + 0.5) this.controls?.update() } + getCameraStateString () { + const { camera } = viewer + return [ + camera.position.x.toFixed(2), + camera.position.y.toFixed(2), + camera.position.z.toFixed(2), + camera.rotation.x.toFixed(2), + camera.rotation.y.toFixed(2), + ].join(',') + } + async initData () { await window._LOAD_MC_DATA() const mcData: IndexedData = require('minecraft-data')(this.version) @@ -146,9 +199,8 @@ export class BasePlaygroundScene { world.setBlockStateId(this.targetPos, 0) this.world = world - this.initGui() - const worldView = new WorldDataEmitter(world, this.viewDistance, this.targetPos) + worldView.isPlayground = true worldView.addWaitTime = 0 window.worldView = worldView @@ -158,9 +210,18 @@ export class BasePlaygroundScene { renderer.setSize(window.innerWidth, window.innerHeight) // Create viewer - const viewer = new Viewer(renderer, { numWorkers: 6, showChunkBorders: false, }) + const viewer = new Viewer(renderer, { numWorkers: 6, showChunkBorders: false, isPlayground: true }) + viewer.setFirstPersonCamera(null, viewer.camera.rotation.y, viewer.camera.rotation.x) window.viewer = viewer - const isWebgpu = false + viewer.world.blockstatesModels = blockstatesModels + viewer.addChunksBatchWaitTime = 0 + viewer.entities.setDebugMode('basic') + viewer.world.mesherConfig.enableLighting = false + viewer.world.allowUpdates = true + this.initGui() + await viewer.setVersion(this.version) + + const isWebgpu = true const promises = [] as Array> if (isWebgpu) { // promises.push(initWebgpuRenderer(() => { }, true, true)) // todo @@ -169,14 +230,9 @@ export class BasePlaygroundScene { renderer.domElement.id = 'viewer-canvas' document.body.appendChild(renderer.domElement) } - viewer.addChunksBatchWaitTime = 0 - viewer.world.blockstatesModels = blockstatesModels - viewer.entities.setDebugMode('basic') - viewer.setVersion(this.version) viewer.entities.onSkinUpdate = () => { viewer.render() } - viewer.world.mesherConfig.enableLighting = false await Promise.all(promises) this.setupWorld() @@ -193,7 +249,7 @@ export class BasePlaygroundScene { this.resetCamera() // #region camera rotation param - const cameraSet = this.params.camera || localStorage.camera + const cameraSet = this.allParamsValuesInit.camera || localStorage.camera if (cameraSet) { const [x, y, z, rx, ry] = cameraSet.split(',').map(Number) viewer.camera.position.set(x, y, z) @@ -204,13 +260,7 @@ export class BasePlaygroundScene { const { camera } = viewer // params.camera = `${camera.rotation.x.toFixed(2)},${camera.rotation.y.toFixed(2)}` // this.updateQs() - localStorage.camera = [ - camera.position.x.toFixed(2), - camera.position.y.toFixed(2), - camera.position.z.toFixed(2), - camera.rotation.x.toFixed(2), - camera.rotation.y.toFixed(2), - ].join(',') + localStorage.camera = this.getCameraStateString() }, 200) if (this.controls) { this.controls.addEventListener('change', () => { @@ -283,13 +333,15 @@ export class BasePlaygroundScene { document.addEventListener('keydown', (e) => { if (!e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) { if (e.code === 'KeyR') { - this.controls?.reset() this.resetCamera() } if (e.code === 'KeyE') { worldView?.setBlockStateId(this.targetPos, this.world.getBlockStateId(this.targetPos)) } } + if (e.code === 'KeyT') { + viewer.camera.position.y += 100 * (e.shiftKey ? -1 : 1) + } }) document.addEventListener('visibilitychange', () => { this.windowHidden = document.visibilityState === 'hidden' @@ -331,13 +383,9 @@ export class BasePlaygroundScene { direction.applyQuaternion(viewer.camera.quaternion) direction.y = 0 - if (pressedKeys.has('ShiftLeft')) { - direction.y *= 2 - direction.x *= 2 - direction.z *= 2 - } + const scalar = pressedKeys.has('AltLeft') ? 4 : 1 // Add the vector to the camera's position to move the camera - viewer.camera.position.add(direction.normalize()) + viewer.camera.position.add(direction.normalize().multiplyScalar(scalar)) this.controls?.update() this.render() } diff --git a/prismarine-viewer/examples/chunksStorage.test.ts b/prismarine-viewer/examples/chunksStorage.test.ts new file mode 100644 index 000000000..344ad3345 --- /dev/null +++ b/prismarine-viewer/examples/chunksStorage.test.ts @@ -0,0 +1,239 @@ +import { test, expect } from 'vitest' +import { ChunksStorage } from './chunksStorage' + +globalThis.reportError = err => { + throw err +} +test('Free areas', () => { + const storage = new ChunksStorage() + storage.chunkSizeDisplay = 1 + const blocksWith1 = Object.fromEntries(Array.from({ length: 100 }).map((_, i) => { + return [`${i},0,0`, 1 as any] + })) + const blocksWith2 = Object.fromEntries(Array.from({ length: 100 }).map((_, i) => { + return [`${i},0,0`, 2 as any] + })) + const blocksWith3 = Object.fromEntries(Array.from({ length: 10 }).map((_, i) => { + return [`${i},0,0`, 3 as any] + })) + const blocksWith4 = Object.fromEntries(Array.from({ length: 10 }).map((_, i) => { + return [`${i},0,0`, 4 as any] + })) + + const getRangeString = () => { + const ranges = {} + let lastNum = storage.allBlocks[0]?.[3] + let lastNumI = 0 + for (let i = 0; i < storage.allBlocks.length; i++) { + const num = storage.allBlocks[i]?.[3] + if (lastNum !== num || i === storage.allBlocks.length - 1) { + const inclusive = i === storage.allBlocks.length - 1 + ranges[`[${lastNumI}-${i}${inclusive ? ']' : ')'}`] = lastNum + lastNum = num + lastNumI = i + } + } + return ranges + } + + const testRange = (start, end, number) => { + for (let i = start; i < end; i++) { + expect(storage.allBlocks[i]?.[3], `allblocks ${i} (range ${start}-${end})`).toBe(number) + } + } + + storage.addChunk(blocksWith1, '0,0,0') + storage.addChunk(blocksWith2, '1,0,0') + expect(storage.chunksMap).toMatchInlineSnapshot(` + Map { + "0,0,0" => 0, + "1,0,0" => 1, + } + `) + expect(storage.chunks).toMatchInlineSnapshot(` + [ + { + "free": false, + "length": 100, + "x": 0, + "z": 0, + }, + { + "free": false, + "length": 100, + "x": 1, + "z": 0, + }, + ] + `) + expect(storage.findBelongingChunk(100)).toMatchInlineSnapshot(` + { + "chunk": { + "free": false, + "length": 100, + "x": 1, + "z": 0, + }, + "index": 1, + } + `) + expect(getRangeString()).toMatchInlineSnapshot(` + { + "[0-100)": 1, + "[100-199]": 2, + } + `) + + storage.removeChunk('0,0,0') + expect(storage.chunks[0].free).toBe(true) + expect(storage.chunks[0].length).toBe(100) + + expect(getRangeString()).toMatchInlineSnapshot(` + { + "[0-100)": undefined, + "[100-199]": 2, + } + `) + + storage.addChunk(blocksWith3, `0,0,2`) + expect(storage.chunksMap).toMatchInlineSnapshot(` + Map { + "1,0,0" => 1, + "0,0,2" => 0, + } + `) + expect(storage.chunks).toMatchInlineSnapshot(` + [ + { + "free": false, + "length": 100, + "x": 0, + "z": 2, + }, + { + "free": false, + "length": 100, + "x": 1, + "z": 0, + }, + ] + `) + expect(getRangeString()).toMatchInlineSnapshot(` + { + "[0-10)": 3, + "[10-100)": undefined, + "[100-199]": 2, + } + `) + + // update (no map changes) + storage.addChunk(blocksWith4, `0,0,2`) + expect(storage.chunksMap).toMatchInlineSnapshot(` + Map { + "1,0,0" => 1, + "0,0,2" => 0, + } + `) + expect(storage.chunks).toMatchInlineSnapshot(` + [ + { + "free": false, + "length": 100, + "x": 0, + "z": 2, + }, + { + "free": false, + "length": 100, + "x": 1, + "z": 0, + }, + ] + `) + expect(getRangeString()).toMatchInlineSnapshot(` + { + "[0-10)": 4, + "[10-100)": undefined, + "[100-199]": 2, + } + `) + + storage.addChunk(blocksWith3, `0,0,3`) + expect(storage.chunksMap).toMatchInlineSnapshot(` + Map { + "1,0,0" => 1, + "0,0,2" => 0, + "0,0,3" => 2, + } + `) + expect(storage.chunks).toMatchInlineSnapshot(` + [ + { + "free": false, + "length": 100, + "x": 0, + "z": 2, + }, + { + "free": false, + "length": 100, + "x": 1, + "z": 0, + }, + { + "free": false, + "length": 10, + "x": 0, + "z": 3, + }, + ] + `) + expect(getRangeString()).toMatchInlineSnapshot(` + { + "[0-10)": 4, + "[10-100)": undefined, + "[100-200)": 2, + "[200-209]": 3, + } + `) + expect(storage.allBlocks.length).toBe(210) + + // update 0,0,2 + storage.addChunk(blocksWith1, `0,0,2`) + expect(storage.chunksMap).toMatchInlineSnapshot(` + Map { + "1,0,0" => 1, + "0,0,3" => 2, + "0,0,2" => 0, + } + `) + expect(storage.chunks).toMatchInlineSnapshot(` + [ + { + "free": false, + "length": 100, + "x": 0, + "z": 2, + }, + { + "free": false, + "length": 100, + "x": 1, + "z": 0, + }, + { + "free": false, + "length": 10, + "x": 0, + "z": 3, + }, + ] + `) + expect(getRangeString()).toMatchInlineSnapshot(` + { + "[0-100)": 1, + "[100-200)": 2, + "[200-209]": 3, + } + `) +}) diff --git a/prismarine-viewer/examples/chunksStorage.ts b/prismarine-viewer/examples/chunksStorage.ts new file mode 100644 index 000000000..cb74defdf --- /dev/null +++ b/prismarine-viewer/examples/chunksStorage.ts @@ -0,0 +1,236 @@ +import { BlockFaceType, BlockType, makeError } from './shared' + +export type BlockWithWebgpuData = [number, number, number, BlockType] + +export class ChunksStorage { + allBlocks = [] as Array + chunks = [] as Array<{ length: number, free: boolean, x: number, z: number }> + chunksMap = new Map() + // flatBuffer = new Uint32Array() + updateQueue = [] as Array<{ start: number, end: number }> + + maxDataUpdate = 10_000 + // awaitingUpdateStart: number | undefined + // awaitingUpdateEnd: number | undefined + // dataSize = 0 + lastFetchedSize = 0 + chunkSizeDisplay = 16 + + get dataSize () { + return this.allBlocks.length + } + + findBelongingChunk (blockIndex: number) { + let currentStart = 0 + let i = 0 + for (const chunk of this.chunks) { + const { length: chunkLength } = chunk + currentStart += chunkLength + if (blockIndex < currentStart) { + return { + chunk, + index: i + } + } + i++ + } + } + + printSectionData ({ x, y, z }) { + x = Math.floor(x / 16) * 16 + y = Math.floor(y / 16) * 16 + z = Math.floor(z / 16) * 16 + const key = `${x},${y},${z}` + const chunkIndex = this.chunksMap.get(key) + if (chunkIndex === undefined) return + const chunk = this.chunks[chunkIndex] + let start = 0 + for (let i = 0; i < chunkIndex; i++) { + start += this.chunks[i].length + } + const end = start + chunk.length + return { + blocks: this.allBlocks.slice(start, end), + index: chunkIndex, + range: [start, end] + } + } + + printBlock ({ x, y, z }: { x: number, y: number, z: number }) { + const section = this.printSectionData({ x, y, z }) + if (!section) return + x = Math.floor(x / 16) * 16 + z = Math.floor(z / 16) * 16 + const xRel = ((x % 16) + 16) % 16 + const zRel = ((z % 16) + 16) % 16 + for (const block of section.blocks) { + if (block && block[0] === xRel && block[1] === y && block[2] === zRel) { + return block + } + } + return null + } + + getDataForBuffers () { + this.lastFetchedSize = this.dataSize + const task = this.updateQueue.shift() + if (!task) return + const { start: awaitingUpdateStart, end } = task + const awaitingUpdateEnd = end + // if (awaitingUpdateEnd - awaitingUpdateStart > this.maxDataUpdate) { + // this.awaitingUpdateStart = awaitingUpdateStart + this.maxDataUpdate + // awaitingUpdateEnd = awaitingUpdateStart + this.maxDataUpdate + // } else { + // this.awaitingUpdateStart = undefined + // this.awaitingUpdateEnd = undefined + // } + return { + allBlocks: this.allBlocks, + chunks: this.chunks, + awaitingUpdateStart, + awaitingUpdateSize: awaitingUpdateEnd - awaitingUpdateStart, + } + } + + // setAwaitingUpdate ({ awaitingUpdateStart, awaitingUpdateSize }: { awaitingUpdateStart: number, awaitingUpdateSize: number }) { + // this.awaitingUpdateStart = awaitingUpdateStart + // this.awaitingUpdateEnd = awaitingUpdateStart + awaitingUpdateSize + // } + + clearData () { + this.chunks = [] + this.allBlocks = [] + this.updateQueue = [] + } + + replaceBlocksData (start: number, newData: typeof this.allBlocks) { + if (newData.length > 16 * 16 * 16) { + throw new Error(`Chunk cant be that big: ${newData.length}`) + } + this.allBlocks.splice(start, newData.length, ...newData) + } + + getAvailableChunk (size: number) { + let currentStart = 0 + let usingChunk: typeof this.chunks[0] | undefined + for (const chunk of this.chunks) { + const { length: chunkLength, free } = chunk + currentStart += chunkLength + if (!free) continue + if (chunkLength >= size) { + usingChunk = chunk + usingChunk.free = false + currentStart -= chunkLength + break + } + } + + if (!usingChunk) { + const newChunk = { + length: size, + free: false, + x: -1, + z: -1 + } + this.chunks.push(newChunk) + usingChunk = newChunk + } + + return { + chunk: usingChunk, + start: currentStart + } + } + + removeChunk (chunkPosKey: string) { + if (!this.chunksMap.has(chunkPosKey)) return + let currentStart = 0 + const chunkIndex = this.chunksMap.get(chunkPosKey)! + const chunk = this.chunks[chunkIndex] + for (let i = 0; i < chunkIndex; i++) { + const chunk = this.chunks[i]! + currentStart += chunk.length + } + + this.replaceBlocksData(currentStart, Array.from({ length: chunk.length }).map(() => undefined)) // empty data, will be filled with 0 + this.requestRangeUpdate(currentStart, currentStart + chunk.length) + chunk.free = true + this.chunksMap.delete(chunkPosKey) + // try merge backwards + // for (let i = chunkIndex - 1; i >= 0; i--) { + // const chunk = this.chunks[i]! + // if (!chunk.free) break + // chunk.length += this.chunks[i]!.length + // this.chunks.splice(i, 1) + // chunkIndex-- + // } + // // try merge forwards + // for (let i = chunkIndex + 1; i < this.chunks.length; i++) { + // const chunk = this.chunks[i]! + // if (!chunk.free) break + // chunk.length += this.chunks[i]!.length + // this.chunks.splice(i, 1) + // i-- + // } + } + + addChunk (blocks: Record, rawPosKey: string) { + this.removeChunk(rawPosKey) + + const [xSection, ySection, zSection] = rawPosKey.split(',').map(Number) + const chunkPosKey = `${xSection / 16},${ySection / 16},${zSection / 16}` + + // if (xSection === 0 && (zSection === -16) && ySection === 128) { + // // if (xSection >= 0 && (zSection >= 0) && ySection >= 128) { + // // newData = newData.slice + // } else { + // return + // } + + const newData = Object.entries(blocks).map(([key, value]) => { + const [x, y, z] = key.split(',').map(Number) + const block = value + const xRel = ((x % 16) + 16) % 16 + const zRel = ((z % 16) + 16) % 16 + // if (xRel !== 0 || (zRel !== 1 && zRel !== 0)) return + return [xRel, y, zRel, block] satisfies BlockWithWebgpuData + }).filter(Boolean) + + // if (ySection > 100 && (xSection < 0 || xSection > 0)) { + // newData = Array.from({ length: 16 }, (_, i) => 0).flatMap((_, i) => { + // return Array.from({ length: 16 }, (_, j) => 0).map((_, k) => { + // return [i % 16, ySection + k, k, { + // visibleFaces: [0, 1, 2, 3, 4, 5], + // modelId: k === 0 ? 1 : 0, + // block: '' + // } + // ] + // }) + // }) + // } + + const { chunk, start } = this.getAvailableChunk(newData.length) + chunk.x = xSection / this.chunkSizeDisplay + chunk.z = zSection / this.chunkSizeDisplay + const chunkIndex = this.chunks.indexOf(chunk) + this.chunksMap.set(rawPosKey, chunkIndex) + + for (const b of newData) { + if (b[3] && typeof b[3] === 'object') { + b[3].chunk = chunkIndex + } + } + + this.replaceBlocksData(start, newData) + this.requestRangeUpdate(start, start + newData.length) + return chunkIndex + } + + requestRangeUpdate (start: number, end: number) { + this.updateQueue.push({ start, end }) + } + + clearRange (start: number, end: number) { + this.replaceBlocksData(start, Array.from({ length: end - start }).map(() => undefined)) + } +} diff --git a/prismarine-viewer/examples/messageChannel.ts b/prismarine-viewer/examples/messageChannel.ts new file mode 100644 index 000000000..fa99db319 --- /dev/null +++ b/prismarine-viewer/examples/messageChannel.ts @@ -0,0 +1,28 @@ +export class MessageChannelReplacement { + port1Listeners = [] as Array<(e: MessageEvent) => void> + port2Listeners = [] as Array<(e: MessageEvent) => void> + port1 = { + addEventListener: (type, listener) => { + if (type !== 'message') throw new Error('unsupported type') + this.port1Listeners.push(listener) + }, + postMessage: (data) => { + for (const listener of this.port1Listeners) { + listener(new MessageEvent('message', { data })) + } + }, + start() {} + } as any + port2 = { + addEventListener: (type, listener) => { + if (type !== 'message') throw new Error('unsupported type') + this.port2Listeners.push(listener) + }, + postMessage: (data) => { + for (const listener of this.port2Listeners) { + listener(new MessageEvent('message', { data })) + } + }, + start() {} + } as any +} diff --git a/prismarine-viewer/examples/playground.ts b/prismarine-viewer/examples/playground.ts index cd0fa2190..3838f4b68 100644 --- a/prismarine-viewer/examples/playground.ts +++ b/prismarine-viewer/examples/playground.ts @@ -3,9 +3,18 @@ import { playgroundGlobalUiState } from './playgroundUi' import * as scenes from './scenes' const qsScene = new URLSearchParams(window.location.search).get('scene') -const Scene: typeof BasePlaygroundScene = qsScene ? scenes[qsScene] : scenes.main -playgroundGlobalUiState.scenes = ['main', 'railsCobweb', 'floorRandom', 'lightingStarfield', 'transparencyIssue', 'entities', 'frequentUpdates', 'slabsOptimization'] -playgroundGlobalUiState.selected = qsScene ?? 'main' +// eslint-disable-next-line unicorn/no-useless-spread +playgroundGlobalUiState.scenes = [...new Set([...Object.keys(scenes)])] +playgroundGlobalUiState.selected = qsScene ?? 'floorRandom' +playgroundGlobalUiState.actions = { + 'Lock camera in URL' () { + scene.lockCameraInUrl() + }, + 'Reset camera' () { + scene.resetCamera() + } +} +const Scene: typeof BasePlaygroundScene = scenes[playgroundGlobalUiState.selected] const scene = new Scene() globalThis.scene = scene diff --git a/prismarine-viewer/examples/scenes/cubesHouse.ts b/prismarine-viewer/examples/scenes/cubesHouse.ts new file mode 100644 index 000000000..562ec78e8 --- /dev/null +++ b/prismarine-viewer/examples/scenes/cubesHouse.ts @@ -0,0 +1,49 @@ +import { Vec3 } from 'vec3' +import { BasePlaygroundScene } from '../baseScene' + +export default class RailsCobwebScene extends BasePlaygroundScene { + viewDistance = 16 + continuousRender = true + targetPos = new Vec3(0, 0, 0) + webgpuRendererParams = true + + override initGui (): void { + this.params = { + chunkDistance: 4, + } + + super.initGui() // restore user params + } + + setupWorld () { + viewer.world.allowUpdates = false + + const { chunkDistance } = this.params + // const fullBlocks = loadedData.blocksArray.map(x => x.name) + const fullBlocks = loadedData.blocksArray.filter(block => { + const b = this.Block.fromStateId(block.defaultState, 0) + if (b.shapes?.length !== 1) return false + const shape = b.shapes[0] + return shape[0] === 0 && shape[1] === 0 && shape[2] === 0 && shape[3] === 1 && shape[4] === 1 && shape[5] === 1 + }) + + const squareSize = chunkDistance * 16 + // for (let y = 0; y < squareSize; y += 2) { + // for (let x = 0; x < squareSize; x++) { + // for (let z = 0; z < squareSize; z++) { + // const isEven = x === z + // if (y > 400) continue + // worldView!.world.setBlockStateId(this.targetPos.offset(x, y, z), isEven ? 1 : 2) + // } + // } + // } + + for (let x = 0; x < chunkDistance; x++) { + for (let z = 0; z < chunkDistance; z++) { + for (let y = 0; y < 200; y++) { + viewer.world.webgpuChannel.generateRandom(16 ** 2, x * 16, z * 16, y) + } + } + } + } +} diff --git a/prismarine-viewer/examples/scenes/floorRandom.ts b/prismarine-viewer/examples/scenes/floorRandom.ts index c6d2ccf1c..20ba5b289 100644 --- a/prismarine-viewer/examples/scenes/floorRandom.ts +++ b/prismarine-viewer/examples/scenes/floorRandom.ts @@ -1,33 +1,48 @@ +import { Vec3 } from 'vec3' import { BasePlaygroundScene } from '../baseScene' export default class RailsCobwebScene extends BasePlaygroundScene { - viewDistance = 5 + webgpuRendererParams = true + viewDistance = 0 continuousRender = true + targetPos = new Vec3(0, 0, 0) override initGui (): void { this.params = { - squareSize: 50 + chunksDistance: 16, } - super.initGui() + this.paramOptions.chunksDistance = { + reloadOnChange: true, + } + + super.initGui() // restore user params } setupWorld () { - const squareSize = this.params.squareSize ?? 30 - const maxSquareSize = this.viewDistance * 16 * 2 - if (squareSize > maxSquareSize) throw new Error(`Square size too big, max is ${maxSquareSize}`) - // const fullBlocks = loadedData.blocksArray.map(x => x.name) - const fullBlocks = loadedData.blocksArray.filter(block => { - const b = this.Block.fromStateId(block.defaultState, 0) - if (b.shapes?.length !== 1) return false - const shape = b.shapes[0] - return shape[0] === 0 && shape[1] === 0 && shape[2] === 0 && shape[3] === 1 && shape[4] === 1 && shape[5] === 1 - }) - for (let x = -squareSize; x <= squareSize; x++) { - for (let z = -squareSize; z <= squareSize; z++) { - const i = Math.abs(x + z) * squareSize - worldView!.world.setBlock(this.targetPos.offset(x, 0, z), this.Block.fromStateId(fullBlocks[i % fullBlocks.length].defaultState, 0)) + viewer.world.allowUpdates = true + const chunkDistance = this.params.chunksDistance + for (let x = -chunkDistance; x < chunkDistance; x++) { + for (let z = -chunkDistance; z < chunkDistance; z++) { + viewer.world.webgpuChannel.generateRandom(16 ** 2, x * 16, z * 16) } } + + // const squareSize = this.params.squareSize ?? 30 + // const maxSquareSize = this.viewDistance * 16 * 2 + // if (squareSize > maxSquareSize) throw new Error(`Square size too big, max is ${maxSquareSize}`) + // // const fullBlocks = loadedData.blocksArray.map(x => x.name) + // const fullBlocks = loadedData.blocksArray.filter(block => { + // const b = this.Block.fromStateId(block.defaultState, 0) + // if (b.shapes?.length !== 1) return false + // const shape = b.shapes[0] + // return shape[0] === 0 && shape[1] === 0 && shape[2] === 0 && shape[3] === 1 && shape[4] === 1 && shape[5] === 1 + // }) + // for (let x = -squareSize; x <= squareSize; x++) { + // for (let z = -squareSize; z <= squareSize; z++) { + // const i = Math.abs(x + z) * squareSize + // worldView!.world.setBlock(this.targetPos.offset(x, 0, z), this.Block.fromStateId(fullBlocks[i % fullBlocks.length].defaultState, 0)) + // } + // } } } diff --git a/prismarine-viewer/examples/scenes/floorStoneWorld.ts b/prismarine-viewer/examples/scenes/floorStoneWorld.ts new file mode 100644 index 000000000..5db1c9354 --- /dev/null +++ b/prismarine-viewer/examples/scenes/floorStoneWorld.ts @@ -0,0 +1,46 @@ +import { Vec3 } from 'vec3' +import { BasePlaygroundScene } from '../baseScene' + +export default class Scene extends BasePlaygroundScene { + viewDistance = 16 + continuousRender = true + targetPos = new Vec3(0, 0, 0) + webgpuRendererParams = true + + override initGui (): void { + this.params = { + chunksDistance: 2, + } + + super.initGui() // restore user params + } + + async setupWorld () { + // const chunkDistance = this.params.chunksDistance + // for (let x = -chunkDistance; x < chunkDistance; x++) { + // for (let z = -chunkDistance; z < chunkDistance; z++) { + // webgpuChannel.generateRandom(16 ** 2, x * 16, z * 16) + // } + // } + + const squareSize = this.params.chunksDistance * 16 + const maxSquareSize = this.viewDistance * 16 * 2 + if (squareSize > maxSquareSize) throw new Error(`Square size too big, max is ${maxSquareSize}`) + // const fullBlocks = loadedData.blocksArray.map(x => x.name) + const fullBlocks = loadedData.blocksArray.filter(block => { + const b = this.Block.fromStateId(block.defaultState, 0) + if (b.shapes?.length !== 1) return false + const shape = b.shapes[0] + return shape[0] === 0 && shape[1] === 0 && shape[2] === 0 && shape[3] === 1 && shape[4] === 1 && shape[5] === 1 + }) + + for (let x = -squareSize; x <= squareSize; x++) { + for (let z = -squareSize; z <= squareSize; z++) { + const isEven = x === z + worldView!.world.setBlockStateId(this.targetPos.offset(x, 0, z), isEven ? 1 : 2) + } + } + + console.log('setting done') + } +} diff --git a/prismarine-viewer/examples/scenes/index.ts b/prismarine-viewer/examples/scenes/index.ts index 81657a712..151e7b843 100644 --- a/prismarine-viewer/examples/scenes/index.ts +++ b/prismarine-viewer/examples/scenes/index.ts @@ -1,10 +1,12 @@ // export { default as rotation } from './rotation' export { default as main } from './main' -export { default as railsCobweb } from './railsCobweb' +// export { default as railsCobweb } from './railsCobweb' export { default as floorRandom } from './floorRandom' -export { default as lightingStarfield } from './lightingStarfield' -export { default as transparencyIssue } from './transparencyIssue' -export { default as rotationIssue } from './rotationIssue' -export { default as entities } from './entities' -export { default as frequentUpdates } from './frequentUpdates' -export { default as slabsOptimization } from './slabsOptimization' +export { default as floorStoneWorld } from './floorStoneWorld' +export { default as layers } from './layers' +export { default as cubesHouse } from './cubesHouse' +// export { default as lightingStarfield } from './lightingStarfield' +// export { default as transparencyIssue } from './transparencyIssue' +// export { default as rotationIssue } from './rotationIssue' +// export { default as entities } from './entities' +// export { default as frequentUpdates } from './frequentUpdates' diff --git a/prismarine-viewer/examples/scenes/layers.ts b/prismarine-viewer/examples/scenes/layers.ts new file mode 100644 index 000000000..e01495750 --- /dev/null +++ b/prismarine-viewer/examples/scenes/layers.ts @@ -0,0 +1,45 @@ +import { Vec3 } from 'vec3' +import { BasePlaygroundScene } from '../baseScene' + +export default class Scene extends BasePlaygroundScene { + viewDistance = 16 + continuousRender = true + targetPos = new Vec3(0, 0, 0) + webgpuRendererParams = true + + override initGui (): void { + this.params = { + chunksDistance: 2, + } + + super.initGui() // restore user params + } + + async setupWorld () { + const squareSize = this.params.chunksDistance * 16 + const maxSquareSize = this.viewDistance * 16 * 2 + if (squareSize > maxSquareSize) throw new Error(`Square size too big, max is ${maxSquareSize}`) + // const fullBlocks = loadedData.blocksArray.map(x => x.name) + const fullBlocks = loadedData.blocksArray.filter(block => { + const b = this.Block.fromStateId(block.defaultState, 0) + if (b.shapes?.length !== 1) return false + const shape = b.shapes[0] + return shape[0] === 0 && shape[1] === 0 && shape[2] === 0 && shape[3] === 1 && shape[4] === 1 && shape[5] === 1 + }) + + const start = -squareSize + const end = squareSize + + const STEP = 40 + for (let y = 0; y <= 256; y += STEP) { + for (let x = start; x <= end; x++) { + for (let z = start; z <= end; z++) { + const isEven = x === z + worldView!.world.setBlockStateId(this.targetPos.offset(x, y, z), y === 0 ? fullBlocks.find(block => block.name === 'glass')!.defaultState : fullBlocks[y / STEP]!.defaultState) + } + } + } + + console.log('setting done') + } +} diff --git a/prismarine-viewer/examples/shared.ts b/prismarine-viewer/examples/shared.ts index ba58a57fa..cf4c97282 100644 --- a/prismarine-viewer/examples/shared.ts +++ b/prismarine-viewer/examples/shared.ts @@ -3,7 +3,8 @@ import ChunkLoader from 'prismarine-chunk' export type BlockFaceType = { side: number - textureIndex: number + // textureIndex: number + modelId: number tint?: [number, number, number] isTransparent?: boolean @@ -14,9 +15,13 @@ export type BlockFaceType = { } export type BlockType = { - faces: BlockFaceType[] + // faces: BlockFaceType[] + visibleFaces: number[] + modelId: number + tint?: [number, number, number] // for testing + chunk?: number block: string } diff --git a/prismarine-viewer/examples/webgpuBlockModels.ts b/prismarine-viewer/examples/webgpuBlockModels.ts new file mode 100644 index 000000000..13dbdade1 --- /dev/null +++ b/prismarine-viewer/examples/webgpuBlockModels.ts @@ -0,0 +1,158 @@ +import { versionToNumber } from 'flying-squid/dist/utils' +import worldBlockProvider from 'mc-assets/dist/worldBlockProvider' +import PrismarineBlock, { Block } from 'prismarine-block' +import { IndexedBlock } from 'minecraft-data' +import { getPreflatBlock } from '../viewer/lib/mesher/getPreflatBlock' +import { WEBGPU_FULL_TEXTURES_LIMIT } from './webgpuRendererShared' + +export const prepareCreateWebgpuBlocksModelsData = () => { + const blocksMap = { + 'double_stone_slab': 'stone', + 'stone_slab': 'stone', + 'oak_stairs': 'planks', + 'stone_stairs': 'stone', + 'glass_pane': 'stained_glass', + 'brick_stairs': 'brick_block', + 'stone_brick_stairs': 'stonebrick', + 'nether_brick_stairs': 'nether_brick', + 'double_wooden_slab': 'planks', + 'wooden_slab': 'planks', + 'sandstone_stairs': 'sandstone', + 'cobblestone_wall': 'cobblestone', + 'quartz_stairs': 'quartz_block', + 'stained_glass_pane': 'stained_glass', + 'red_sandstone_stairs': 'red_sandstone', + 'stone_slab2': 'stone_slab', + 'purpur_stairs': 'purpur_block', + 'purpur_slab': 'purpur_block' + } + + const isPreflat = versionToNumber(viewer.world.version!) < versionToNumber('1.13') + const provider = worldBlockProvider(viewer.world.blockstatesModels, viewer.world.blocksAtlasParser?.atlasJson ?? viewer.world.blocksAtlases, 'latest') + const PBlockOriginal = PrismarineBlock(viewer.world.version!) + + const interestedTextureTiles = new Set() + const blocksDataModelDebug = {} as AllBlocksDataModels + const blocksDataModel = {} as AllBlocksDataModels + const blocksProccessed = {} as Record + let i = 0 + const allBlocksStateIdToModelIdMap = {} as AllBlocksStateIdToModelIdMap + + const addBlockModel = (state: number, name: string, props: Record, mcBlockData?: IndexedBlock, defaultState = false) => { + const models = provider.getAllResolvedModels0_1({ + name, + properties: props + }, isPreflat) + // skipping composite blocks + if (models.length !== 1 || !models[0]![0].elements) { + return + } + const elements = models[0]![0]?.elements + if (elements.length !== 1 && name !== 'grass_block') { + return + } + const elem = models[0]![0].elements[0] + if (elem.from[0] !== 0 || elem.from[1] !== 0 || elem.from[2] !== 0 || elem.to[0] !== 16 || elem.to[1] !== 16 || elem.to[2] !== 16) { + // not full block + return + } + const facesMapping = [ + ['front', 'south'], + ['bottom', 'down'], + ['top', 'up'], + ['right', 'east'], + ['left', 'west'], + ['back', 'north'], + ] + const blockData: BlocksModelData = { + textures: [0, 0, 0, 0, 0, 0], + rotation: [0, 0, 0, 0, 0, 0] + } + for (const [face, { texture, cullface, rotation = 0 }] of Object.entries(elem.faces)) { + const faceIndex = facesMapping.findIndex(x => x.includes(face)) + if (faceIndex === -1) { + throw new Error(`Unknown face ${face}`) + } + blockData.textures[faceIndex] = texture.tileIndex + blockData.rotation[faceIndex] = rotation / 90 + if (Math.floor(blockData.rotation[faceIndex]) !== blockData.rotation[faceIndex]) { + throw new Error(`Invalid rotation ${rotation} ${name}`) + } + interestedTextureTiles.add(texture.debugName) + } + const k = i++ + allBlocksStateIdToModelIdMap[state] = k + blocksDataModel[k] = blockData + if (defaultState) { + blocksDataModelDebug[name] ??= blockData + } + blocksProccessed[name] = true + if (mcBlockData) { + blockData.transparent = mcBlockData.transparent + blockData.emitLight = mcBlockData.emitLight + blockData.filterLight = mcBlockData.filterLight + } + } + addBlockModel(-1, 'unknown', {}) + const textureOverrideFullBlocks = { + water: 'water_still', + lava: 'lava_still' + } + outer: for (const b of loadedData.blocksArray) { + for (let state = b.minStateId; state <= b.maxStateId; state++) { + if (interestedTextureTiles.size >= WEBGPU_FULL_TEXTURES_LIMIT) { + console.warn(`Limit in ${WEBGPU_FULL_TEXTURES_LIMIT} textures reached for full blocks, skipping others!`) + break outer + } + const mapping = blocksMap[b.name] + const block = PBlockOriginal.fromStateId(mapping && loadedData.blocksByName[mapping] ? loadedData.blocksByName[mapping].defaultState : state, 0) + if (isPreflat) { + getPreflatBlock(block) + } + + const textureOverride = textureOverrideFullBlocks[block.name] as string | undefined + if (textureOverride) { + const k = i++ + const texture = provider.getTextureInfo(textureOverride) + if (!texture) { + console.warn('Missing texture override') + continue + } + const texIndex = texture.tileIndex + allBlocksStateIdToModelIdMap[state] = k + const blockData: BlocksModelData = { + textures: [texIndex, texIndex, texIndex, texIndex, texIndex, texIndex], + rotation: [0, 0, 0, 0, 0, 0], + filterLight: b.filterLight + } + blocksDataModel[k] = blockData + interestedTextureTiles.add(textureOverride) + continue + } + + if (block.shapes.length === 0 || !block.shapes.every(shape => { + return shape[0] === 0 && shape[1] === 0 && shape[2] === 0 && shape[3] === 1 && shape[4] === 1 && shape[5] === 1 + })) { + continue + } + + addBlockModel(state, block.name, block.getProperties(), b, state === b.defaultState) + } + } + return { + blocksDataModel, + allBlocksStateIdToModelIdMap, + interestedTextureTiles, + blocksDataModelDebug + } +} +export type AllBlocksDataModels = Record +export type AllBlocksStateIdToModelIdMap = Record + +export type BlocksModelData = { + textures: number[] + rotation: number[] + transparent?: boolean + emitLight?: number + filterLight?: number +} diff --git a/prismarine-viewer/examples/webgpuRenderer.ts b/prismarine-viewer/examples/webgpuRenderer.ts new file mode 100644 index 000000000..9272ddc7f --- /dev/null +++ b/prismarine-viewer/examples/webgpuRenderer.ts @@ -0,0 +1,1285 @@ +import * as THREE from 'three' +import * as tweenJs from '@tweenjs/tween.js' +import VolumtetricFragShader from '../webgpuShaders/RadialBlur/frag.wgsl' +import VolumtetricVertShader from '../webgpuShaders/RadialBlur/vert.wgsl' +import { BlockFaceType } from './shared' +import { PositionOffset, UVOffset, quadVertexArray, quadVertexCount, cubeVertexSize } from './CubeDef' +import VertShader from './Cube.vert.wgsl' +import FragShader from './Cube.frag.wgsl' +import ComputeShader from './Cube.comp.wgsl' +import ComputeSortShader from './CubeSort.comp.wgsl' +import { chunksStorage, updateSize, postMessage } from './webgpuRendererWorker' +import { defaultWebgpuRendererParams, RendererInitParams, RendererParams } from './webgpuRendererShared' +import type { BlocksModelData } from './webgpuBlockModels' + +const cubeByteLength = 12 +export class WebgpuRenderer { + destroyed = false + rendering = true + renderedFrames = 0 + rendererParams = { ...defaultWebgpuRendererParams } + chunksFadeAnimationController = new IndexedInOutAnimationController(() => {}) + + ready = false + + device: GPUDevice + renderPassDescriptor: GPURenderPassDescriptor + uniformBindGroup: GPUBindGroup + vertexCubeBindGroup: GPUBindGroup + cameraUniform: GPUBuffer + ViewUniformBuffer: GPUBuffer + ProjectionUniformBuffer: GPUBuffer + ctx: GPUCanvasContext + verticesBuffer: GPUBuffer + InstancedModelBuffer: GPUBuffer + pipeline: GPURenderPipeline + InstancedTextureIndexBuffer: GPUBuffer + InstancedColorBuffer: GPUBuffer + notRenderedBlockChanges = 0 + renderingStats: undefined | { instanceCount: number } + renderingStatsRequestTime: number | undefined + + // Add these properties to the WebgpuRenderer class + computePipeline: GPUComputePipeline + indirectDrawBuffer: GPUBuffer + cubesBuffer: GPUBuffer + visibleCubesBuffer: GPUBuffer + computeBindGroup: GPUBindGroup + computeBindGroupLayout: GPUBindGroupLayout + indirectDrawParams: Uint32Array + maxBufferSize: number + commandEncoder: GPUCommandEncoder + AtlasTexture: GPUTexture + secondCameraUniformBindGroup: GPUBindGroup + secondCameraUniform: GPUBuffer + + multisampleTexture: GPUTexture | undefined + chunksBuffer: GPUBuffer + chunkBindGroup: GPUBindGroup + debugBuffer: GPUBuffer + + realNumberOfCubes = 0 + occlusionTexture: GPUBuffer + computeSortPipeline: GPUComputePipeline + depthTextureBuffer: GPUBuffer + textureSizeBuffer: any + textureSizeBindGroup: GPUBindGroup + modelsBuffer: GPUBuffer + indirectDrawBufferMap: GPUBuffer + indirectDrawBufferMapBeingUsed = false + cameraComputePositionUniform: GPUBuffer + NUMBER_OF_CUBES: number + depthTexture: GPUTexture + rendererDeviceString: string + cameraUpdated = true + lastCameraUpdateTime = 0 + noCameraUpdates = 0 + positiveCameraUpdates = false + lastCameraUpdateDiff = undefined as undefined | { + x: number + y: number + z: number + time: number + } + debugCameraMove = { + x: 0, + y: 0, + z: 0 + } + renderMs = 0 + renderMsCount = 0 + volumetricPipeline: GPURenderPipeline + VolumetricBindGroup: GPUBindGroup + depthTextureAnother: GPUTexture + volumetricRenderPassDescriptor: GPURenderPassDescriptor + tempTexture: GPUTexture + rotationsUniform: GPUBuffer + earlyZRejectUniform: GPUBuffer + tileSizeUniform: GPUBuffer + clearColorBuffer: GPUBuffer + chunksCount: number + + + // eslint-disable-next-line max-params + constructor (public canvas: HTMLCanvasElement, public imageBlob: ImageBitmapSource, public isPlayground: boolean, public camera: THREE.PerspectiveCamera, public localStorage: any, public blocksDataModel: Record, public rendererInitParams: RendererInitParams) { + this.NUMBER_OF_CUBES = 65_536 + void this.init().catch((err) => { + console.error(err) + postMessage({ type: 'rendererProblem', isContextLost: false, message: err.message }) + }) + } + + changeBackgroundColor (color: [number, number, number]) { + const colorRgba = [color[0], color[1], color[2], 1] + this.renderPassDescriptor.colorAttachments[0].clearValue = colorRgba + this.device.queue.writeBuffer( + this.clearColorBuffer, + 0, + new Float32Array(colorRgba) + ) + } + + updateConfig (newParams: RendererParams) { + this.rendererParams = { ...this.rendererParams, ...newParams } + } + + async init () { + const { canvas, imageBlob, isPlayground, localStorage } = this + this.camera.near = 0.05 + updateSize(canvas.width, canvas.height) + + if (!navigator.gpu) throw new Error('WebGPU not supported (probably can be enabled in settings)') + const adapter = await navigator.gpu.requestAdapter({ + ...this.rendererInitParams + }) + if (!adapter) throw new Error('WebGPU not supported') + const adapterInfo = adapter.info ?? {} // todo fix ios + this.rendererDeviceString = `${adapterInfo.vendor} ${adapterInfo.device} (${adapterInfo.architecture}) ${adapterInfo.description}` + + const twoGigs = 2_147_483_644 + try { + this.device = await adapter.requestDevice({ + // https://developer.mozilla.org/en-US/docs/Web/API/GPUDevice/limits + requiredLimits: { + maxStorageBufferBindingSize: twoGigs, + maxBufferSize: twoGigs, + } + }) + } catch (err) { + this.device = await adapter.requestDevice() + } + const { device } = this + this.maxBufferSize = device.limits.maxStorageBufferBindingSize + this.renderedFrames = device.limits.maxComputeWorkgroupSizeX + console.log('max buffer size', this.maxBufferSize / 1024 / 1024, 'MB', 'available features', [...device.features.values()]) + + const ctx = this.ctx = canvas.getContext('webgpu')! + + const presentationFormat = navigator.gpu.getPreferredCanvasFormat() + + ctx.configure({ + device, + format: presentationFormat, + alphaMode: 'opaque', + }) + + const verticesBuffer = device.createBuffer({ + size: quadVertexArray.byteLength, + usage: GPUBufferUsage.VERTEX, + mappedAtCreation: true, + }) + + this.verticesBuffer = verticesBuffer + new Float32Array(verticesBuffer.getMappedRange()).set(quadVertexArray) + verticesBuffer.unmap() + + const pipeline = device.createRenderPipeline({ + label: 'mainPipeline', + layout: 'auto', + vertex: { + module: device.createShaderModule({ + code: localStorage.vertShader || VertShader, + }), + buffers: [ + { + arrayStride: cubeVertexSize, + attributes: [ + { + shaderLocation: 0, + offset: PositionOffset, + format: 'float32x3', + }, + { + shaderLocation: 1, + offset: UVOffset, + format: 'float32x2', + }, + ], + }, + ], + }, + fragment: { + module: device.createShaderModule({ + code: localStorage.fragShader || FragShader, + }), + targets: [ + { + format: presentationFormat, + blend: { + color: { + srcFactor: 'src-alpha', + dstFactor: 'one-minus-src-alpha', + operation: 'add', + }, + alpha: { + srcFactor: 'src-alpha', + dstFactor: 'one-minus-src-alpha', + operation: 'add', + }, + }, + }, + ], + }, + // multisample: { + // count: 4, + // }, + primitive: { + topology: 'triangle-list', + cullMode: 'none', + }, + depthStencil: { + depthWriteEnabled: true, + depthCompare: 'less', + format: 'depth32float', + }, + }) + this.pipeline = pipeline + + this.volumetricPipeline = device.createRenderPipeline({ + label: 'volumtetricPipeline', + layout: 'auto', + vertex: { + module: device.createShaderModule({ + code: localStorage.VolumtetricVertShader || VolumtetricVertShader, + }), + buffers: [ + { + arrayStride: cubeVertexSize, + attributes: [ + { + shaderLocation: 0, + offset: PositionOffset, + format: 'float32x3', + }, + { + shaderLocation: 1, + offset: UVOffset, + format: 'float32x2', + }, + ], + }, + ], + }, + fragment: { + module: device.createShaderModule({ + code: localStorage.VolumtetricFragShader || VolumtetricFragShader, + }), + targets: [ + { + format: presentationFormat, + blend: { + color: { + srcFactor: 'src-alpha', + dstFactor: 'one-minus-src-alpha', + operation: 'add', + }, + alpha: { + srcFactor: 'src-alpha', + dstFactor: 'one-minus-src-alpha', + operation: 'add', + }, + }, + }, + ], + }, + primitive: { + topology: 'triangle-list', + cullMode: 'none', + }, + depthStencil: { + depthWriteEnabled: false, + depthCompare: 'less', + format: 'depth32float', + }, + }) + + this.depthTexture = device.createTexture({ + size: [canvas.width, canvas.height], + format: 'depth32float', + usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING, + //sampleCount: 4, + }) + + this.depthTextureAnother = device.createTexture({ + size: [canvas.width, canvas.height], + format: 'depth32float', + usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING, + //sampleCount: 4, + }) + + this.tempTexture = device.createTexture({ + size: [canvas.width, canvas.height], + format: 'bgra8unorm', + usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING, + //sampleCount: 4, + }) + + const Mat4x4BufferSize = 4 * (4 * 4) // 4x4 matrix + + this.cameraUniform = device.createBuffer({ + size: Mat4x4BufferSize, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }) + + this.earlyZRejectUniform = device.createBuffer({ + size: 4, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }) + + this.tileSizeUniform = device.createBuffer({ + size: 8, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }) + + this.rotationsUniform = device.createBuffer({ + size: Mat4x4BufferSize * 6, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }) + + const matrixData = new Float32Array([ + ...new THREE.Matrix4().makeRotationX(THREE.MathUtils.degToRad(90)).toArray(), + ...new THREE.Matrix4().makeRotationX(THREE.MathUtils.degToRad(-90)).toArray(), + ...new THREE.Matrix4().makeRotationY(THREE.MathUtils.degToRad(0)).toArray(), + ...new THREE.Matrix4().makeRotationY(THREE.MathUtils.degToRad(180)).toArray(), + ...new THREE.Matrix4().makeRotationY(THREE.MathUtils.degToRad(-90)).toArray(), + ...new THREE.Matrix4().makeRotationY(THREE.MathUtils.degToRad(90)).toArray(), + ]) + + device.queue.writeBuffer( + this.rotationsUniform, + 0, + matrixData + ) + + this.clearColorBuffer = device.createBuffer({ + size: 4 * 4, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }) + + this.cameraComputePositionUniform = device.createBuffer({ + size: 4 * 4, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }) + + this.secondCameraUniform = device.createBuffer({ + size: Mat4x4BufferSize, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }) + + const ViewProjectionMat42 = new THREE.Matrix4() + const { projectionMatrix: projectionMatrix2, matrix: matrix2 } = this.camera2 + ViewProjectionMat42.multiplyMatrices(projectionMatrix2, matrix2.invert()) + const ViewProjection2 = new Float32Array(ViewProjectionMat42.elements) + device.queue.writeBuffer( + this.secondCameraUniform, + 0, + ViewProjection2 + ) + + // upload image into a GPUTexture. + await this.updateTexture(imageBlob, true) + + this.volumetricRenderPassDescriptor = { + label: 'VolumteticRenderPassDescriptor', + colorAttachments: [ + { + view: undefined as any, // Assigned later + clearValue: [0.678_431_372_549_019_6, 0.847_058_823_529_411_8, 0.901_960_784_313_725_5, 1], + loadOp: 'clear', + storeOp: 'store', + }, + ], + depthStencilAttachment: { + view: this.depthTextureAnother.createView(), + depthClearValue: 1, + depthLoadOp: 'clear', + depthStoreOp: 'store', + }, + } + + this.renderPassDescriptor = { + label: 'MainRenderPassDescriptor', + colorAttachments: [ + { + view: undefined as any, // Assigned later + clearValue: [0.678_431_372_549_019_6, 0.847_058_823_529_411_8, 0.901_960_784_313_725_5, 1], + loadOp: 'clear', + storeOp: 'store', + }, + ], + depthStencilAttachment: { + view: this.depthTexture.createView(), + depthClearValue: 1, + depthLoadOp: 'clear', + depthStoreOp: 'store', + }, + } + + // Create compute pipeline + const computeShaderModule = device.createShaderModule({ + code: localStorage.computeShader || ComputeShader, + label: 'Occlusion Writing', + }) + + const computeSortShaderModule = device.createShaderModule({ + code: ComputeSortShader, + label: 'Storage Texture Sorting', + }) + + const computeBindGroupLayout = device.createBindGroupLayout({ + label: 'computeBindGroupLayout', + entries: [ + { binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform' } }, + { binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } }, + { binding: 2, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } }, + { binding: 3, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } }, + { binding: 4, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } }, + { binding: 5, visibility: GPUShaderStage.COMPUTE, texture: { sampleType: 'depth' } }, + { binding: 6, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform' } }, + ], + }) + + const computeChunksLayout = device.createBindGroupLayout({ + label: 'computeChunksLayout', + entries: [ + { binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'read-only-storage' } }, + { binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } }, + { binding: 2, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } }, + { binding: 3, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform' } }, + ], + }) + + const textureSizeBindGroupLayout = device.createBindGroupLayout({ + entries: [ + { + binding: 0, + visibility: GPUShaderStage.COMPUTE, + buffer: { type: 'uniform' }, + }, + ], + }) + + const computePipelineLayout = device.createPipelineLayout({ + label: 'computePipelineLayout', + bindGroupLayouts: [computeBindGroupLayout, computeChunksLayout, textureSizeBindGroupLayout] + }) + + this.textureSizeBuffer = this.device.createBuffer({ + size: 8, // vec2 consists of two 32-bit unsigned integers + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }) + + + this.textureSizeBindGroup = device.createBindGroup({ + layout: textureSizeBindGroupLayout, + entries: [ + { + binding: 0, + resource: { + buffer: this.textureSizeBuffer, + }, + }, + ], + }) + + this.computePipeline = device.createComputePipeline({ + label: 'Culled Instance', + layout: computePipelineLayout, + // layout: 'auto', + compute: { + module: computeShaderModule, + entryPoint: 'main', + }, + }) + + this.computeSortPipeline = device.createComputePipeline({ + label: 'Culled Instance', + layout: computePipelineLayout, + // layout: 'auto', + compute: { + module: computeSortShaderModule, + entryPoint: 'main', + }, + }) + + this.indirectDrawBuffer = device.createBuffer({ + label: 'indirectDrawBuffer', + size: 16, // 4 uint32 values + usage: GPUBufferUsage.INDIRECT | GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC + }) + + this.indirectDrawBufferMap = device.createBuffer({ + label: 'indirectDrawBufferMap', + size: 16, // 4 uint32 values + usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ, + }) + + this.debugBuffer = device.createBuffer({ + label: 'debugBuffer', + size: 4 * 8192, + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC, + }) + + this.chunksBuffer = this.createVertexStorage(202_535 * 20, 'chunksBuffer') + this.occlusionTexture = this.createVertexStorage(4096 * 4096 * 4, 'occlusionTexture') + this.depthTextureBuffer = this.createVertexStorage(4096 * 4096 * 4, 'depthTextureBuffer') + + // Initialize indirect draw parameters + const indirectDrawParams = new Uint32Array([quadVertexCount, 0, 0, 0]) + device.queue.writeBuffer(this.indirectDrawBuffer, 0, indirectDrawParams) + + // initialize texture size + const textureSize = new Uint32Array([this.canvas.width, this.canvas.height]) + device.queue.writeBuffer(this.textureSizeBuffer, 0, textureSize) + + void device.lost.then((info) => { + console.warn('WebGPU context lost:', info) + postMessage({ type: 'rendererProblem', isContextLost: true, message: info.message }) + }) + + this.updateBlocksModelData() + this.createNewDataBuffers() + + this.indirectDrawParams = new Uint32Array([quadVertexCount, 0, 0, 0]) + + // always last! + this.loop(true) // start rendering + this.ready = true + return canvas + } + + async updateTexture (imageBlob: ImageBitmapSource, isInitial = false) { + const textureBitmap = await createImageBitmap(imageBlob) + this.AtlasTexture?.destroy() + this.AtlasTexture = this.device.createTexture({ + size: [textureBitmap.width, textureBitmap.height, 1], + format: 'rgba8unorm', + usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT, + //sampleCount: 4 + }) + this.device.queue.copyExternalImageToTexture( + { source: textureBitmap }, + { texture: this.AtlasTexture }, + [textureBitmap.width, textureBitmap.height] + ) + + this.device.queue.writeBuffer( + this.tileSizeUniform, + 0, + new Float32Array([16, 16]) + ) + + if (!isInitial) { + this.createUniformBindGroup() + } + } + + safeLoop (isFirst: boolean | undefined, time: number | undefined) { + try { + this.loop(isFirst, time) + } catch (err) { + console.error(err) + postMessage({ type: 'rendererProblem', isContextLost: false, message: err.message }) + } + } + + public updateBlocksModelData () { + const keys = Object.keys(this.blocksDataModel) + // const modelsDataLength = keys.length + const modelsDataLength = +keys.at(-1)! + const modelsBuffer = new Uint32Array(modelsDataLength * 2) + for (let i = 0; i < modelsDataLength; i++) { + const blockData = this.blocksDataModel[i]/* ?? { + textures: [0, 0, 0, 0, 0, 0], + rotation: [0, 0, 0, 0], + } */ + if (!blockData) throw new Error(`Block model ${i} not found`) + const tempBuffer1 = (((blockData.textures[0] << 10) | blockData.textures[1]) << 10) | blockData.textures[2] + const tempBuffer2 = (((blockData.textures[3] << 10) | blockData.textures[4]) << 10) | blockData.textures[5] + modelsBuffer[+i * 2] = tempBuffer1 + modelsBuffer[+i * 2 + 1] = tempBuffer2 + } + + this.modelsBuffer?.destroy() + this.modelsBuffer = this.createVertexStorage(modelsDataLength * cubeByteLength, 'modelsBuffer') + this.device.queue.writeBuffer(this.modelsBuffer, 0, modelsBuffer) + } + + private createUniformBindGroup () { + const { device, pipeline } = this + const sampler = device.createSampler({ + magFilter: 'nearest', + minFilter: 'nearest', + }) + + this.uniformBindGroup = device.createBindGroup({ + label: 'uniformBindGroups', + layout: pipeline.getBindGroupLayout(0), + entries: [ + { + binding: 0, + resource: + { + buffer: this.cameraUniform, + }, + }, + { + binding: 1, + resource: sampler, + }, + { + binding: 2, + resource: this.AtlasTexture.createView(), + }, + { + binding: 3, + resource: { + buffer: this.modelsBuffer + }, + }, + { + binding: 4, + resource: { + buffer: this.rotationsUniform + } + }, + { + binding: 5, + resource: { buffer: this.tileSizeUniform }, + } + ], + }) + + this.vertexCubeBindGroup = device.createBindGroup({ + label: 'vertexCubeBindGroup', + layout: pipeline.getBindGroupLayout(1), + entries: [ + { + binding: 0, + resource: { buffer: this.cubesBuffer }, + }, + { + binding: 1, + resource: { buffer: this.visibleCubesBuffer }, + }, + { + binding: 2, + resource: { buffer: this.chunksBuffer }, + } + ], + }) + + this.secondCameraUniformBindGroup = device.createBindGroup({ + label: 'uniformBindGroupsCamera', + layout: pipeline.getBindGroupLayout(0), + entries: [ + { + binding: 0, + resource: { + buffer: this.secondCameraUniform, + }, + }, + { + binding: 1, + resource: sampler, + }, + { + binding: 2, + resource: this.AtlasTexture.createView(), + }, + { + binding: 3, + resource: { + buffer: this.modelsBuffer + }, + }, + { + binding: 4, + resource: { + buffer: this.rotationsUniform + } + }, + { + binding: 5, + resource: { buffer: this.tileSizeUniform }, + } + ], + }) + + this.VolumetricBindGroup = device.createBindGroup({ + layout: this.volumetricPipeline.getBindGroupLayout(0), + label: 'volumtetricBindGroup', + entries: [ + { + binding: 0, + resource: this.depthTexture.createView(), + }, + { + binding: 1, + resource: sampler, + }, + { + binding: 2, + resource: this.tempTexture.createView(), + }, + { + binding: 3, + resource: { buffer: this.clearColorBuffer }, + } + ] + }) + + + + this.computeBindGroup = device.createBindGroup({ + layout: this.computePipeline.getBindGroupLayout(0), + label: 'computeBindGroup', + entries: [ + { + binding: 0, + resource: { buffer: this.cameraUniform }, + }, + { + binding: 1, + resource: { buffer: this.cubesBuffer }, + }, + { + binding: 2, + resource: { buffer: this.visibleCubesBuffer }, + }, + { + binding: 3, + resource: { buffer: this.indirectDrawBuffer }, + }, + { + binding: 4, + resource: { buffer: this.debugBuffer }, + }, + { + binding: 5, + resource: this.depthTexture.createView(), + }, + { + binding: 6, + resource: { buffer: this.earlyZRejectUniform }, + }, + ], + }) + + this.chunkBindGroup = device.createBindGroup({ + layout: this.computePipeline.getBindGroupLayout(1), + label: 'anotherComputeBindGroup', + entries: [ + { + binding: 0, + resource: { buffer: this.chunksBuffer }, + }, + { + binding: 1, + resource: { buffer: this.occlusionTexture }, + }, + { + binding: 2, + resource: { buffer: this.depthTextureBuffer }, + }, + { + binding: 3, + resource: { buffer: this.cameraComputePositionUniform }, + } + ], + }) + } + + async readDebugBuffer () { + const readBuffer = this.device.createBuffer({ + size: this.debugBuffer.size, + usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ, + }) + + const commandEncoder = this.device.createCommandEncoder() + commandEncoder.copyBufferToBuffer(this.debugBuffer, 0, readBuffer, 0, this.debugBuffer.size) + this.device.queue.submit([commandEncoder.finish()]) + + await readBuffer.mapAsync(GPUMapMode.READ) + const arrayBuffer = readBuffer.getMappedRange() + const debugData = new Uint32Array(arrayBuffer.slice(0, this.debugBuffer.size)) + readBuffer.unmap() + readBuffer.destroy() + return debugData + } + + createNewDataBuffers () { + const oldCubesBuffer = this.cubesBuffer + const oldVisibleCubesBuffer = this.visibleCubesBuffer + this.commandEncoder = this.device.createCommandEncoder() + + this.cubesBuffer = this.createVertexStorage(this.NUMBER_OF_CUBES * cubeByteLength, 'cubesBuffer') + + this.visibleCubesBuffer = this.createVertexStorage(this.NUMBER_OF_CUBES * cubeByteLength, 'visibleCubesBuffer') + + if (oldCubesBuffer) { + this.commandEncoder.copyBufferToBuffer(oldCubesBuffer, 0, this.cubesBuffer, 0, oldCubesBuffer.size) + this.commandEncoder.copyBufferToBuffer(oldVisibleCubesBuffer, 0, this.visibleCubesBuffer, 0, oldVisibleCubesBuffer.size) + this.device.queue.submit([this.commandEncoder.finish()]) + oldCubesBuffer.destroy() + oldVisibleCubesBuffer.destroy() + + } + + this.createUniformBindGroup() + } + + private createVertexStorage (size: number, label: string) { + return this.device.createBuffer({ + label, + size, + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC, + }) + } + + updateSides () { + } + + updateCubesBuffersDataFromLoop () { + const DEBUG_DATA = false + + const dataForBuffers = chunksStorage.getDataForBuffers() + if (!dataForBuffers) return + const { allBlocks, chunks, awaitingUpdateSize: updateSize, awaitingUpdateStart: updateOffset } = dataForBuffers + // console.log('updating', updateOffset, updateSize) + + const NUMBER_OF_CUBES_NEEDED = allBlocks.length + if (NUMBER_OF_CUBES_NEEDED > this.NUMBER_OF_CUBES) { + const NUMBER_OF_CUBES_OLD = this.NUMBER_OF_CUBES + while (NUMBER_OF_CUBES_NEEDED > this.NUMBER_OF_CUBES) this.NUMBER_OF_CUBES += 1_000_000 + + console.warn('extending number of cubes', NUMBER_OF_CUBES_OLD, '->', this.NUMBER_OF_CUBES, `(needed ${NUMBER_OF_CUBES_NEEDED})`) + console.time('recreate buffers') + this.createNewDataBuffers() + console.timeEnd('recreate buffers') + } + this.realNumberOfCubes = NUMBER_OF_CUBES_NEEDED + + const unique = new Set() + const debugCheckDuplicate = (first, second, third) => { + const key = `${first},${third}` + if (unique.has(key)) { + throw new Error(`Duplicate: ${key}`) + } + unique.add(key) + } + + const cubeFlatData = new Uint32Array(updateSize * 3) + const blocksToUpdate = allBlocks.slice(updateOffset, updateOffset + updateSize) + + // eslint-disable-next-line unicorn/no-for-loop + for (let i = 0; i < blocksToUpdate.length; i++) { + let first = 0 + let second = 0 + let third = 0 + const chunkBlock = blocksToUpdate[i] + + if (chunkBlock) { + const [x, y, z, block] = chunkBlock + // if (chunk.index !== block.chunk) { + // throw new Error(`Block chunk mismatch ${block.chunk} !== ${chunk.index}`) + // } + const positions = [x, y + this.rendererParams.cameraOffset[1], z] + const visibility = Array.from({ length: 6 }, (_, i) => (block.visibleFaces.includes(i) ? 1 : 0)) + + const tint = block.tint ?? [1, 1, 1] + const colors = tint.map(x => x * 255) + + first = ((block.modelId << 4 | positions[2]) << 10 | positions[1]) << 4 | positions[0] + const visibilityCombined = (visibility[0]) | + (visibility[1] << 1) | + (visibility[2] << 2) | + (visibility[3] << 3) | + (visibility[4] << 4) | + (visibility[5] << 5) + second = ((visibilityCombined << 8 | colors[2]) << 8 | colors[1]) << 8 | colors[0] + third = block.chunk! + } + + cubeFlatData[i * 3] = first + cubeFlatData[i * 3 + 1] = second + cubeFlatData[i * 3 + 2] = third + if (DEBUG_DATA && chunkBlock) { + debugCheckDuplicate(first, second, third) + } + } + + const { totalFromChunks } = this.updateChunks(chunks) + + if (DEBUG_DATA) { + const actualCount = allBlocks.length + if (totalFromChunks !== actualCount) { + reportError?.(new Error(`Buffers length mismatch: from chunks: ${totalFromChunks}, flat data: ${actualCount}`)) + } + } + + this.device.queue.writeBuffer(this.cubesBuffer, updateOffset * cubeByteLength, cubeFlatData) + + this.notRenderedBlockChanges++ + this.realNumberOfCubes = allBlocks.length + } + + updateChunks (chunks: Array<{ x: number, z: number, length: number }>, offset = 0) { + this.chunksCount = chunks.length + // this.commandEncoder = this.device.createCommandEncoder() + // this.chunksBuffer = this.createVertexStorage(chunks.length * 20, 'chunksBuffer') + // this.device.queue.submit([this.commandEncoder.finish()]) + const chunksBuffer = new Int32Array(this.chunksCount * 5) + let totalFromChunks = 0 + for (let i = 0; i < this.chunksCount; i++) { + const offset = i * 5 + const { x, z, length } = chunks[i]! + const chunkProgress = this.chunksFadeAnimationController.indexes[i]?.progress ?? 1 + chunksBuffer[offset] = x + chunksBuffer[offset + 1] = z + chunksBuffer[offset + 2] = chunkProgress * 255 + chunksBuffer[offset + 3] = totalFromChunks + chunksBuffer[offset + 4] = length + const cubesCount = length + totalFromChunks += cubesCount + } + this.device.queue.writeBuffer(this.chunksBuffer, offset, chunksBuffer) + return { totalFromChunks } + } + + lastCall = performance.now() + logged = false + camera2 = (() => { + const camera = new THREE.PerspectiveCamera() + camera.lookAt(0, -1, 0) + camera.position.set(150, 500, 150) + camera.fov = 100 + camera.updateMatrix() + return camera + })() + + lastLoopTime = performance.now() + + loop (forceFrame = false, time = performance.now()) { + if (this.destroyed) return + const nextFrame = () => { + requestAnimationFrame((time) => { + this.safeLoop(undefined, time) + }) + } + + if (!this.rendering) { + nextFrame() + if (!forceFrame) { + return + } + } + const start = performance.now() + const timeDiff = time - this.lastLoopTime + this.loopPre(timeDiff) + + const { device, cameraUniform: uniformBuffer, renderPassDescriptor, uniformBindGroup, pipeline, ctx, verticesBuffer } = this + + this.chunksFadeAnimationController.update(time) + // #region update camera + tweenJs.update() + const oldPos = this.camera.position.clone() + this.camera.position.x += this.rendererParams.cameraOffset[0] + this.camera.position.y += this.rendererParams.cameraOffset[1] + this.camera.position.z += this.rendererParams.cameraOffset[2] + + this.camera.updateProjectionMatrix() + this.camera.updateMatrix() + + const { projectionMatrix, matrix } = this.camera + const ViewProjectionMat4 = new THREE.Matrix4() + ViewProjectionMat4.multiplyMatrices(projectionMatrix, matrix.invert()) + const viewProjection = new Float32Array(ViewProjectionMat4.elements) + device.queue.writeBuffer( + uniformBuffer, + 0, + viewProjection + ) + + device.queue.writeBuffer( + this.earlyZRejectUniform, + 0, + new Uint32Array([this.rendererParams.earlyZRejection ? 1 : 0]) + ) + + + const cameraPosition = new Float32Array([this.camera.position.x, this.camera.position.y, this.camera.position.z]) + device.queue.writeBuffer( + this.cameraComputePositionUniform, + 0, + cameraPosition + ) + + this.camera.position.set(oldPos.x, oldPos.y, oldPos.z) + // #endregion + + // let { multisampleTexture } = this; + // // If the multisample texture doesn't exist or + // // is the wrong size then make a new one. + // if (multisampleTexture === undefined || + // multisampleTexture.width !== canvasTexture.width || + // multisampleTexture.height !== canvasTexture.height) { + + // // If we have an existing multisample texture destroy it. + // if (multisampleTexture) { + // multisampleTexture.destroy() + // } + + // // Create a new multisample texture that matches our + // // canvas's size + // multisampleTexture = device.createTexture({ + // format: canvasTexture.format, + // usage: GPUTextureUsage.RENDER_ATTACHMENT, + // size: [canvasTexture.width, canvasTexture.height], + // sampleCount: 4, + // }) + // this.multisampleTexture = multisampleTexture + // } + + + + // TODO! + if (this.rendererParams.godRays) { + renderPassDescriptor.colorAttachments[0].view = this.tempTexture.createView() + this.volumetricRenderPassDescriptor.colorAttachments[0].view = ctx + .getCurrentTexture() + .createView() + } else { + renderPassDescriptor.colorAttachments[0].view = ctx + .getCurrentTexture() + .createView() + } + + + // renderPassDescriptor.colorAttachments[0].view = + // multisampleTexture.createView(); + // // Set the canvas texture as the texture to "resolve" + // // the multisample texture to. + // renderPassDescriptor.colorAttachments[0].resolveTarget = + // canvasTexture.createView(); + + + this.commandEncoder = device.createCommandEncoder() + //this.commandEncoder.clearBuffer(this.occlusionTexture) + + //this.commandEncoder.clearBuffer(this.DepthTextureBuffer); + if (this.rendererParams.occlusionActive) { + this.commandEncoder.clearBuffer(this.occlusionTexture) + this.commandEncoder.clearBuffer(this.visibleCubesBuffer) + this.commandEncoder.clearBuffer(this.depthTextureBuffer) + device.queue.writeBuffer(this.indirectDrawBuffer, 0, this.indirectDrawParams) + } + // Compute pass for occlusion culling + const textureSize = new Uint32Array([this.canvas.width, this.canvas.height]) + device.queue.writeBuffer(this.textureSizeBuffer, 0, textureSize) + + if (this.realNumberOfCubes) { + if (this.rendererParams.occlusionActive) { + { + const computePass = this.commandEncoder.beginComputePass() + computePass.label = 'Frustrum/Occluision Culling' + computePass.setPipeline(this.computePipeline) + computePass.setBindGroup(0, this.computeBindGroup) + computePass.setBindGroup(1, this.chunkBindGroup) + computePass.setBindGroup(2, this.textureSizeBindGroup) + computePass.dispatchWorkgroups(Math.max(Math.ceil(this.chunksCount / 64), 65_535)) + computePass.end() + device.queue.submit([this.commandEncoder.finish()]) + } + { + this.commandEncoder = device.createCommandEncoder() + const computePass = this.commandEncoder.beginComputePass() + computePass.label = 'Texture Index Sorting' + computePass.setPipeline(this.computeSortPipeline) + computePass.setBindGroup(0, this.computeBindGroup) + computePass.setBindGroup(1, this.chunkBindGroup) + computePass.setBindGroup(2, this.textureSizeBindGroup) + computePass.dispatchWorkgroups(Math.ceil(this.canvas.width / 16), Math.ceil(this.canvas.height / 16)) + computePass.end() + if (!this.indirectDrawBufferMapBeingUsed) { + this.commandEncoder.copyBufferToBuffer(this.indirectDrawBuffer, 0, this.indirectDrawBufferMap, 0, 16) + } + device.queue.submit([this.commandEncoder.finish()]) + } + } + { + this.commandEncoder = device.createCommandEncoder() + const renderPass = this.commandEncoder.beginRenderPass(this.renderPassDescriptor) + renderPass.label = 'Voxel Main Pass' + renderPass.setPipeline(pipeline) + renderPass.setBindGroup(0, this.uniformBindGroup) + renderPass.setVertexBuffer(0, verticesBuffer) + renderPass.setBindGroup(1, this.vertexCubeBindGroup) + // Use indirect drawing + renderPass.drawIndirect(this.indirectDrawBuffer, 0) + if (this.rendererParams.secondCamera) { + renderPass.setBindGroup(0, this.secondCameraUniformBindGroup) + renderPass.setViewport(this.canvas.width / 2, this.canvas.height / 2, this.canvas.width / 2, this.canvas.height / 2, 0, 0) + renderPass.drawIndirect(this.indirectDrawBuffer, 0) + } + renderPass.end() + + + device.queue.submit([this.commandEncoder.finish()]) + } + // Volumetric lighting pass + if (this.rendererParams.godRays) { + this.commandEncoder = device.createCommandEncoder() + const volumtetricRenderPass = this.commandEncoder.beginRenderPass(this.volumetricRenderPassDescriptor) + volumtetricRenderPass.label = 'Volumetric Render Pass' + volumtetricRenderPass.setPipeline(this.volumetricPipeline) + volumtetricRenderPass.setVertexBuffer(0, verticesBuffer) + volumtetricRenderPass.setBindGroup(0, this.VolumetricBindGroup) + volumtetricRenderPass.draw(6) + volumtetricRenderPass.end() + device.queue.submit([this.commandEncoder.finish()]) + } + } + if (chunksStorage.updateQueue.length) { + // console.time('updateBlocks') + // eslint-disable-next-line unicorn/no-useless-spread + const queue = [...chunksStorage.updateQueue.slice(0, 0)] + let updateCount = 0 + for (const q of chunksStorage.updateQueue) { + queue.push(q) + updateCount += q.end - q.start + if (updateCount > chunksStorage.maxDataUpdate) { + break // to next frame + } + } + while (chunksStorage.updateQueue.length) { + this.updateCubesBuffersDataFromLoop() + } + for (const { start, end } of queue) { + chunksStorage.clearRange(start, end) + } + // console.timeEnd('updateBlocks') + } else if (this.chunksFadeAnimationController.updateWasMade) { + this.updateChunks(chunksStorage.chunks) + } + + if (!this.indirectDrawBufferMapBeingUsed && (!this.renderingStatsRequestTime || time - this.renderingStatsRequestTime > 500)) { + this.renderingStatsRequestTime = time + void this.getRenderingTilesCount().then((result) => { + this.renderingStats = result + }) + } + + this.loopPost() + + this.renderedFrames++ + nextFrame() + this.notRenderedBlockChanges = 0 + const took = performance.now() - start + this.renderMs += took + this.renderMsCount++ + if (took > 55) { + console.log('One frame render loop took', took) + } + } + + loopPre (timeDiff: number) { + if (!this.cameraUpdated) { + this.noCameraUpdates++ + if (this.lastCameraUpdateDiff && this.positiveCameraUpdates) { + const pos = {} as { x: number, y: number, z: number } + for (const key of ['x', 'y', 'z']) { + const msDiff = this.lastCameraUpdateDiff[key] / this.lastCameraUpdateDiff.time + pos[key] = this.camera.position[key] + msDiff * timeDiff + } + this.updateCameraPos(pos) + } + } + + } + + loopPost () { + this.cameraUpdated = false + } + + updateCameraPos (newPos: { x: number, y: number, z: number }) { + //this.camera.position.set(newPos.x, newPos.y, newPos.z) + new tweenJs.Tween(this.camera.position).to({ x: newPos.x, y: newPos.y, z: newPos.z }, 50).start() + } + + async getRenderingTilesCount () { + this.indirectDrawBufferMapBeingUsed = true + await this.indirectDrawBufferMap.mapAsync(GPUMapMode.READ) + const arrayBuffer = this.indirectDrawBufferMap.getMappedRange() + const data = new Uint32Array(arrayBuffer) + // Read the indirect draw parameters + const vertexCount = data[0] + const instanceCount = data[1] + const firstVertex = data[2] + const firstInstance = data[3] + this.indirectDrawBufferMap.unmap() + this.indirectDrawBufferMapBeingUsed = false + return { vertexCount, instanceCount, firstVertex, firstInstance } + } + + destroy () { + this.rendering = false + this.device.destroy() + } +} + +const debugCheckDuplicates = (arr: any[]) => { + const seen = new Set() + for (const item of arr) { + if (seen.has(item)) throw new Error(`Duplicate: ${item}`) + seen.add(item) + } +} + +class IndexedInOutAnimationController { + lastUpdateTime?: number + indexes: Record void }> = {} + updateWasMade = false + + constructor (public updateIndex: (key: string, progress: number, removed: boolean) => void, public DURATION = 500) { } + + update (time: number) { + this.updateWasMade = false + this.lastUpdateTime ??= time + // eslint-disable-next-line guard-for-in + for (const key in this.indexes) { + const data = this.indexes[key] + const timeDelta = (time - this.lastUpdateTime) / this.DURATION + let removed = false + if (data.isAdding) { + data.progress += timeDelta + if (data.progress >= 1) { + delete this.indexes[key] + } + } else { + data.progress -= timeDelta + if (data.progress <= 0) { + delete this.indexes[key] + removed = true + data.onRemoved?.() + } + } + this.updateIndex(key, data.progress, removed) + this.updateWasMade = true + } + this.lastUpdateTime = time + } + + addIndex (key: string) { + this.indexes[key] = { progress: 0, isAdding: true } + } + + removeIndex (key: string, onRemoved?: () => void) { + if (this.indexes[key]) { + this.indexes[key].isAdding = false + this.indexes[key].onRemoved = onRemoved + } else { + this.indexes[key] = { progress: 1, isAdding: false, onRemoved } + } + } +} diff --git a/prismarine-viewer/examples/webgpuRendererShared.ts b/prismarine-viewer/examples/webgpuRendererShared.ts new file mode 100644 index 000000000..58b408569 --- /dev/null +++ b/prismarine-viewer/examples/webgpuRendererShared.ts @@ -0,0 +1,32 @@ +const workerParam = new URLSearchParams(typeof window === 'undefined' ? '?' : window.location.search).get('webgpuWorker') +const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent) + +export const defaultWebgpuRendererParams = { + secondCamera: false, + MSAA: false, + cameraOffset: [0, 0, 0] as [number, number, number], + webgpuWorker: workerParam ? workerParam === 'true' : !isSafari, + godRays: true, + occlusionActive: true, + earlyZRejection: false, + allowChunksViewUpdate: false +} + +export const rendererParamsGui = { + secondCamera: true, + MSAA: true, + webgpuWorker: { + qsReload: true + }, + godRays: true, + occlusionActive: true, + earlyZRejection: true, + allowChunksViewUpdate: true +} + +export const WEBGPU_FULL_TEXTURES_LIMIT = 1024 +export const WEBGPU_HEIGHT_LIMIT = 1024 + +export type RendererInitParams = GPURequestAdapterOptions & {} + +export type RendererParams = typeof defaultWebgpuRendererParams diff --git a/prismarine-viewer/examples/webgpuRendererWorker.ts b/prismarine-viewer/examples/webgpuRendererWorker.ts new file mode 100644 index 000000000..229555106 --- /dev/null +++ b/prismarine-viewer/examples/webgpuRendererWorker.ts @@ -0,0 +1,310 @@ +/// +import * as THREE from 'three' +import * as tweenJs from '@tweenjs/tween.js' +import { BlockFaceType, BlockType, makeError } from './shared' +import { createWorkerProxy } from './workerProxy' +import { WebgpuRenderer } from './webgpuRenderer' +import { RendererInitParams, RendererParams } from './webgpuRendererShared' +import { ChunksStorage } from './chunksStorage' + +export const chunksStorage = new ChunksStorage() +globalThis.chunksStorage = chunksStorage + +let animationTick = 0 +let maxFps = 0 + +const camera = new THREE.PerspectiveCamera(75, 1 / 1, 0.1, 10_000) +globalThis.camera = camera + +let webgpuRenderer: WebgpuRenderer | undefined + +export const postMessage = (data, ...args) => { + if (globalThis.webgpuRendererChannel) { + globalThis.webgpuRendererChannel.port2.postMessage(data, ...args) + } else { + globalThis.postMessage(data, ...args) + } +} + +setInterval(() => { + if (!webgpuRenderer) return + // console.log('FPS:', renderedFrames) + const renderMsAvg = (webgpuRenderer.renderMs / webgpuRenderer.renderMsCount).toFixed(0) + postMessage({ type: 'fps', fps: `${webgpuRenderer.renderedFrames} (${new Intl.NumberFormat().format(chunksStorage.lastFetchedSize)} blocks,${renderMsAvg}ms)` }) + webgpuRenderer.noCameraUpdates = 0 + webgpuRenderer.renderedFrames = 0 + webgpuRenderer.renderMs = 0 + webgpuRenderer.renderMsCount = 0 +}, 1000) + +setInterval(() => { + postMessage({ + type: 'stats', + stats: `Rendering Tiles: ${formatLargeNumber(webgpuRenderer?.renderingStats?.instanceCount ?? -1, false)} Buffer: ${formatLargeNumber(webgpuRenderer?.NUMBER_OF_CUBES ?? -1)}`, + device: webgpuRenderer?.rendererDeviceString, + }) +}, 300) + +const formatLargeNumber = (number: number, compact = true) => { + return new Intl.NumberFormat(undefined, { notation: compact ? 'compact' : 'standard', compactDisplay: 'short' }).format(number) +} + +export const updateSize = (width, height) => { + camera.aspect = width / height + camera.updateProjectionMatrix() +} + + +// const updateCubesWhenAvailable = () => { +// onceRendererAvailable((renderer) => { +// renderer.updateSides() +// }) +// } + +let requests = [] as Array<{ resolve: () => void }> +let requestsNamed = {} as Record void> +const onceRendererAvailable = (request: (renderer: WebgpuRenderer) => any, name?: string) => { + if (webgpuRenderer?.ready) { + request(webgpuRenderer) + } else { + requests.push({ resolve: () => request(webgpuRenderer!) }) + if (name) { + requestsNamed[name] = () => request(webgpuRenderer!) + } + } +} + +const availableUpCheck = setInterval(() => { + const { ready } = webgpuRenderer ?? {} + if (ready) { + clearInterval(availableUpCheck) + for (const request of requests) { + request.resolve() + } + requests = [] + for (const request of Object.values(requestsNamed)) { + request() + } + requestsNamed = {} + } +}, 100) + +let started = false +let autoTickUpdate = undefined as number | undefined + +export const workerProxyType = createWorkerProxy({ + // eslint-disable-next-line max-params + canvas (canvas, imageBlob, isPlayground, localStorage, blocksDataModel, initConfig: RendererInitParams) { + if (globalThis.webgpuRendererChannel) { + // HACK! IOS safari bug: no support for transferControlToOffscreen in the same context! so we create a new canvas here! + const newCanvas = document.createElement('canvas') + newCanvas.width = canvas.width + newCanvas.height = canvas.height + canvas = newCanvas + // remove existing canvas + document.querySelector('#viewer-canvas')!.remove() + canvas.id = 'viewer-canvas' + document.body.appendChild(canvas) + } + started = true + webgpuRenderer = new WebgpuRenderer(canvas, imageBlob, isPlayground, camera, localStorage, blocksDataModel, initConfig) + globalThis.webgpuRenderer = webgpuRenderer + postMessage({ type: 'webgpuRendererReady' }) + }, + startRender () { + if (!webgpuRenderer) return + webgpuRenderer.rendering = true + }, + stopRender () { + if (!webgpuRenderer) return + webgpuRenderer.rendering = false + }, + resize (newWidth, newHeight) { + updateSize(newWidth, newHeight) + }, + updateConfig (params: RendererParams) { + // when available + onceRendererAvailable(() => { + webgpuRenderer!.updateConfig(params) + }) + }, + getFaces () { + const faces = [] as any[] + const getFace = (face: number) => { + // if (offsetZ / 16) debugger + return { + side: face, + textureIndex: Math.floor(Math.random() * 512) + // textureIndex: offsetZ / 16 === 31 ? 2 : 1 + } + } + for (let i = 0; i < 6; i++) { + faces.push(getFace(i)) + } + return faces + }, + generateRandom (count: number, offsetX = 0, offsetZ = 0, yOffset = 0, model = 0) { + const square = Math.sqrt(count) + if (square % 1 !== 0) throw new Error('square must be a whole number') + const blocks = {} as Record + for (let x = offsetX; x < square + offsetX; x++) { + for (let z = offsetZ; z < square + offsetZ; z++) { + blocks[`${x},${yOffset},${z}`] = { + visibleFaces: [0, 1, 2, 3, 4, 5], + modelId: model || Math.floor(Math.random() * 3000), + block: '', + } satisfies BlockType + } + } + // console.log('generated random data:', count) + this.addBlocksSection(blocks, `${offsetX},${yOffset},${offsetZ}`) + }, + updateMaxFps (fps) { + maxFps = fps + }, + updateModels (blocksDataModel: WebgpuRenderer['blocksDataModel']) { + webgpuRenderer!.blocksDataModel = blocksDataModel + webgpuRenderer!.updateBlocksModelData() + }, + addAddBlocksFlat (positions: number[]) { + const chunks = new Map() + for (let i = 0; i < positions.length; i += 3) { + const x = positions[i] + const y = positions[i + 1] + const z = positions[i + 2] + + const xChunk = Math.floor(x / 16) * 16 + const zChunk = Math.floor(z / 16) * 16 + const key = `${xChunk},${0},${zChunk}` + if (!chunks.has(key)) chunks.set(key, {}) + chunks.get(key)![`${x},${y},${z}`] = { + faces: this.getFaces() + } + } + for (const [key, value] of chunks) { + this.addBlocksSection(value, key) + } + }, + addBlocksSection (tiles: Record, key: string, animate = true) { + const index = chunksStorage.addChunk(tiles, key) + if (animate && webgpuRenderer) { + webgpuRenderer.chunksFadeAnimationController.addIndex(`${index}`) + } + }, + addBlocksSectionDone () { + }, + updateTexture (imageBlob: Blob) { + if (!webgpuRenderer) return + void webgpuRenderer.updateTexture(imageBlob) + }, + removeBlocksSection (key) { + if (webgpuRenderer) { + webgpuRenderer.chunksFadeAnimationController.removeIndex(key, () => { + chunksStorage.removeChunk(key) + }) + } + }, + debugCameraMove ({ x = 0, y = 0, z = 0 }) { + webgpuRenderer!.debugCameraMove = { x, y, z } + }, + camera (newCam: { rotation: { x: number, y: number, z: number }, position: { x: number, y: number, z: number }, fov: number }) { + const oldPos = camera.position.clone() + camera.rotation.set(newCam.rotation.x, newCam.rotation.y, newCam.rotation.z, 'ZYX') + if (!webgpuRenderer || (camera.position.x === 0 && camera.position.y === 0 && camera.position.z === 0)) { + // initial camera position + camera.position.set(newCam.position.x, newCam.position.y, newCam.position.z) + } else { + webgpuRenderer?.updateCameraPos(newCam.position) + } + + if (newCam.fov !== camera.fov) { + camera.fov = newCam.fov + camera.updateProjectionMatrix() + } + if (webgpuRenderer) { + webgpuRenderer.cameraUpdated = true + if (webgpuRenderer.lastCameraUpdateTime) { + webgpuRenderer.lastCameraUpdateDiff = { + x: oldPos.x - camera.position.x, + y: oldPos.y - camera.position.y, + z: oldPos.z - camera.position.z, + time: performance.now() - webgpuRenderer.lastCameraUpdateTime + } + } + webgpuRenderer.lastCameraUpdateTime = performance.now() + } + }, + animationTick (frames, tick) { + if (frames <= 0) { + autoTickUpdate = undefined + animationTick = 0 + return + } + if (tick === -1) { + autoTickUpdate = frames + } else { + autoTickUpdate = undefined + animationTick = tick % 20 // todo update automatically in worker + } + }, + fullDataReset () { + if (chunksStorage.chunksMap.size) { + console.warn('fullReset: chunksMap not empty', chunksStorage.chunksMap) + } + // todo clear existing ranges with limit + chunksStorage.clearData() + }, + exportData () { + const exported = exportData() + // postMessage({ type: 'exportData', data: exported }, undefined as any, [exported.sides.buffer]) + }, + loadFixture (json) { + // allSides = json.map(([x, y, z, face, textureIndex]) => { + // return [x, y, z, { face, textureIndex }] as [number, number, number, BlockFaceType] + // }) + // const dataSize = json.length / 5 + // for (let i = 0; i < json.length; i += 5) { + // chunksStorage.allSides.push([json[i], json[i + 1], json[i + 2], { side: json[i + 3], textureIndex: json[i + 4] }]) + // } + // updateCubesWhenAvailable(0) + }, + updateBackground (color) { + onceRendererAvailable((renderer) => { + renderer.changeBackgroundColor(color) + }, 'updateBackground') + }, + destroy () { + chunksStorage.clearData() + webgpuRenderer?.destroy() + } +}, globalThis.webgpuRendererChannel?.port2) + +// globalThis.testDuplicates = () => { +// const duplicates = [...chunksStorage.getDataForBuffers().allSides].flat().filter((value, index, self) => self.indexOf(value) !== index) +// console.log('duplicates', duplicates) +// } + +const exportData = () => { + // const allSides = [...chunksStorage.getDataForBuffers().allSides].flat() + + // // Calculate the total length of the final array + // const totalLength = allSides.length * 5 + + // // Create a new Int16Array with the total length + // const flatData = new Int16Array(totalLength) + + // // Fill the flatData array + // for (const [i, sideData] of allSides.entries()) { + // if (!sideData) continue + // const [x, y, z, side] = sideData + // // flatData.set([x, y, z, side.side, side.textureIndex], i * 5) + // } + + // return { sides: flatData } +} + +setInterval(() => { + if (autoTickUpdate) { + animationTick = (animationTick + 1) % autoTickUpdate + } +}, 1000 / 20) diff --git a/prismarine-viewer/viewer/lib/workerProxy.ts b/prismarine-viewer/examples/workerProxy.ts similarity index 78% rename from prismarine-viewer/viewer/lib/workerProxy.ts rename to prismarine-viewer/examples/workerProxy.ts index a27c817d9..9d8e7fcc0 100644 --- a/prismarine-viewer/viewer/lib/workerProxy.ts +++ b/prismarine-viewer/examples/workerProxy.ts @@ -1,5 +1,6 @@ -export function createWorkerProxy void>> (handlers: T): { __workerProxy: T } { - addEventListener('message', (event) => { +export function createWorkerProxy void>> (handlers: T, channel?: MessagePort): { __workerProxy: T } { + const target = channel ?? globalThis + target.addEventListener('message', (event: any) => { const { type, args } = event.data if (handlers[type]) { handlers[type](...args) @@ -19,7 +20,7 @@ export function createWorkerProxy v * const workerChannel = useWorkerProxy(worker) * ``` */ -export const useWorkerProxy = void> }> (worker: Worker, autoTransfer = true): T['__workerProxy'] & { +export const useWorkerProxy = void> }> (worker: Worker | MessagePort, autoTransfer = true): T['__workerProxy'] & { transfer: (...args: Transferable[]) => T['__workerProxy'] } => { // in main thread @@ -40,11 +41,11 @@ export const useWorkerProxy = { - const transfer = autoTransfer ? args.filter(arg => arg instanceof ArrayBuffer || arg instanceof MessagePort || arg instanceof ImageBitmap || arg instanceof OffscreenCanvas) : [] + const transfer = autoTransfer ? args.filter(arg => arg instanceof ArrayBuffer || arg instanceof MessagePort || arg instanceof ImageBitmap || arg instanceof OffscreenCanvas || arg instanceof ImageData) : [] worker.postMessage({ type: prop, args, - }, transfer) + }, transfer as any[]) } } }) diff --git a/prismarine-viewer/package.json b/prismarine-viewer/package.json index 02b0a304d..c29871b81 100644 --- a/prismarine-viewer/package.json +++ b/prismarine-viewer/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "@tweenjs/tween.js": "^20.0.3", + "live-server": "^1.2.2", "assert": "^2.0.0", "buffer": "^6.0.3", "filesize": "^10.0.12", diff --git a/prismarine-viewer/playground.html b/prismarine-viewer/playground.html index ec4c0f33c..258426feb 100644 --- a/prismarine-viewer/playground.html +++ b/prismarine-viewer/playground.html @@ -11,11 +11,17 @@ html, body { height: 100%; + touch-action: none; margin: 0; padding: 0; } + * { + user-select: none; + -webkit-user-select: none; + } + canvas { height: 100%; width: 100%; diff --git a/prismarine-viewer/rsbuildSharedConfig.ts b/prismarine-viewer/rsbuildSharedConfig.ts index 24a29a26d..8784ae656 100644 --- a/prismarine-viewer/rsbuildSharedConfig.ts +++ b/prismarine-viewer/rsbuildSharedConfig.ts @@ -1,7 +1,18 @@ import { defineConfig, ModifyRspackConfigUtils } from '@rsbuild/core'; import { pluginNodePolyfill } from '@rsbuild/plugin-node-polyfill'; import { pluginReact } from '@rsbuild/plugin-react'; +import { pluginBasicSsl } from '@rsbuild/plugin-basic-ssl' import path from 'path' +import fs from 'fs' + +let releaseTag +let releaseChangelog + +if (fs.existsSync('./assets/release.json')) { + const releaseJson = JSON.parse(fs.readFileSync('./assets/release.json', 'utf8')) + releaseTag = releaseJson.latestTag + releaseChangelog = releaseJson.changelog?.replace(//, '') +} export const appAndRendererSharedConfig = () => defineConfig({ dev: { @@ -11,6 +22,7 @@ export const appAndRendererSharedConfig = () => defineConfig({ paths: [ path.join(__dirname, './dist/webgpuRendererWorker.js'), path.join(__dirname, './dist/mesher.js'), + path.join(__dirname, './dist/integratedServer.js'), ] }, }, @@ -39,6 +51,10 @@ export const appAndRendererSharedConfig = () => defineConfig({ }, define: { 'process.platform': '"browser"', + 'process.env.GITHUB_URL': + JSON.stringify(`https://github.com/${process.env.GITHUB_REPOSITORY || `${process.env.VERCEL_GIT_REPO_OWNER}/${process.env.VERCEL_GIT_REPO_SLUG}`}`), + 'process.env.RELEASE_TAG': JSON.stringify(releaseTag), + 'process.env.RELEASE_CHANGELOG': JSON.stringify(releaseChangelog), }, decorators: { version: 'legacy', // default is a lie @@ -56,7 +72,8 @@ export const appAndRendererSharedConfig = () => defineConfig({ }, plugins: [ pluginReact(), - pluginNodePolyfill() + pluginNodePolyfill(), + ...process.env.ENABLE_HTTPS ? [pluginBasicSsl()] : [] ], tools: { rspack (config, helpers) { diff --git a/prismarine-viewer/sharedBuildOptions.mjs b/prismarine-viewer/sharedBuildOptions.mjs new file mode 100644 index 000000000..52b2fb8fc --- /dev/null +++ b/prismarine-viewer/sharedBuildOptions.mjs @@ -0,0 +1,3 @@ +export const sharedPlaygroundMainOptions = { + alias: {} +} \ No newline at end of file diff --git a/prismarine-viewer/viewer/lib/mesher/getPreflatBlock.ts b/prismarine-viewer/viewer/lib/mesher/getPreflatBlock.ts new file mode 100644 index 000000000..6c9ebfd5a --- /dev/null +++ b/prismarine-viewer/viewer/lib/mesher/getPreflatBlock.ts @@ -0,0 +1,30 @@ +import legacyJson from '../../../../src/preflatMap.json' + +export const getPreflatBlock = (block, reportIssue?: () => void) => { + const b = block + b._properties = {} + + const namePropsStr = legacyJson.blocks[b.type + ':' + b.metadata] || findClosestLegacyBlockFallback(b.type, b.metadata, reportIssue) + if (namePropsStr) { + b.name = namePropsStr.split('[')[0] + const propsStr = namePropsStr.split('[')?.[1]?.split(']') + if (propsStr) { + const newProperties = Object.fromEntries(propsStr.join('').split(',').map(x => { + let [key, val] = x.split('=') + if (!isNaN(val)) val = parseInt(val, 10) + return [key, val] + })) + b._properties = newProperties + } + } + return b +} + +const findClosestLegacyBlockFallback = (id, metadata, reportIssue) => { + reportIssue?.() + for (const [key, value] of Object.entries(legacyJson.blocks)) { + const [idKey, meta] = key.split(':') + if (idKey === id) return value + } + return null +} diff --git a/prismarine-viewer/viewer/lib/mesher/mesher.ts b/prismarine-viewer/viewer/lib/mesher/mesher.ts index 7c120abc1..bb6bb2672 100644 --- a/prismarine-viewer/viewer/lib/mesher/mesher.ts +++ b/prismarine-viewer/viewer/lib/mesher/mesher.ts @@ -1,6 +1,6 @@ import { Vec3 } from 'vec3' import { World } from './world' -import { getSectionGeometry, setBlockStatesData as setMesherData } from './models' +import { getSectionGeometry, setBlockStatesData as setMesherData, setSpecialBlockState, setWorld, world } from './models' if (module.require) { // If we are in a node environement, we need to fake some env variables @@ -12,7 +12,6 @@ if (module.require) { } let workerIndex = 0 -let world: World let dirtySections = new Map() let allDataReady = false @@ -20,7 +19,7 @@ function sectionKey (x, y, z) { return `${x},${y},${z}` } -const batchMessagesLimit = 100 +const batchMessagesLimit = 1 let queuedMessages = [] as any[] let queueWaiting = false @@ -62,12 +61,6 @@ function setSectionDirty (pos, value = true) { } } -const softCleanup = () => { - // clean block cache and loaded chunks - world = new World(world.config.version) - globalThis.world = world -} - const handleMessage = data => { const globalVar: any = globalThis @@ -82,7 +75,9 @@ const handleMessage = data => { world.erroredBlockModel = undefined } - world ??= new World(data.config.version) + if (!world) { + setWorld(new World(data.config.version)) + } world.config = { ...world.config, ...data.config } globalThis.world = world } @@ -95,6 +90,10 @@ const handleMessage = data => { break } + case 'webgpuData': { + world.setDataForWebgpuRenderer(data.data) + break + } case 'dirty': { const loc = new Vec3(data.x, data.y, data.z) setSectionDirty(loc, data.value) @@ -108,7 +107,6 @@ const handleMessage = data => { } case 'unloadChunk': { world.removeColumn(data.x, data.z) - if (Object.keys(world.columns).length === 0) softCleanup() break } @@ -118,8 +116,13 @@ const handleMessage = data => { break } + case 'specialBlockState': { + setSpecialBlockState(data.data) + + break + } case 'reset': { - world = undefined as any + setWorld(undefined) // blocksStates = null dirtySections = new Map() // todo also remove cached @@ -128,7 +131,7 @@ const handleMessage = data => { break } - // No default + // No default } } diff --git a/prismarine-viewer/viewer/lib/mesher/models.ts b/prismarine-viewer/viewer/lib/mesher/models.ts index 51a00a448..e40a14687 100644 --- a/prismarine-viewer/viewer/lib/mesher/models.ts +++ b/prismarine-viewer/viewer/lib/mesher/models.ts @@ -23,6 +23,16 @@ for (const key of Object.keys(tintsData)) { tints[key] = prepareTints(tintsData[key]) } +let specialBlockState: undefined | Record +export const setSpecialBlockState = (blockState) => { + specialBlockState = blockState +} +// eslint-disable-next-line import/no-mutable-exports +export let world: World +export const setWorld = (_world) => { + world = _world +} + type Tiles = { [blockPos: string]: BlockType } @@ -74,13 +84,13 @@ export function preflatBlockCalculation (block: Block, world: World, position: V } // case 'gate_in_wall': {} case 'block_snowy': { - const aboveIsSnow = world.getBlock(position.offset(0, 1, 0))?.name === 'snow' - if (aboveIsSnow) { + const aboveIsSnow = `${world.getBlock(position.offset(0, 1, 0))?.name === 'snow'}` + if (aboveIsSnow === block.getProperties().snowy) { + return + } else { return { - snowy: `${aboveIsSnow}` + snowy: aboveIsSnow } - } else { - return } } case 'door': { @@ -121,14 +131,18 @@ function getLiquidRenderHeight (world, block, type, pos) { const isCube = (block: Block) => { if (!block || block.transparent) return false if (block.isCube) return true - if (!block.models?.length || block.models.length !== 1) return false + if (!block.models?.length || block.models.length !== 1 || !block.models[0]) return false // all variants - return block.models[0].every(v => v.elements!.every(e => { + return block.models[0]?.every(v => v.elements!?.every(e => { return e.from[0] === 0 && e.from[1] === 0 && e.from[2] === 0 && e.to[0] === 16 && e.to[1] === 16 && e.to[2] === 16 })) } -function renderLiquid (world: World, cursor: Vec3, texture: any | undefined, type: number, biome: string, water: boolean, attr: Record) { +const addPossibleTileCheck = (attr, pos, side) => { + +} + +function renderLiquid (world, cursor, texture, type, biome, water, attr, stateId) { const heights: number[] = [] for (let z = -1; z <= 1; z++) { for (let x = -1; x <= 1; x++) { @@ -145,17 +159,17 @@ function renderLiquid (world: World, cursor: Vec3, texture: any | undefined, typ // eslint-disable-next-line guard-for-in for (const face in elemFaces) { - const { dir, corners } = elemFaces[face] + const { dir, corners, webgpuSide } = elemFaces[face] const isUp = dir[1] === 1 - const neighborPos = cursor.offset(...dir as [number, number, number]) + const neighborPos = cursor.offset(...dir) const neighbor = world.getBlock(neighborPos) if (!neighbor) continue if (neighbor.type === type) continue const isGlass = neighbor.name.includes('glass') if ((isCube(neighbor) && !isUp) || neighbor.material === 'plant' || neighbor.getProperties().waterlogged) continue - let tint = [1, 1, 1] + let tint = [1, 1, 1] as [number, number, number] if (water) { let m = 1 // Fake lighting to improve lisibility if (Math.abs(dir[0]) > 0) m = 0.6 @@ -166,17 +180,24 @@ function renderLiquid (world: World, cursor: Vec3, texture: any | undefined, typ if (needTiles) { const tiles = attr.tiles as Tiles - tiles[`${cursor.x},${cursor.y},${cursor.z}`] ??= { - block: 'water', - faces: [], + const model = world.webgpuModelsMapping[stateId] + // TODO height + if (model) { + tiles[`${cursor.x},${cursor.y},${cursor.z}`] ??= { + block: 'water', + visibleFaces: [], + modelId: model, + tint, + } + tiles[`${cursor.x},${cursor.y},${cursor.z}`].visibleFaces.push(webgpuSide) } - tiles[`${cursor.x},${cursor.y},${cursor.z}`].faces.push({ - face, - neighbor: `${neighborPos.x},${neighborPos.y},${neighborPos.z}`, - side: 0, // todo - textureIndex: 0, - // texture: eFace.texture.name, - }) + // tiles[`${cursor.x},${cursor.y},${cursor.z}`].faces.push({ + // face, + // neighbor: `${neighborPos.x},${neighborPos.y},${neighborPos.z}`, + // side: 0, // todo + // textureIndex: 0, + // // texture: eFace.texture.name, + // }) } const { u } = texture @@ -184,16 +205,18 @@ function renderLiquid (world: World, cursor: Vec3, texture: any | undefined, typ const { su } = texture const { sv } = texture - for (const pos of corners) { - const height = cornerHeights[pos[2] * 2 + pos[0]] - attr.t_positions.push( - (pos[0] ? 0.999 : 0.001) + (cursor.x & 15) - 8, - (pos[1] ? height - 0.001 : 0.001) + (cursor.y & 15) - 8, - (pos[2] ? 0.999 : 0.001) + (cursor.z & 15) - 8 - ) - attr.t_normals.push(...dir) - attr.t_uvs.push(pos[3] * su + u, pos[4] * sv * (pos[1] ? 1 : height) + v) - attr.t_colors.push(tint[0], tint[1], tint[2]) + if (!needTiles) { + for (const pos of corners) { + const height = cornerHeights[pos[2] * 2 + pos[0]] + attr.t_positions.push( + (pos[0] ? 0.999 : 0.001) + (cursor.x & 15) - 8, + (pos[1] ? height - 0.001 : 0.001) + (cursor.y & 15) - 8, + (pos[2] ? 0.999 : 0.001) + (cursor.z & 15) - 8 + ) + attr.t_normals.push(...dir) + attr.t_uvs.push(pos[3] * su + u, pos[4] * sv * (pos[1] ? 1 : height) + v) + attr.t_colors.push(tint[0], tint[1], tint[2]) + } } } } @@ -238,6 +261,11 @@ const identicalCull = (currentElement: BlockElement, neighbor: Block, direction: let needSectionRecomputeOnChange = false +// todo remove +const hasModelForNeighbor = (world: World, block: Block) => { + return !!world.webgpuModelsMapping[block.stateId!]/* ?? world.webgpuModelsMapping[-1] */ +} + function renderElement (world: World, cursor: Vec3, element: BlockElement, doAO: boolean, attr: MesherGeometryOutput, globalMatrix: any, globalShift: any, block: Block, biome: string) { const position = cursor // const key = `${position.x},${position.y},${position.z}` @@ -247,16 +275,19 @@ function renderElement (world: World, cursor: Vec3, element: BlockElement, doAO: // eslint-disable-next-line guard-for-in for (const face in element.faces) { const eFace = element.faces[face] - const { corners, mask1, mask2, side } = elemFaces[face] + const { corners, mask1, mask2, webgpuSide } = elemFaces[face] const dir = matmul3(globalMatrix, elemFaces[face].dir) if (eFace.cullface) { const neighbor = world.getBlock(cursor.plus(new Vec3(...dir)), blockProvider, {}) if (neighbor) { - if (cullIfIdentical && neighbor.stateId === block.stateId) continue - if (!neighbor.transparent && (isCube(neighbor) || identicalCull(element, neighbor, new Vec3(...dir)))) continue + if (hasModelForNeighbor(world, block)) { + if (cullIfIdentical && neighbor.stateId === block.stateId) continue + if (!neighbor.transparent && (isCube(neighbor) || identicalCull(element, neighbor, new Vec3(...dir)))) continue + } } else { needSectionRecomputeOnChange = true + // TODO support sync worlds continue } } @@ -298,8 +329,6 @@ function renderElement (world: World, cursor: Vec3, element: BlockElement, doAO: if (face === 'down') { r += 180 } - const uvcs = Math.cos(r * Math.PI / 180) - const uvsn = -Math.sin(r * Math.PI / 180) let localMatrix = null as any let localShift = null as any @@ -332,6 +361,8 @@ function renderElement (world: World, cursor: Vec3, element: BlockElement, doAO: ] if (!needTiles) { // 10% + const uvcs = Math.cos(r * Math.PI / 180) + const uvsn = -Math.sin(r * Math.PI / 180) vertex = vecadd3(matmul3(localMatrix, vertex), localShift) vertex = vecadd3(matmul3(globalMatrix, vertex), globalShift) vertex = vertex.map(v => v / 16) @@ -350,8 +381,9 @@ function renderElement (world: World, cursor: Vec3, element: BlockElement, doAO: } let light = 1 - const { smoothLighting } = world.config - // const smoothLighting = true + // const { smoothLighting } = world.config + const smoothLighting = false + doAO = false if (doAO) { const dx = pos[0] * 2 - 1 const dy = pos[1] * 2 - 1 @@ -405,24 +437,30 @@ function renderElement (world: World, cursor: Vec3, element: BlockElement, doAO: if (needTiles) { const tiles = attr.tiles as Tiles - tiles[`${cursor.x},${cursor.y},${cursor.z}`] ??= { - block: block.name, - faces: [], - } - const needsOnlyOneFace = false - const isTilesEmpty = tiles[`${cursor.x},${cursor.y},${cursor.z}`].faces.length < 1 - if (isTilesEmpty || !needsOnlyOneFace) { - tiles[`${cursor.x},${cursor.y},${cursor.z}`].faces.push({ - face, - side, - textureIndex: eFace.texture.tileIndex, - neighbor: `${neighborPos.x},${neighborPos.y},${neighborPos.z}`, - light: baseLight, - tint: lightWithColor, - //@ts-expect-error debug prop - texture: eFace.texture.debugName || block.name, - } satisfies BlockType['faces'][number]) + const model = world.webgpuModelsMapping[block.stateId!]/* ?? world.webgpuModelsMapping[-1] */ + if (model !== undefined) { + if (specialBlockState?.value === 'highlight' && specialBlockState.position.x === cursor.x && specialBlockState.position.y === cursor.y && specialBlockState.position.z === cursor.z) { + lightWithColor[0] *= 0.5 + lightWithColor[1] *= 0.5 + lightWithColor[2] *= 0.5 + } + tiles[`${cursor.x},${cursor.y},${cursor.z}`] ??= { + block: block.name, + visibleFaces: [], + modelId: model, + tint: lightWithColor[0] === 1 && lightWithColor[1] === 1 && lightWithColor[2] === 1 ? undefined : lightWithColor, + } + tiles[`${cursor.x},${cursor.y},${cursor.z}`].visibleFaces.push(webgpuSide) } + // tiles[`${cursor.x},${cursor.y},${cursor.z}`].faces.push({ + // face, + // side, + // modelId: eFace, + // // textureIndex: eFace.texture.tileIndex, + // neighbor: `${neighborPos.x},${neighborPos.y},${neighborPos.z}`, + // //@ts-expect-error debug prop + // texture: eFace.texture.debugName || block.name, + // } satisfies BlockType['faces'][number]) } if (!needTiles) { @@ -472,13 +510,13 @@ export function getSectionGeometry (sx, sy, sz, world: World) { for (cursor.z = sz; cursor.z < sz + 16; cursor.z++) { for (cursor.x = sx; cursor.x < sx + 16; cursor.x++) { let block = world.getBlock(cursor, blockProvider, attr)! - if (!INVISIBLE_BLOCKS.has(block.name)) { - const highest = attr.highestBlocks.get(`${cursor.x},${cursor.z}`) - if (!highest || highest.y < cursor.y) { - attr.highestBlocks.set(`${cursor.x},${cursor.z}`, { y: cursor.y, stateId: block.stateId, biomeId: block.biome.id }) - } - } - if (INVISIBLE_BLOCKS.has(block.name)) continue + // if (!INVISIBLE_BLOCKS.has(block.name)) { + // const highest = attr.highestBlocks.get(`${cursor.x},${cursor.z}`) + // if (!highest || highest.y < cursor.y) { + // attr.highestBlocks.set(`${cursor.x},${cursor.z}`, { y: cursor.y, stateId: block.stateId, biomeId: block.biome.id }) + // } + // } + // if (INVISIBLE_BLOCKS.has(block.name)) continue if ((block.name.includes('_sign') || block.name === 'sign') && !world.config.disableSignsMapsSupport) { const key = `${cursor.x},${cursor.y},${cursor.z}` const props: any = block.getProperties() @@ -503,27 +541,30 @@ export function getSectionGeometry (sx, sy, sz, world: World) { if (patchProperties) { block._originalProperties ??= block._properties block._properties = { ...block._originalProperties, ...patchProperties } - if (block.models && JSON.stringify(block._originalProperties) !== JSON.stringify(block._properties)) { - // recompute models - block.models = undefined + const patched = JSON.stringify(block._properties) + block.patchedModels[''] ??= block.models! + block.models = block.patchedModels[patched] + if (!block.models) { + // need to recompute models block = world.getBlock(cursor, blockProvider, attr)! } + block.patchedModels[patched] = block.models! } else { block._properties = block._originalProperties ?? block._properties block._originalProperties = undefined + block.models = block.patchedModels[''] ?? block.models } } const isWaterlogged = isBlockWaterlogged(block) if (block.name === 'water' || isWaterlogged) { const pos = cursor.clone() - // eslint-disable-next-line @typescript-eslint/no-loop-func - delayedRender.push(() => { - renderLiquid(world, pos, blockProvider.getTextureInfo('water_still'), block.type, biome, true, attr) - }) + // delayedRender.push(() => { + renderLiquid(world, pos, blockProvider.getTextureInfo('water_still'), block.type, biome, true, attr, block.stateId) + // }) attr.blocksCount++ } else if (block.name === 'lava') { - renderLiquid(world, cursor, blockProvider.getTextureInfo('lava_still'), block.type, biome, false, attr) + renderLiquid(world, cursor, blockProvider.getTextureInfo('lava_still'), block.type, biome, false, attr, block.stateId) attr.blocksCount++ } if (block.name !== 'water' && block.name !== 'lava' && !INVISIBLE_BLOCKS.has(block.name)) { @@ -535,6 +576,7 @@ export function getSectionGeometry (sx, sy, sz, world: World) { const firstForceVar = world.config.debugModelVariant?.[0] let part = 0 for (const modelVars of models ?? []) { + if (part > 0) continue // todo only webgpu const pos = cursor.clone() // const variantRuntime = mod(Math.floor(pos.x / 16) + Math.floor(pos.y / 16) + Math.floor(pos.z / 16), modelVars.length) const variantRuntime = 0 @@ -544,32 +586,32 @@ export function getSectionGeometry (sx, sy, sz, world: World) { if (!model) continue // #region 10% - let globalMatrix = null as any - let globalShift = null as any - for (const axis of ['x', 'y', 'z'] as const) { - if (axis in model) { - globalMatrix = globalMatrix ? - matmulmat3(globalMatrix, buildRotationMatrix(axis, -(model[axis] ?? 0))) : - buildRotationMatrix(axis, -(model[axis] ?? 0)) - } - } - if (globalMatrix) { - globalShift = [8, 8, 8] - globalShift = vecsub3(globalShift, matmul3(globalMatrix, globalShift)) - } + const globalMatrix = null as any + const globalShift = null as any + // for (const axis of ['x', 'y', 'z'] as const) { + // if (axis in model) { + // globalMatrix = globalMatrix ? + // matmulmat3(globalMatrix, buildRotationMatrix(axis, -(model[axis] ?? 0))) : + // buildRotationMatrix(axis, -(model[axis] ?? 0)) + // } + // } + // if (globalMatrix) { + // globalShift = [8, 8, 8] + // globalShift = vecsub3(globalShift, matmul3(globalMatrix, globalShift)) + // } // #endregion for (const element of model.elements ?? []) { const ao = model.ao ?? true - if (block.transparent) { - const pos = cursor.clone() - delayedRender.push(() => { - renderElement(world, pos, element, ao, attr, globalMatrix, globalShift, block, biome) - }) - } else { - // 60% - renderElement(world, cursor, element, ao, attr, globalMatrix, globalShift, block, biome) - } + // if (block.transparent) { + // const pos = cursor.clone() + // delayedRender.push(() => { + // renderElement(world, pos, element, ao, attr, globalMatrix, globalShift, block, biome) + // }) + // } else { + // 60% + renderElement(world, cursor, element, ao, attr, globalMatrix, globalShift, block, biome) + // } } } if (part > 0) attr.blocksCount++ @@ -621,6 +663,9 @@ export function getSectionGeometry (sx, sy, sz, world: World) { export const setBlockStatesData = (blockstatesModels, blocksAtlas: any, _needTiles = false, useUnknownBlockModel = true, version = 'latest') => { blockProvider = worldBlockProvider(blockstatesModels, blocksAtlas, version) + if (world) { + world.blockCache = {} + } globalThis.blockProvider = blockProvider if (useUnknownBlockModel) { unknownBlockModel = blockProvider.getAllResolvedModels0_1({ name: 'unknown', properties: {} }) diff --git a/prismarine-viewer/viewer/lib/mesher/modelsGeometryCommon.ts b/prismarine-viewer/viewer/lib/mesher/modelsGeometryCommon.ts index 2aec00e29..8293ebf5d 100644 --- a/prismarine-viewer/viewer/lib/mesher/modelsGeometryCommon.ts +++ b/prismarine-viewer/viewer/lib/mesher/modelsGeometryCommon.ts @@ -74,6 +74,8 @@ export function matmulmat3 (a, b) { export const elemFaces = { up: { + side: 0, + webgpuSide: 0, dir: [0, 1, 0], mask1: [1, 1, 0], mask2: [0, 1, 1], @@ -85,6 +87,8 @@ export const elemFaces = { ] }, down: { + side: 1, + webgpuSide: 1, dir: [0, -1, 0], mask1: [1, 1, 0], mask2: [0, 1, 1], @@ -96,6 +100,8 @@ export const elemFaces = { ] }, east: { + side: 2, + webgpuSide: 4, dir: [1, 0, 0], mask1: [1, 1, 0], mask2: [1, 0, 1], @@ -107,6 +113,8 @@ export const elemFaces = { ] }, west: { + side: 3, + webgpuSide: 5, dir: [-1, 0, 0], mask1: [1, 1, 0], mask2: [1, 0, 1], @@ -118,6 +126,8 @@ export const elemFaces = { ] }, north: { + side: 4, + webgpuSide: 3, dir: [0, 0, -1], mask1: [1, 0, 1], mask2: [0, 1, 1], @@ -129,6 +139,8 @@ export const elemFaces = { ] }, south: { + side: 0, + webgpuSide: 2, dir: [0, 0, 1], mask1: [1, 0, 1], mask2: [0, 1, 1], diff --git a/prismarine-viewer/viewer/lib/mesher/world.ts b/prismarine-viewer/viewer/lib/mesher/world.ts index c69da7513..3d25ff78c 100644 --- a/prismarine-viewer/viewer/lib/mesher/world.ts +++ b/prismarine-viewer/viewer/lib/mesher/world.ts @@ -4,8 +4,9 @@ import { Block } from 'prismarine-block' import { Vec3 } from 'vec3' import { WorldBlockProvider } from 'mc-assets/dist/worldBlockProvider' import moreBlockDataGeneratedJson from '../moreBlockDataGenerated.json' -import legacyJson from '../../../../src/preflatMap.json' -import { defaultMesherConfig } from './shared' +import type { AllBlocksStateIdToModelIdMap } from '../../../examples/webgpuBlockModels' +import { defaultMesherConfig, MesherGeometryOutput } from './shared' +import { getPreflatBlock } from './getPreflatBlock' const ignoreAoBlocks = Object.keys(moreBlockDataGeneratedJson.noOcclusions) @@ -26,6 +27,7 @@ export type WorldBlock = Omit & { isCube: boolean /** cache */ models?: BlockModelPartsResolved | null + patchedModels: Record _originalProperties?: Record _properties?: Record } @@ -39,6 +41,7 @@ export class World { biomeCache: { [id: number]: mcData.Biome } preflat: boolean erroredBlockModel?: BlockModelPartsResolved + webgpuModelsMapping: AllBlocksStateIdToModelIdMap constructor (version) { this.Chunk = Chunks(version) as any @@ -113,7 +116,7 @@ export class World { return this.getColumn(Math.floor(pos.x / 16) * 16, Math.floor(pos.z / 16) * 16) } - getBlock (pos: Vec3, blockProvider?, attr?): WorldBlock | null { + getBlock (pos: Vec3, blockProvider?, attr?: Partial): WorldBlock | null { // for easier testing if (!(pos instanceof Vec3)) pos = new Vec3(...pos as [number, number, number]) const key = columnKey(Math.floor(pos.x / 16) * 16, Math.floor(pos.z / 16) * 16) @@ -128,6 +131,7 @@ export class World { if (!this.blockCache[stateId]) { const b = column.getBlock(locInChunk) as unknown as WorldBlock + b.patchedModels = {} b.isCube = isCube(b.shapes) this.blockCache[stateId] = b Object.defineProperty(b, 'position', { @@ -136,21 +140,12 @@ export class World { } }) if (this.preflat) { - b._properties = {} - - const namePropsStr = legacyJson.blocks[b.type + ':' + b.metadata] || findClosestLegacyBlockFallback(b.type, b.metadata, pos) - if (namePropsStr) { - b.name = namePropsStr.split('[')[0] - const propsStr = namePropsStr.split('[')?.[1]?.split(']') - if (propsStr) { - const newProperties = Object.fromEntries(propsStr.join('').split(',').map(x => { - let [key, val] = x.split('=') - if (!isNaN(val)) val = parseInt(val, 10) - return [key, val] - })) - b._properties = newProperties - } - } + // patch block + getPreflatBlock(b, () => { + const id = b.type + const { metadata } = b + console.warn(`[mesher] Unknown block with ${id}:${metadata} at ${pos.toString()}, falling back`) // todo has known issues + }) } } @@ -200,15 +195,10 @@ export class World { shouldMakeAo (block: WorldBlock | null) { return block?.isCube && !ignoreAoBlocks.includes(block.name) } -} -const findClosestLegacyBlockFallback = (id, metadata, pos) => { - console.warn(`[mesher] Unknown block with ${id}:${metadata} at ${pos}, falling back`) // todo has known issues - for (const [key, value] of Object.entries(legacyJson.blocks)) { - const [idKey, meta] = key.split(':') - if (idKey === id) return value + setDataForWebgpuRenderer (data: { allBlocksStateIdToModelIdMap: AllBlocksStateIdToModelIdMap }) { + this.webgpuModelsMapping = data.allBlocksStateIdToModelIdMap } - return null } // todo export in chunk instead diff --git a/prismarine-viewer/viewer/lib/ui/newStats.ts b/prismarine-viewer/viewer/lib/ui/newStats.ts index 4f4b5cee7..cbb084fb9 100644 --- a/prismarine-viewer/viewer/lib/ui/newStats.ts +++ b/prismarine-viewer/viewer/lib/ui/newStats.ts @@ -4,11 +4,10 @@ const rightOffset = 0 const stats = {} let lastY = 20 -export const addNewStat = (id: string, width = 80, x = rightOffset, y = lastY) => { +export const addNewStat = (id: string, width = 80, x = rightOffset, y?: number) => { const pane = document.createElement('div') - pane.id = 'fps-counter' pane.style.position = 'fixed' - pane.style.top = `${y}px` + pane.style.top = `${y ?? lastY}px` pane.style.right = `${x}px` // gray bg pane.style.backgroundColor = 'rgba(0, 0, 0, 0.7)' @@ -20,7 +19,7 @@ export const addNewStat = (id: string, width = 80, x = rightOffset, y = lastY) = pane.style.pointerEvents = 'none' document.body.appendChild(pane) stats[id] = pane - if (y === 0) { // otherwise it's a custom position + if (y === undefined && x === rightOffset) { // otherwise it's a custom position // rightOffset += width lastY += 20 } @@ -35,6 +34,50 @@ export const addNewStat = (id: string, width = 80, x = rightOffset, y = lastY) = } } +export const addNewStat2 = (id: string, { top, bottom, right, left, displayOnlyWhenWider }: { top?: number, bottom?: number, right?: number, left?: number, displayOnlyWhenWider?: number }) => { + if (top === undefined && bottom === undefined) top = 0 + const pane = document.createElement('div') + pane.style.position = 'fixed' + if (top !== undefined) { + pane.style.top = `${top}px` + } + if (bottom !== undefined) { + pane.style.bottom = `${bottom}px` + } + if (left !== undefined) { + pane.style.left = `${left}px` + } + if (right !== undefined) { + pane.style.right = `${right}px` + } + // gray bg + pane.style.backgroundColor = 'rgba(0, 0, 0, 0.7)' + pane.style.color = 'white' + pane.style.padding = '2px' + pane.style.fontFamily = 'monospace' + pane.style.fontSize = '12px' + pane.style.zIndex = '10000' + pane.style.pointerEvents = 'none' + document.body.appendChild(pane) + stats[id] = pane + + const resizeCheck = () => { + if (!displayOnlyWhenWider) return + pane.style.display = window.innerWidth > displayOnlyWhenWider ? 'block' : 'none' + } + window.addEventListener('resize', resizeCheck) + resizeCheck() + + return { + updateText (text: string) { + pane.innerText = text + }, + setVisibility (visible: boolean) { + pane.style.display = visible ? 'block' : 'none' + } + } +} + export const updateStatText = (id, text) => { if (!stats[id]) return stats[id].innerText = text diff --git a/prismarine-viewer/viewer/lib/viewer.ts b/prismarine-viewer/viewer/lib/viewer.ts index 73f29fb2d..78aef65a5 100644 --- a/prismarine-viewer/viewer/lib/viewer.ts +++ b/prismarine-viewer/viewer/lib/viewer.ts @@ -3,6 +3,7 @@ import * as THREE from 'three' import { Vec3 } from 'vec3' import { generateSpiralMatrix } from 'flying-squid/dist/utils' import worldBlockProvider from 'mc-assets/dist/worldBlockProvider' +import { WorldRendererWebgpu } from './worldrendererWebgpu' import { Entities } from './entities' import { Primitives } from './primitives' import { WorldRendererThree } from './worldrendererThree' @@ -14,17 +15,19 @@ export class Viewer { scene: THREE.Scene ambientLight: THREE.AmbientLight directionalLight: THREE.DirectionalLight - world: WorldRendererCommon + world: WorldRendererWebgpu/* | WorldRendererThree */ entities: Entities // primitives: Primitives domElement: HTMLCanvasElement playerHeight = 1.62 isSneaking = false - threeJsWorld: WorldRendererThree + // threeJsWorld: WorldRendererThree cameraObjectOverride?: THREE.Object3D // for xr audioListener: THREE.AudioListener renderingUntilNoUpdates = false processEntityOverrides = (e, overrides) => overrides + webgpuWorld: WorldRendererWebgpu + powerPreference: string | undefined getMineflayerBot (): void | Record {} // to be overridden @@ -43,8 +46,8 @@ export class Viewer { this.scene = new THREE.Scene() this.scene.matrixAutoUpdate = false // for perf - this.threeJsWorld = new WorldRendererThree(this.scene, this.renderer, worldConfig) - this.setWorld() + // this.threeJsWorld = new WorldRendererThree(this.scene, this.renderer, worldConfig) + this.setWorld(worldConfig) this.resetScene() this.entities = new Entities(this.scene) // this.primitives = new Primitives(this.scene, this.camera) @@ -52,8 +55,14 @@ export class Viewer { this.domElement = renderer.domElement } - setWorld () { - this.world = this.threeJsWorld + setWorld (worldConfig: typeof defaultWorldRendererConfig = this.world.config) { + const { version, texturesVersion } = this.world ?? {} + if (this.world) this.world.destroy() + this.webgpuWorld = new WorldRendererWebgpu(worldConfig, this.renderer, { powerPreference: this.powerPreference }) + this.world = this.webgpuWorld + if (version) { + void this.setVersion(version, texturesVersion) + } } resetScene () { @@ -91,10 +100,6 @@ export class Viewer { }) } - addColumn (x, z, chunk, isLightUpdate = false) { - this.world.addColumn(x, z, chunk, isLightUpdate) - } - removeColumn (x: string, z: string) { this.world.removeColumn(x, z) } @@ -107,7 +112,7 @@ export class Viewer { await this.world.waitForChunkToLoad(pos) } if (!this.world.loadedChunks[`${sectionX},${sectionZ}`]) { - console.debug('[should be unreachable] setBlockStateId called for unloaded chunk', pos) + console.warn('[should be unreachable] setBlockStateId called for unloaded chunk', pos) } this.world.setBlockStateId(pos, stateId) } @@ -212,9 +217,10 @@ export class Viewer { timeout data } | null - worldEmitter.on('loadChunk', ({ x, z, chunk, worldConfig, isLightUpdate }) => { + worldEmitter.on('loadChunk', ({ x, z, column, worldConfig, isLightUpdate }) => { this.world.worldConfig = worldConfig this.world.queuedChunks.add(`${x},${z}`) + const chunk = column.toJson() // todo use export const args = [x, z, chunk, isLightUpdate] if (!currentLoadChunkBatch) { // add a setting to use debounce instead @@ -223,7 +229,7 @@ export class Viewer { timeout: setTimeout(() => { for (const args of currentLoadChunkBatch!.data) { this.world.queuedChunks.delete(`${args[0]},${args[1]}`) - this.addColumn(...args as Parameters) + this.world.addColumn(...args as Parameters) } currentLoadChunkBatch = null }, this.addChunksBatchWaitTime) @@ -233,7 +239,7 @@ export class Viewer { }) // todo remove and use other architecture instead so data flow is clear worldEmitter.on('blockEntities', (blockEntities) => { - if (this.world instanceof WorldRendererThree) (this.world).blockEntities = blockEntities + if (this.world instanceof WorldRendererThree) (this.world as WorldRendererThree).blockEntities = blockEntities }) worldEmitter.on('unloadChunk', ({ x, z }) => { @@ -265,7 +271,7 @@ export class Viewer { }) worldEmitter.on('updateLight', ({ pos }) => { - if (this.world instanceof WorldRendererThree) (this.world).updateLight(pos.x, pos.z) + if (this.world instanceof WorldRendererThree) (this.world as WorldRendererThree).updateLight(pos.x, pos.z) }) worldEmitter.on('time', (timeOfDay) => { @@ -287,7 +293,7 @@ export class Viewer { if (this.world.mesherConfig.skyLight === skyLight) return this.world.mesherConfig.skyLight = skyLight if (this.world instanceof WorldRendererThree) { - (this.world).rerenderAllChunks?.() + (this.world as WorldRendererThree).rerenderAllChunks?.() } }) @@ -296,7 +302,7 @@ export class Viewer { render () { if (this.world instanceof WorldRendererThree) { - (this.world).render() + (this.world as WorldRendererThree).render() this.entities.render() } } diff --git a/prismarine-viewer/viewer/lib/viewerWrapper.ts b/prismarine-viewer/viewer/lib/viewerWrapper.ts index 52e244fe4..39c0444e1 100644 --- a/prismarine-viewer/viewer/lib/viewerWrapper.ts +++ b/prismarine-viewer/viewer/lib/viewerWrapper.ts @@ -92,16 +92,16 @@ export class ViewerWrapper { } } this.preRender() - statsStart() - // ios bug: viewport dimensions are updated after the resize event - if (this.previousWindowWidth !== window.innerWidth || this.previousWindowHeight !== window.innerHeight) { - this.resizeHandler() - this.previousWindowWidth = window.innerWidth - this.previousWindowHeight = window.innerHeight - } - viewer.render() - this.renderedFps++ - statsEnd() + // statsStart() + // // ios bug: viewport dimensions are updated after the resize event + // if (this.previousWindowWidth !== window.innerWidth || this.previousWindowHeight !== window.innerHeight) { + // this.resizeHandler() + // this.previousWindowWidth = window.innerWidth + // this.previousWindowHeight = window.innerHeight + // } + // viewer.render() + // this.renderedFps++ + // statsEnd() this.postRender() } diff --git a/prismarine-viewer/viewer/lib/worldDataEmitter.ts b/prismarine-viewer/viewer/lib/worldDataEmitter.ts index 0223b855b..599910a14 100644 --- a/prismarine-viewer/viewer/lib/worldDataEmitter.ts +++ b/prismarine-viewer/viewer/lib/worldDataEmitter.ts @@ -21,10 +21,8 @@ export class WorldDataEmitter extends EventEmitter { private readonly lastPos: Vec3 private eventListeners: Record = {} private readonly emitter: WorldDataEmitter - keepChunksDistance = 0 addWaitTime = 1 _handDisplay = false - isPlayground = false get handDisplay () { return this._handDisplay } @@ -33,6 +31,10 @@ export class WorldDataEmitter extends EventEmitter { this.eventListeners.heldItemChanged?.() } + /* config */ keepChunksDistance = 0 + /* config */ isPlayground = false + /* config */ allowPositionUpdate = true + constructor (public world: typeof __type_bot['world'], public viewDistance: number, position: Vec3 = new Vec3(0, 0, 0)) { super() this.loadedChunks = {} @@ -205,15 +207,11 @@ export class WorldDataEmitter extends EventEmitter { // const latency = Math.floor(performance.now() - this.lastTime) // this.debugGotChunkLatency.push(latency) // this.lastTime = performance.now() - // todo optimize toJson data, make it clear why it is used - const chunk = column.toJson() - // TODO: blockEntities const worldConfig = { minY: column['minY'] ?? 0, worldHeight: column['worldHeight'] ?? 256, } - //@ts-expect-error - this.emitter.emit('loadChunk', { x: pos.x, z: pos.z, chunk, blockEntities: column.blockEntities, worldConfig, isLightUpdate }) + this.emitter.emit('loadChunk', { x: pos.x, z: pos.z, column, worldConfig, isLightUpdate }) this.loadedChunks[`${pos.x},${pos.z}`] = true } else if (this.isPlayground) { // don't allow in real worlds pre-flag chunks as loaded to avoid race condition when the chunk might still be loading. In playground it's assumed we always pre-load all chunks first this.emitter.emit('markAsLoaded', { x: pos.x, z: pos.z }) @@ -236,6 +234,7 @@ export class WorldDataEmitter extends EventEmitter { } async updatePosition (pos: Vec3, force = false) { + if (!this.allowPositionUpdate) return const [lastX, lastZ] = chunkPos(this.lastPos) const [botX, botZ] = chunkPos(pos) if (lastX !== botX || lastZ !== botZ || force) { diff --git a/prismarine-viewer/viewer/lib/worldrendererCommon.ts b/prismarine-viewer/viewer/lib/worldrendererCommon.ts index 71c094b4f..67c273a2c 100644 --- a/prismarine-viewer/viewer/lib/worldrendererCommon.ts +++ b/prismarine-viewer/viewer/lib/worldrendererCommon.ts @@ -29,7 +29,8 @@ export const worldCleanup = buildCleanupDecorator('resetWorld') export const defaultWorldRendererConfig = { showChunkBorders: false, - numWorkers: 4 + numWorkers: 4, + isPlayground: false } export type WorldRendererConfig = typeof defaultWorldRendererConfig @@ -45,7 +46,6 @@ export abstract class WorldRendererCommon threejsCursorLineMaterial: LineMaterial @worldCleanup() cursorBlock = null as Vec3 | null - isPlayground = false displayStats = true @worldCleanup() worldConfig = { minY: 0, worldHeight: 256 } @@ -56,18 +56,23 @@ export abstract class WorldRendererCommon active = false version = undefined as string | undefined + // #region CHUNK & SECTIONS TRACKING @worldCleanup() loadedChunks = {} as Record // data is added for these chunks and they might be still processing @worldCleanup() finishedChunks = {} as Record // these chunks are fully loaded into the world (scene) + @worldCleanup() + finishedSections = {} as Record // these sections are fully loaded into the world (scene) + @worldCleanup() // loading sections (chunks) sectionsWaiting = new Map() @worldCleanup() queuedChunks = new Set() + // #endregion @worldCleanup() renderUpdateEmitter = new EventEmitter() as unknown as TypedEmitter<{ @@ -115,8 +120,13 @@ export abstract class WorldRendererCommon workersProcessAverageTime = 0 workersProcessAverageTimeCount = 0 maxWorkersProcessTime = 0 - geometryReceiveCount = {} + geometryReceiveCount = 0 + geometryReceiveCountPerSec = 0 allLoadedIn: undefined | number + messagesDelay = 0 + messageDelayCount = 0 + + // geometryReceiveCount = {} rendererDevice = '...' edgeChunks = {} as Record @@ -140,6 +150,12 @@ export abstract class WorldRendererCommon const loadedChunks = Object.keys(this.finishedChunks).length updateStatText('loaded-chunks', `${loadedChunks}/${this.chunksLength} chunks (${this.lastChunkDistance}/${this.viewDistance})`) }) + + setInterval(() => { + this.geometryReceiveCountPerSec = this.geometryReceiveCount + this.geometryReceiveCount = 0 + this.updateChunksStatsText() + }, 1000) } snapshotInitialValues () { } @@ -157,8 +173,9 @@ export abstract class WorldRendererCommon if (!this.active) return this.handleWorkerMessage(data) if (data.type === 'geometry') { - this.geometryReceiveCount[data.workerIndex] ??= 0 - this.geometryReceiveCount[data.workerIndex]++ + // this.geometryReceiveCount[data.workerIndex] ??= 0 + // this.geometryReceiveCount[data.workerIndex]++ + this.geometryReceiveCount++ const geometry = data.geometry as MesherGeometryOutput for (const key in geometry.highestBlocks) { const highest = geometry.highestBlocks[key] @@ -173,6 +190,7 @@ export abstract class WorldRendererCommon if (!this.sectionsWaiting.get(data.key)) throw new Error(`sectionFinished event for non-outstanding section ${data.key}`) this.sectionsWaiting.set(data.key, this.sectionsWaiting.get(data.key)! - 1) if (this.sectionsWaiting.get(data.key) === 0) this.sectionsWaiting.delete(data.key) + this.finishedSections[data.key] = true const chunkCoords = data.key.split(',').map(Number) if (this.loadedChunks[`${chunkCoords[0]},${chunkCoords[2]}`]) { // ensure chunk data was added, not a neighbor chunk update @@ -196,8 +214,12 @@ export abstract class WorldRendererCommon } worker.onmessage = ({ data }) => { if (Array.isArray(data)) { + // const time = data[0] + // this.messagesDelay += Date.now() - time + // this.messageDelayCount++ // eslint-disable-next-line unicorn/no-array-for-each data.forEach(handleMessage) + // data.slice(1).forEach(handleMessage) return } handleMessage(data) @@ -298,14 +320,15 @@ export abstract class WorldRendererCommon } } - async updateTexturesData (resourcePackUpdate = false) { + async updateTexturesData (resourcePackUpdate = false, prioritizeBlockTextures?: string[]) { const blocksAssetsParser = new AtlasParser(this.blocksAtlases, blocksAtlasLatest, blocksAtlasLegacy) const itemsAssetsParser = new AtlasParser(this.itemsAtlases, itemsAtlasLatest, itemsAtlasLegacy) + const customBlockTextures = Object.keys(this.customTextures.blocks?.textures ?? {}).filter(x => x.includes('/')) const { atlas: blocksAtlas, canvas: blocksCanvas } = await blocksAssetsParser.makeNewAtlas(this.texturesVersion ?? this.version ?? 'latest', (textureName) => { const texture = this.customTextures?.blocks?.textures[textureName] if (!texture) return return texture - }, this.customTextures?.blocks?.tileSize) + }, /* this.customTextures?.blocks?.tileSize */undefined, prioritizeBlockTextures, customBlockTextures) const { atlas: itemsAtlas, canvas: itemsCanvas } = await itemsAssetsParser.makeNewAtlas(this.texturesVersion ?? this.version ?? 'latest', (textureName) => { const texture = this.customTextures?.items?.textures[textureName] if (!texture) return @@ -356,7 +379,7 @@ export abstract class WorldRendererCommon } updateChunksStatsText () { - updateStatText('downloaded-chunks', `${Object.keys(this.loadedChunks).length}/${this.chunksLength} chunks D (${this.workers.length}:${this.workersProcessAverageTime.toFixed(0)}ms/${this.allLoadedIn?.toFixed(1) ?? '-'}s)`) + updateStatText('downloaded-chunks', `${Object.keys(this.loadedChunks).length}/${this.chunksLength} chunks D (${this.workers.length}:${this.workersProcessAverageTime.toFixed(0)}ms/${this.geometryReceiveCountPerSec}ss/${this.allLoadedIn?.toFixed(1) ?? '-'}s)`) } addColumn (x: number, z: number, chunk: any, isLightUpdate: boolean) { @@ -397,6 +420,7 @@ export abstract class WorldRendererCommon this.allChunksFinished = Object.keys(this.finishedChunks).length === this.chunksLength for (let y = this.worldConfig.minY; y < this.worldConfig.worldHeight; y += 16) { this.setSectionDirty(new Vec3(x, y, z), false) + this.finishedSections[`${x},${y},${z}`] = false } // remove from highestBlocks const startX = Math.floor(x / 16) * 16 @@ -411,26 +435,30 @@ export abstract class WorldRendererCommon } setBlockStateId (pos: Vec3, stateId: number) { - const key = `${Math.floor(pos.x / 16) * 16},${Math.floor(pos.y / 16) * 16},${Math.floor(pos.z / 16) * 16}` - const useChangeWorker = !this.sectionsWaiting[key] for (const worker of this.workers) { worker.postMessage({ type: 'blockUpdate', pos, stateId }) } - this.setSectionDirty(pos, true, useChangeWorker) + this.setSectionDirty(pos, true, true) if (this.neighborChunkUpdates) { - if ((pos.x & 15) === 0) this.setSectionDirty(pos.offset(-16, 0, 0), true, useChangeWorker) - if ((pos.x & 15) === 15) this.setSectionDirty(pos.offset(16, 0, 0), true, useChangeWorker) - if ((pos.y & 15) === 0) this.setSectionDirty(pos.offset(0, -16, 0), true, useChangeWorker) - if ((pos.y & 15) === 15) this.setSectionDirty(pos.offset(0, 16, 0), true, useChangeWorker) - if ((pos.z & 15) === 0) this.setSectionDirty(pos.offset(0, 0, -16), true, useChangeWorker) - if ((pos.z & 15) === 15) this.setSectionDirty(pos.offset(0, 0, 16), true, useChangeWorker) + if ((pos.x & 15) === 0) this.setSectionDirty(pos.offset(-16, 0, 0), true, true) + if ((pos.x & 15) === 15) this.setSectionDirty(pos.offset(16, 0, 0), true, true) + if ((pos.y & 15) === 0) this.setSectionDirty(pos.offset(0, -16, 0), true, true) + if ((pos.y & 15) === 15) this.setSectionDirty(pos.offset(0, 16, 0), true, true) + if ((pos.z & 15) === 0) this.setSectionDirty(pos.offset(0, 0, -16), true, true) + if ((pos.z & 15) === 15) this.setSectionDirty(pos.offset(0, 0, 16), true, true) } } queueAwaited = false messagesQueue = {} as { [workerIndex: string]: any[] } - getWorkerNumber (pos: Vec3) { + getWorkerNumber (pos: Vec3, updateAction = false) { + if (updateAction) { + const key = `${Math.floor(pos.x / 16) * 16},${Math.floor(pos.y / 16) * 16},${Math.floor(pos.z / 16) * 16}` + const useChangeWorker = !this.sectionsWaiting[key] + if (useChangeWorker) return 0 + } + const hash = mod(Math.floor(pos.x / 16) + Math.floor(pos.y / 16) + Math.floor(pos.z / 16), this.workers.length - 1) return hash + 1 } @@ -446,7 +474,7 @@ export abstract class WorldRendererCommon // Dispatch sections to workers based on position // This guarantees uniformity accross workers and that a given section // is always dispatched to the same worker - const hash = useChangeWorker ? 0 : this.getWorkerNumber(pos) + const hash = this.getWorkerNumber(pos, useChangeWorker) this.sectionsWaiting.set(key, (this.sectionsWaiting.get(key) ?? 0) + 1) this.messagesQueue[hash] ??= [] this.messagesQueue[hash].push({ diff --git a/prismarine-viewer/viewer/lib/worldrendererWebgpu.ts b/prismarine-viewer/viewer/lib/worldrendererWebgpu.ts new file mode 100644 index 000000000..d7fda4ec6 --- /dev/null +++ b/prismarine-viewer/viewer/lib/worldrendererWebgpu.ts @@ -0,0 +1,405 @@ +import { Vec3 } from 'vec3' +// import { addBlocksSection, addWebgpuListener, webgpuChannel } from '../../examples/webgpuRendererMain' +import { pickObj } from '@zardoy/utils' +import { GUI } from 'lil-gui' +import type { WebglData } from '../prepare/webglData' +import { prepareCreateWebgpuBlocksModelsData } from '../../examples/webgpuBlockModels' +import type { workerProxyType } from '../../examples/webgpuRendererWorker' +import { useWorkerProxy } from '../../examples/workerProxy' +import { defaultWebgpuRendererParams, rendererParamsGui } from '../../examples/webgpuRendererShared' +import { loadJSON } from './utils.web' +import { WorldRendererCommon, WorldRendererConfig } from './worldrendererCommon' +import { MesherGeometryOutput } from './mesher/shared' +import { addNewStat, addNewStat2, updateStatText } from './ui/newStats' +import { isMobile } from './simpleUtils' +import { WorldRendererThree } from './worldrendererThree' + +export class WorldRendererWebgpu extends WorldRendererCommon { + outputFormat = 'webgpu' as const + stopBlockUpdate = false + allowUpdates = true + rendering = true + issueReporter = new RendererProblemReporter() + abortController = new AbortController() + worker: Worker | MessagePort | undefined + _readyPromise = Promise.withResolvers() + _readyWorkerPromise = Promise.withResolvers() + readyPromise = this._readyPromise.promise + readyWorkerPromise = this._readyWorkerPromise.promise + postRender = () => {} + preRender = () => {} + rendererParams = defaultWebgpuRendererParams + initCalled = false + + webgpuChannel: typeof workerProxyType['__workerProxy'] = this.getPlaceholderChannel() + rendererDevice = '...' + powerPreference: string | undefined + + constructor (config: WorldRendererConfig, public webglRenderer: THREE.WebGLRenderer, { powerPreference } = {} as any) { + super(config) + this.powerPreference = powerPreference + + void this.readyWorkerPromise.then(() => { + this.addWebgpuListener('rendererProblem', (data) => { + this.issueReporter.reportProblem(data.isContextLost, data.message) + }) + }) + + this.renderUpdateEmitter.on('update', () => { + const loadedChunks = Object.keys(this.finishedChunks).length + updateStatText('loaded-chunks', `${loadedChunks}/${this.chunksLength} chunks (${this.lastChunkDistance}/${this.viewDistance})`) + }) + } + + destroy () { + this.abortController.abort() + this.webgpuChannel.destroy() // still needed in case if running in the same thread + if (this.worker instanceof Worker) { + this.worker.terminate() + } + } + + getPlaceholderChannel () { + return new Proxy({}, { + get: (target, p) => (...args) => { + void this.readyWorkerPromise.then(() => { + this.webgpuChannel[p](...args) + }) + } + }) as any // placeholder to avoid crashes + } + + updateRendererParams (params: Partial) { + this.rendererParams = { ...this.rendererParams, ...params } + this.webgpuChannel.updateConfig(this.rendererParams) + } + + sendCameraToWorker () { + const cameraVectors = ['rotation', 'position'].reduce((acc, key) => { + acc[key] = ['x', 'y', 'z'].reduce((acc2, key2) => { + acc2[key2] = this.camera[key][key2] + return acc2 + }, {}) + return acc + }, {}) as any + this.webgpuChannel.camera({ + ...cameraVectors, + fov: this.camera.fov + }) + } + + addWebgpuListener (type: string, listener: (data: any) => void) { + void this.readyWorkerPromise.then(() => { + this.worker!.addEventListener('message', (e: any) => { + if (e.data.type === type) { + listener(e.data) + } + }) + }) + } + + override async setVersion (version, texturesVersion = version): Promise { + return Promise.all([ + super.setVersion(version, texturesVersion), + this.readyPromise + ]) + } + + setBlockStateId (pos: any, stateId: any): void { + if (this.stopBlockUpdate) return + super.setBlockStateId(pos, stateId) + } + + sendDataForWebgpuRenderer (data) { + for (const worker of this.workers) { + worker.postMessage({ type: 'webgpuData', data }) + } + } + + isWaitingForChunksToRender = false + + override addColumn (x: number, z: number, data: any, _): void { + if (this.initialChunksLoad) { + this.updateRendererParams({ + cameraOffset: [0, this.worldMinYRender < 0 ? Math.abs(this.worldMinYRender) : 0, 0] + }) + } + super.addColumn(x, z, data, _) + } + + allChunksLoaded (): void { + console.log('allChunksLoaded') + this.webgpuChannel.addBlocksSectionDone() + } + + handleWorkerMessage (data: { geometry: MesherGeometryOutput, type, key }): void { + if (data.type === 'geometry' && Object.keys(data.geometry.tiles).length) { + this.addChunksToScene(data.key, data.geometry) + } + } + + addChunksToScene (key: string, geometry: MesherGeometryOutput) { + if (this.finishedChunks[key] && !this.allowUpdates) return + // const chunkCoords = key.split(',').map(Number) as [number, number, number] + if (/* !this.loadedChunks[chunkCoords[0] + ',' + chunkCoords[2]] || */ !this.active) return + + this.webgpuChannel.addBlocksSection(geometry.tiles, key, !this.finishedSections[key]) + } + + updateCamera (pos: Vec3 | null, yaw: number, pitch: number): void { + if (pos) { + // new tweenJs.Tween(this.camera.position).to({ x: pos.x, y: pos.y, z: pos.z }, 50).start() + this.camera.position.set(pos.x, pos.y, pos.z) + } + this.camera.rotation.set(pitch, yaw, 0, 'ZYX') + this.sendCameraToWorker() + } + render (): void { } + + chunksReset () { + this.webgpuChannel.fullDataReset() + } + + updatePosDataChunk (key: string) { + } + + async updateTexturesData (resourcePackUpdate = false): Promise { + const { blocksDataModelDebug: blocksDataModelBefore, interestedTextureTiles } = prepareCreateWebgpuBlocksModelsData() + await super.updateTexturesData(undefined, [...interestedTextureTiles].map(x => x.replace('block/', ''))) + const { blocksDataModel, blocksDataModelDebug, allBlocksStateIdToModelIdMap } = prepareCreateWebgpuBlocksModelsData() + // this.webgpuChannel.updateModels(blocksDataModel) + this.sendDataForWebgpuRenderer({ allBlocksStateIdToModelIdMap }) + void this.initWebgpu(blocksDataModel) + if (resourcePackUpdate) { + const blob = await fetch(this.material.map!.image.src).then(async (res) => res.blob()) + this.webgpuChannel.updateTexture(blob) + } + } + + updateShowChunksBorder (value: boolean) { + // todo + } + + changeBackgroundColor (color: [number, number, number]) { + this.webgpuChannel.updateBackground(color) + } + + setHighlightCursorBlock (position: typeof this.cursorBlock): void { + const useChangeWorker = true + if (this.cursorBlock) { + const worker = this.workers[this.getWorkerNumber(this.cursorBlock, useChangeWorker)] + worker.postMessage({ type: 'specialBlockState', data: { value: null, position: this.cursorBlock } }) + this.setSectionDirty(this.cursorBlock, true, useChangeWorker) + } + + this.cursorBlock = position + if (this.cursorBlock) { + const worker = this.workers[this.getWorkerNumber(this.cursorBlock, useChangeWorker)] + worker.postMessage({ type: 'specialBlockState', data: { value: 'highlight', position: this.cursorBlock } }) + this.setSectionDirty(this.cursorBlock, true, useChangeWorker) + } + } + + + removeColumn (x, z) { + super.removeColumn(x, z) + + for (let y = this.worldConfig.minY; y < this.worldConfig.worldHeight; y += 16) { + this.webgpuChannel.removeBlocksSection(`${x},${y},${z}`) + } + } + + async initWebgpu (blocksDataModel) { + if (this.initCalled) return + this.initCalled = true + // do not use worker in safari, it is bugged + const USE_WORKER = defaultWebgpuRendererParams.webgpuWorker + + const playground = this.config.isPlayground + const { image } = (this.material.map!) + const imageBlob = await fetch(image.src).then(async (res) => res.blob()) + + const existingCanvas = document.getElementById('viewer-canvas') + existingCanvas?.remove() + const canvas = document.createElement('canvas') + canvas.width = window.innerWidth * window.devicePixelRatio + canvas.height = window.innerHeight * window.devicePixelRatio + document.body.appendChild(canvas) + canvas.id = 'viewer-canvas' + + + // replacable by initWebglRenderer + if (USE_WORKER) { + this.worker = new Worker('./webgpuRendererWorker.js') + console.log('starting offscreen') + } else if (globalThis.webgpuRendererChannel) { + this.worker = globalThis.webgpuRendererChannel.port1 as MessagePort + } else { + const messageChannel = new MessageChannel() + globalThis.webgpuRendererChannel = messageChannel + this.worker = messageChannel.port1 + messageChannel.port1.start() + messageChannel.port2.start() + await import('../../examples/webgpuRendererWorker') + } + addWebgpuDebugUi(this.worker, playground, this.webglRenderer) + this.webgpuChannel = useWorkerProxy(this.worker, true) + this._readyWorkerPromise.resolve(undefined) + this.webgpuChannel.canvas( + canvas.transferControlToOffscreen(), + imageBlob, + playground, + pickObj(localStorage, 'vertShader', 'fragShader', 'computeShader'), + blocksDataModel, + { powerPreference: this.powerPreference as GPUPowerPreference } + ) + + if (!USE_WORKER) { + // wait for the .canvas() message to be processed (it's async since we still use message channel) + await new Promise(resolve => { + setTimeout(resolve, 0) + }) + } + + let oldWidth = window.innerWidth + let oldHeight = window.innerHeight + let focused = true + const { signal } = this.abortController + window.addEventListener('focus', () => { + focused = true + this.webgpuChannel.startRender() + }, { signal }) + window.addEventListener('blur', () => { + focused = false + this.webgpuChannel.stopRender() + }, { signal }) + const mainLoop = () => { + if (this.abortController.signal.aborted) return + requestAnimationFrame(mainLoop) + if (!focused || window.stopRender) return + + if (oldWidth !== window.innerWidth || oldHeight !== window.innerHeight) { + oldWidth = window.innerWidth + oldHeight = window.innerHeight + this.webgpuChannel.resize(window.innerWidth * window.devicePixelRatio, window.innerHeight * window.devicePixelRatio) + } + this.preRender() + this.postRender() + this.sendCameraToWorker() + } + + requestAnimationFrame(mainLoop) + + this._readyPromise.resolve(undefined) + } +} + +class RendererProblemReporter { + dom = document.createElement('div') + contextlostDom = document.createElement('div') + mainIssueDom = document.createElement('div') + + constructor () { + document.body.appendChild(this.dom) + this.dom.className = 'renderer-problem-reporter' + this.dom.appendChild(this.contextlostDom) + this.dom.appendChild(this.mainIssueDom) + this.dom.style.fontFamily = 'monospace' + this.dom.style.fontSize = '20px' + this.contextlostDom.style.cssText = ` + position: fixed; + top: 60px; + left: 0; + right: 0; + color: red; + display: flex; + justify-content: center; + z-index: -1; + font-size: 18px; + text-align: center; + ` + this.mainIssueDom.style.cssText = ` + position: fixed; + inset: 0; + color: red; + display: flex; + justify-content: center; + align-items: center; + z-index: -1; + text-align: center; + ` + this.reportProblem(false, 'Waiting for renderer...') + this.mainIssueDom.style.color = 'white' + } + + reportProblem (isContextLost: boolean, message: string) { + this.mainIssueDom.style.color = 'red' + if (isContextLost) { + this.contextlostDom.textContent = `Renderer context lost (try restarting the browser): ${message}` + } else { + this.mainIssueDom.textContent = message + } + } +} + +const addWebgpuDebugUi = (worker, isPlayground, renderer) => { + // todo destroy + const mobile = isMobile() + const { updateText } = addNewStat('fps', 200, undefined, 0) + let prevTimeout + worker.addEventListener('message', (e: any) => { + if (e.data.type === 'fps') { + updateText(`FPS: ${e.data.fps}`) + if (prevTimeout) clearTimeout(prevTimeout) + prevTimeout = setTimeout(() => { + updateText('') + }, 1002) + } + if (e.data.type === 'stats') { + updateTextGpuStats(e.data.stats) + viewer.world.rendererDevice = `${e.data.device} WebGL data: ${WorldRendererThree.getRendererInfo(renderer)}` + } + }) + + const { updateText: updateText2 } = addNewStat('fps-main', 90, 0, 20) + const { updateText: updateTextGpuStats } = addNewStat('gpu-stats', 90, 0, 40) + const leftUi = isPlayground ? 130 : mobile ? 25 : 0 + const { updateText: updateTextBuild } = addNewStat2('build-info', { + left: leftUi, + displayOnlyWhenWider: 700, + }) + updateTextBuild(`WebGPU Renderer Demo by @SA2URAMI. Build: ${process.env.NODE_ENV === 'development' ? 'dev' : process.env.RELEASE_TAG}`) + let updates = 0 + const mainLoop = () => { + requestAnimationFrame(mainLoop) + updates++ + } + mainLoop() + setInterval(() => { + updateText2(`Main Loop: ${updates}`) + updates = 0 + }, 1000) + + if (!isPlayground) { + const gui = new GUI() + gui.domElement.classList.add('webgpu-debug-ui') + gui.title('WebGPU Params') + gui.open(false) + setTimeout(() => { + gui.open(false) + }, 500) + for (const rendererParam of Object.entries(viewer.world.rendererParams)) { + const [key, value] = rendererParam + if (!rendererParamsGui[key]) continue + // eslint-disable-next-line @typescript-eslint/no-loop-func + gui.add(viewer.world.rendererParams, key).onChange((newVal) => { + viewer.world.updateRendererParams({ [key]: newVal }) + if (rendererParamsGui[key]?.qsReload) { + const searchParams = new URLSearchParams(window.location.search) + searchParams.set(key, String(value)) + window.location.search = searchParams.toString() + } + }) + } + } +} diff --git a/prismarine-viewer/viewer/prepare/webglData.ts b/prismarine-viewer/viewer/prepare/webglData.ts new file mode 100644 index 000000000..676e8b63f --- /dev/null +++ b/prismarine-viewer/viewer/prepare/webglData.ts @@ -0,0 +1,28 @@ +import { join } from 'path' +import fs from 'fs' +import { JsonAtlas } from './atlas' + +export type WebglData = ReturnType + +export const prepareWebglData = (blockTexturesDir: string, atlas: JsonAtlas) => { + // todo + return Object.fromEntries(Object.entries(atlas.textures).map(([texture, { animatedFrames }]) => { + if (!animatedFrames) return null! + const mcMeta = JSON.parse(fs.readFileSync(join(blockTexturesDir, texture + '.png.mcmeta'), 'utf8')) as { + animation: { + interpolate: boolean, + frametime: number, + frames: Array<{ + index: number, + time: number + } | number> + } + } + return [texture, { + animation: { + ...mcMeta.animation, + framesCount: animatedFrames + } + }] as const + }).filter(Boolean)) +} diff --git a/prismarine-viewer/webgpuShaders/RadialBlur/frag.wgsl b/prismarine-viewer/webgpuShaders/RadialBlur/frag.wgsl new file mode 100644 index 000000000..4b995366e --- /dev/null +++ b/prismarine-viewer/webgpuShaders/RadialBlur/frag.wgsl @@ -0,0 +1,76 @@ +// Fragment shader +@group(0) @binding(0) var tex: texture_depth_2d; + @group(0) @binding(1) var mySampler: sampler; + @group(0) @binding(2) var texColor: texture_2d; + @group(0) @binding(3) var clearColor: vec4; +const sampleDist : f32 = 1.0; +const sampleStrength : f32 = 2.2; + +const SAMPLES: f32 = 24.; +fn hash( p: vec2 ) -> f32 { return fract(sin(dot(p, vec2(41, 289)))*45758.5453); } + +fn lOff() -> vec3{ + + var u = sin(vec2(1.57, 0)); + var a = mat2x2(u.x,u.y, -u.y, u.x); + + var l : vec3 = normalize(vec3(1.5, 1., -0.5)); + var temp = a * l.xz; + l.x = temp.x; + l.z = temp.y; + temp = a * l.xy; + l.x = temp.x; + l.y = temp.y; + + return l; +} + +@fragment +fn main( + @location(0) uv: vec2f, +) -> @location(0) vec4f +{ + var uvs = uv; + uvs.y = 1.0 - uvs.y; + var decay : f32 = 0.93; + // Controls the sample density, which in turn, controls the sample spread. + var density = 0.5; + // Sample weight. Decays as we radiate outwards. + var weight = 0.04; + + var l = lOff(); + + var tuv = uvs-l.xy*.45; + + var dTuv = tuv*density/SAMPLES; + + var temp = textureSample(tex,mySampler, uvs); + var col : f32; + var outTex = textureSample(texColor, mySampler, uvs); + if (temp == 1.0) { + col = temp * 0.25; + } + + uvs += dTuv*(hash(uvs.xy - 1.0) * 2. - 1.); + + for(var i=0.0; i < SAMPLES; i += 1){ + + uvs -= dTuv; + var temp = textureSample(tex, mySampler, uvs); + if (temp == 1.0) { + col +=temp * weight; + } + weight *= decay; + + } + + + //col *= (1. - dot(tuv, tuv)*.75); + let t = clearColor.xyz * sqrt(smoothstep(0.0, 1.0, col)); + if (temp == 1.0) { + return vec4(t, 1.0); + } + + + return outTex + vec4(t, 1.0); +} diff --git a/prismarine-viewer/webgpuShaders/RadialBlur/vert.wgsl b/prismarine-viewer/webgpuShaders/RadialBlur/vert.wgsl new file mode 100644 index 000000000..623aa31f2 --- /dev/null +++ b/prismarine-viewer/webgpuShaders/RadialBlur/vert.wgsl @@ -0,0 +1,18 @@ + +struct VertexOutput { + @builtin(position) Position: vec4f, + @location(0) fragUV: vec2f, +} + + +@vertex +fn main( + @location(0) position: vec4, + @location(1) uv: vec2 +) -> VertexOutput { + var output: VertexOutput; + output.Position = vec4f(position.xy, 0.0 , 1.0); + output.Position = sign(output.Position); + output.fragUV = uv; + return output; +} \ No newline at end of file diff --git a/rsbuild.config.ts b/rsbuild.config.ts index 1085e3fcf..f49f74468 100644 --- a/rsbuild.config.ts +++ b/rsbuild.config.ts @@ -21,14 +21,8 @@ const buildingVersion = new Date().toISOString().split(':')[0] const dev = process.env.NODE_ENV === 'development' -let releaseTag -let releaseChangelog - -if (fs.existsSync('./assets/release.json')) { - const releaseJson = JSON.parse(fs.readFileSync('./assets/release.json', 'utf8')) - releaseTag = releaseJson.latestTag - releaseChangelog = releaseJson.changelog?.replace(//, '') -} +// clean dist folder +// fs.rmSync('./dist', { recursive: true, force: true }) // base options are in ./prismarine-viewer/rsbuildSharedConfig.ts const appConfig = defineConfig({ @@ -43,6 +37,7 @@ const appConfig = defineConfig({ js: 'source-map', css: true, }, + cleanDistPath: false, }, source: { entry: { @@ -54,11 +49,7 @@ const appConfig = defineConfig({ define: { 'process.env.BUILD_VERSION': JSON.stringify(!dev ? buildingVersion : 'undefined'), 'process.env.MAIN_MENU_LINKS': JSON.stringify(process.env.MAIN_MENU_LINKS), - 'process.env.GITHUB_URL': - JSON.stringify(`https://github.com/${process.env.GITHUB_REPOSITORY || `${process.env.VERCEL_GIT_REPO_OWNER}/${process.env.VERCEL_GIT_REPO_SLUG}`}`), 'process.env.DEPS_VERSIONS': JSON.stringify({}), - 'process.env.RELEASE_TAG': JSON.stringify(releaseTag), - 'process.env.RELEASE_CHANGELOG': JSON.stringify(releaseChangelog), }, }, server: { @@ -109,6 +100,12 @@ const appConfig = defineConfig({ } else if (!dev) { await execAsync('pnpm run build-mesher') } + if (fs.existsSync('./prismarine-viewer/dist/webgpuRendererWorker.js')) { + // copy worker + fs.copyFileSync('./prismarine-viewer/dist/webgpuRendererWorker.js', './dist/webgpuRendererWorker.js') + } else { + await execAsync('pnpm run build-other-workers') + } fs.writeFileSync('./dist/version.txt', buildingVersion, 'utf-8') console.timeEnd('total-prep') } diff --git a/src/browserfs.ts b/src/browserfs.ts index 7e66dfe04..3851bb2af 100644 --- a/src/browserfs.ts +++ b/src/browserfs.ts @@ -1,385 +1,25 @@ +import * as fs from 'fs' import { join } from 'path' -import { promisify } from 'util' -import fs from 'fs' -import sanitizeFilename from 'sanitize-filename' -import { oneOf } from '@zardoy/utils' -import * as browserfs from 'browserfs' -import { options, resetOptions } from './optionsStorage' - -import { fsState, loadSave } from './loadSave' -import { installTexturePack, installTexturePackFromHandle, updateTexturePackInstalledState } from './resourcePack' import { miscUiState } from './globalState' +import { configureBrowserFs, copyFilesAsync, existsViaStats, initialFsState, mountRemoteFsBackend } from './integratedServer/browserfsShared' +import { fsState, loadSave } from './loadSave' +import { resetOptions } from './optionsStorage' +import { installTexturePack, updateTexturePackInstalledState } from './resourcePack' import { setLoadingScreenStatus } from './utils' -const { GoogleDriveFileSystem } = require('google-drive-browserfs/src/backends/GoogleDrive') // disable type checking - -browserfs.install(window) -const defaultMountablePoints = { - '/world': { fs: 'LocalStorage' }, // will be removed in future - '/data': { fs: 'IndexedDB' }, - '/resourcepack': { fs: 'InMemory' }, // temporary storage for currently loaded resource pack -} -browserfs.configure({ - fs: 'MountableFileSystem', - options: defaultMountablePoints, -}, async (e) => { - // todo disable singleplayer button - if (e) throw e - await updateTexturePackInstalledState() - miscUiState.appLoaded = true -}) - -export const forceCachedDataPaths = {} -export const forceRedirectPaths = {} - -window.fs = fs -//@ts-expect-error -fs.promises = new Proxy(Object.fromEntries(['readFile', 'writeFile', 'stat', 'mkdir', 'rmdir', 'unlink', 'rename', /* 'copyFile', */'readdir'].map(key => [key, promisify(fs[key])])), { - get (target, p: string, receiver) { - if (!target[p]) throw new Error(`Not implemented fs.promises.${p}`) - return (...args) => { - // browser fs bug: if path doesn't start with / dirname will return . which would cause infinite loop, so we need to normalize paths - if (typeof args[0] === 'string' && !args[0].startsWith('/')) args[0] = '/' + args[0] - const toRemap = Object.entries(forceRedirectPaths).find(([from]) => args[0].startsWith(from)) - if (toRemap) { - args[0] = args[0].replace(toRemap[0], toRemap[1]) - } - // Write methods - // todo issue one-time warning (in chat I guess) - const readonly = fsState.isReadonly && !(args[0].startsWith('/data') && !fsState.inMemorySave) // allow copying worlds from external providers such as zip - if (readonly) { - if (oneOf(p, 'readFile', 'writeFile') && forceCachedDataPaths[args[0]]) { - if (p === 'readFile') { - return Promise.resolve(forceCachedDataPaths[args[0]]) - } else if (p === 'writeFile') { - forceCachedDataPaths[args[0]] = args[1] - console.debug('Skipped writing to readonly fs', args[0]) - return Promise.resolve() - } - } - if (oneOf(p, 'writeFile', 'mkdir', 'rename')) return - } - if (p === 'open' && fsState.isReadonly) { - args[1] = 'r' // read-only, zipfs throw otherwise - } - if (p === 'readFile') { - fsState.openReadOperations++ - } else if (p === 'writeFile') { - fsState.openWriteOperations++ - } - return target[p](...args).finally(() => { - if (p === 'readFile') { - fsState.openReadOperations-- - } else if (p === 'writeFile') { - fsState.openWriteOperations-- - } - }) - } - } -}) -//@ts-expect-error -fs.promises.open = async (...args) => { - //@ts-expect-error - const fd = await promisify(fs.open)(...args) - return { - ...Object.fromEntries(['read', 'write', 'close'].map(x => [x, async (...args) => { - return new Promise(resolve => { - // todo it results in world corruption on interactions eg block placements - if (x === 'write' && fsState.isReadonly) { - resolve({ buffer: Buffer.from([]), bytesRead: 0 }) - return - } - - if (x === 'read') { - fsState.openReadOperations++ - } else if (x === 'write' || x === 'close') { - fsState.openWriteOperations++ - } - fs[x](fd, ...args, (err, bytesRead, buffer) => { - if (x === 'read') { - fsState.openReadOperations-- - } else if (x === 'write' || x === 'close') { - // todo that's not correct - fsState.openWriteOperations-- - } - if (err) throw err - // todo if readonly probably there is no need to open at all (return some mocked version - check reload)? - if (x === 'write' && !fsState.isReadonly) { - // flush data, though alternatively we can rely on close in unload - fs.fsync(fd, () => { }) - } - resolve({ buffer, bytesRead }) - }) - }) - }])), - // for debugging - fd, - filename: args[0], - async close () { - return new Promise(resolve => { - fs.close(fd, (err) => { - if (err) { - throw err - } else { - resolve() - } - }) - }) - } - } -} - -// for testing purposes, todo move it to core patch -const removeFileRecursiveSync = (path) => { - for (const file of fs.readdirSync(path)) { - const curPath = join(path, file) - if (fs.lstatSync(curPath).isDirectory()) { - // recurse - removeFileRecursiveSync(curPath) - fs.rmdirSync(curPath) - } else { - // delete file - fs.unlinkSync(curPath) - } - } -} - -window.removeFileRecursiveSync = removeFileRecursiveSync - -export const mkdirRecursive = async (path: string) => { - const parts = path.split('/') - let current = '' - for (const part of parts) { - current += part + '/' - try { - // eslint-disable-next-line no-await-in-loop - await fs.promises.mkdir(current) - } catch (err) { - } - } -} -export const uniqueFileNameFromWorldName = async (title: string, savePath: string) => { - const name = sanitizeFilename(title) - let resultPath!: string - // getUniqueFolderName - let i = 0 - let free = false - while (!free) { - try { - resultPath = `${savePath.replace(/\$/, '')}/${name}${i === 0 ? '' : `-${i}`}` - // eslint-disable-next-line no-await-in-loop - await fs.promises.stat(resultPath) - i++ - } catch (err) { - free = true - } - } - return resultPath -} - -export const mountExportFolder = async () => { - let handle: FileSystemDirectoryHandle - try { - handle = await showDirectoryPicker({ - id: 'world-export', - }) - } catch (err) { - if (err instanceof DOMException && err.name === 'AbortError') return - throw err - } - if (!handle) return false - await new Promise(resolve => { - browserfs.configure({ - fs: 'MountableFileSystem', - options: { - ...defaultMountablePoints, - '/export': { - fs: 'FileSystemAccess', - options: { - handle - } - } - }, - }, (e) => { - if (e) throw e - resolve() - }) - }) - return true -} - -let googleDriveFileSystem - -/** Only cached! */ -export const googleDriveGetFileIdFromPath = (path: string) => { - return googleDriveFileSystem._getExistingFileId(path) -} - -export const mountGoogleDriveFolder = async (readonly: boolean, rootId: string) => { - googleDriveFileSystem = new GoogleDriveFileSystem() - googleDriveFileSystem.rootDirId = rootId - googleDriveFileSystem.isReadonly = readonly - await new Promise(resolve => { - browserfs.configure({ - fs: 'MountableFileSystem', - options: { - ...defaultMountablePoints, - '/google': googleDriveFileSystem - }, - }, (e) => { - if (e) throw e - resolve() - }) - }) - fsState.isReadonly = readonly - fsState.syncFs = false - fsState.inMemorySave = false - fsState.remoteBackend = true - return true -} - -export async function removeFileRecursiveAsync (path) { - const errors = [] as Array<[string, Error]> - try { - const files = await fs.promises.readdir(path) - - // Use Promise.all to parallelize file/directory removal - await Promise.all(files.map(async (file) => { - const curPath = join(path, file) - const stats = await fs.promises.stat(curPath) - if (stats.isDirectory()) { - // Recurse - await removeFileRecursiveAsync(curPath) - } else { - // Delete file - await fs.promises.unlink(curPath) - } - })) - - // After removing all files/directories, remove the current directory - await fs.promises.rmdir(path) - } catch (error) { - errors.push([path, error]) - } - - if (errors.length) { - setTimeout(() => { - console.error(errors) - throw new Error(`Error removing directories/files: ${errors.map(([path, err]) => `${path}: ${err.message}`).join(', ')}`) - }) - } -} - - -const SUPPORT_WRITE = true - -export const openWorldDirectory = async (dragndropHandle?: FileSystemDirectoryHandle) => { - let _directoryHandle: FileSystemDirectoryHandle - if (dragndropHandle) { - _directoryHandle = dragndropHandle - } else { - try { - _directoryHandle = await window.showDirectoryPicker({ - id: 'select-world', // important: this is used to remember user choice (start directory) - }) - } catch (err) { - if (err instanceof DOMException && err.name === 'AbortError') return - throw err +export const resetLocalStorageWithoutWorld = () => { + for (const key of Object.keys(localStorage)) { + if (!/^[\da-fA-F]{8}(?:\b-[\da-fA-F]{4}){3}\b-[\da-fA-F]{12}$/g.test(key) && key !== '/') { + localStorage.removeItem(key) } } - const directoryHandle = _directoryHandle - - const requestResult = SUPPORT_WRITE && !options.preferLoadReadonly ? await directoryHandle.requestPermission?.({ mode: 'readwrite' }) : undefined - const writeAccess = requestResult === 'granted' - - const doContinue = writeAccess || !SUPPORT_WRITE || options.disableLoadPrompts || confirm('Continue in readonly mode?') - if (!doContinue) return - await new Promise(resolve => { - browserfs.configure({ - fs: 'MountableFileSystem', - options: { - ...defaultMountablePoints, - '/world': { - fs: 'FileSystemAccess', - options: { - handle: directoryHandle - } - } - }, - }, (e) => { - if (e) throw e - resolve() - }) - }) - - fsState.isReadonly = !writeAccess - fsState.syncFs = false - fsState.inMemorySave = false - fsState.remoteBackend = false - await loadSave() -} - -const tryToDetectResourcePack = async () => { - const askInstall = async () => { - // todo investigate browserfs read errors - return alert('ATM You can install texturepacks only via options menu.') - // if (confirm('Resource pack detected, do you want to install it?')) { - // await installTexturePackFromHandle() - // } - } - - if (fs.existsSync('/world/pack.mcmeta')) { - await askInstall() - return true - } - // const jszip = new JSZip() - // let loaded = await jszip.loadAsync(file) - // if (loaded.file('pack.mcmeta')) { - // loaded = null - // askInstall() - // return true - // } - // loaded = null -} - -export const possiblyCleanHandle = (callback = () => { }) => { - if (!fsState.saveLoaded) { - // todo clean handle - browserfs.configure({ - fs: 'MountableFileSystem', - options: defaultMountablePoints, - }, (e) => { - callback() - if (e) throw e - }) - } -} - -const readdirSafe = async (path: string) => { - try { - return await fs.promises.readdir(path) - } catch (err) { - return null - } + resetOptions() } -export const collectFilesToCopy = async (basePath: string, safe = false): Promise => { - const result: string[] = [] - const countFiles = async (relPath: string) => { - const resolvedPath = join(basePath, relPath) - const files = relPath === '.' && !safe ? await fs.promises.readdir(resolvedPath) : await readdirSafe(resolvedPath) - if (!files) return null - await Promise.all(files.map(async file => { - const res = await countFiles(join(relPath, file)) - if (res === null) { - // is file - result.push(join(relPath, file)) - } - })) - } - await countFiles('.') - return result -} +configureBrowserFs(async () => { + await updateTexturePackInstalledState() + miscUiState.appLoaded = true +}) export const copyFilesAsyncWithProgress = async (pathSrc: string, pathDest: string, throwRootNotExist = true, addMsg = '') => { const stat = await existsViaStats(pathSrc) @@ -423,60 +63,9 @@ export const copyFilesAsyncWithProgress = async (pathSrc: string, pathDest: stri } } -export const existsViaStats = async (path: string) => { - try { - return await fs.promises.stat(path) - } catch (e) { - return false - } -} - -export const fileExistsAsyncOptimized = async (path: string) => { - try { - await fs.promises.readdir(path) - } catch (err) { - if (err.code === 'ENOTDIR') return true - // eslint-disable-next-line sonarjs/prefer-single-boolean-return - if (err.code === 'ENOENT') return false - // throw err - return false - } - return true -} - -export const copyFilesAsync = async (pathSrc: string, pathDest: string, fileCopied?: (name) => void) => { - // query: can't use fs.copy! use fs.promises.writeFile and readFile - const files = await fs.promises.readdir(pathSrc) - - if (!await existsViaStats(pathDest)) { - await fs.promises.mkdir(pathDest, { recursive: true }) - } - - // Use Promise.all to parallelize file/directory copying - await Promise.all(files.map(async (file) => { - const curPathSrc = join(pathSrc, file) - const curPathDest = join(pathDest, file) - const stats = await fs.promises.stat(curPathSrc) - if (stats.isDirectory()) { - // Recurse - await fs.promises.mkdir(curPathDest) - await copyFilesAsync(curPathSrc, curPathDest, fileCopied) - } else { - // Copy file - try { - await fs.promises.writeFile(curPathDest, await fs.promises.readFile(curPathSrc) as any) - console.debug('copied file', curPathSrc, curPathDest) - } catch (err) { - console.error('Error copying file', curPathSrc, curPathDest, err) - throw err - } - fileCopied?.(curPathDest) - } - })) -} - export const openWorldFromHttpDir = async (fileDescriptorUrls: string[]/* | undefined */, baseUrlParam) => { // todo try go guess mode + let indexFileUrl let index let baseUrl for (const url of fileDescriptorUrls) { @@ -502,58 +91,44 @@ export const openWorldFromHttpDir = async (fileDescriptorUrls: string[]/* | und index = file baseUrl = baseUrlParam ?? url.split('/').slice(0, -1).join('/') } + indexFileUrl = url break } if (!index) throw new Error(`The provided mapDir file is not valid descriptor file! ${fileDescriptorUrls.join(', ')}`) - await new Promise(async resolve => { - browserfs.configure({ - fs: 'MountableFileSystem', - options: { - ...defaultMountablePoints, - '/world': { - fs: 'HTTPRequest', - options: { - index, - baseUrl - } - } - }, - }, (e) => { - if (e) throw e - resolve() - }) - }) fsState.saveLoaded = false fsState.isReadonly = true fsState.syncFs = false fsState.inMemorySave = false fsState.remoteBackend = true + fsState.usingIndexFileUrl = indexFileUrl + fsState.remoteBackendBaseUrl = baseUrl + + await mountRemoteFsBackend(fsState) await loadSave() } -// todo rename method const openWorldZipInner = async (file: File | ArrayBuffer, name = file['name']) => { - await new Promise(async resolve => { - browserfs.configure({ - // todo - fs: 'MountableFileSystem', - options: { - ...defaultMountablePoints, - '/world': { - fs: 'ZipFS', - options: { - zipData: Buffer.from(file instanceof File ? (await file.arrayBuffer()) : file), - name - } - } - }, - }, (e) => { - if (e) throw e - resolve() - }) - }) + // await new Promise(async resolve => { + // browserfs.configure({ + // // todo + // fs: 'MountableFileSystem', + // options: { + // ...defaultMountablePoints, + // '/world': { + // fs: 'ZipFS', + // options: { + // zipData: Buffer.from(file instanceof File ? (await file.arrayBuffer()) : file), + // name + // } + // } + // }, + // }, (e) => { + // if (e) throw e + // resolve() + // }) + // }) fsState.saveLoaded = false fsState.isReadonly = true @@ -593,28 +168,29 @@ export const openWorldZip = async (...args: Parameters try { return await openWorldZipInner(...args) } finally { - possiblyCleanHandle() + // possiblyCleanHandle() } } -export const resetLocalStorageWorld = () => { - for (const key of Object.keys(localStorage)) { - if (/^[\da-fA-F]{8}(?:\b-[\da-fA-F]{4}){3}\b-[\da-fA-F]{12}$/g.test(key) || key === '/') { - localStorage.removeItem(key) - } - } +export const resetStateAfterDisconnect = () => { + miscUiState.gameLoaded = false + miscUiState.loadedDataVersion = null + miscUiState.singleplayer = false + miscUiState.flyingSquid = false + miscUiState.wanOpened = false + miscUiState.currentDisplayQr = null + + Object.assign(fsState, structuredClone(initialFsState)) } -export const resetLocalStorageWithoutWorld = () => { +export const resetLocalStorageWorld = () => { for (const key of Object.keys(localStorage)) { - if (!/^[\da-fA-F]{8}(?:\b-[\da-fA-F]{4}){3}\b-[\da-fA-F]{12}$/g.test(key) && key !== '/') { + if (/^[\da-fA-F]{8}(?:\b-[\da-fA-F]{4}){3}\b-[\da-fA-F]{12}$/g.test(key) || key === '/') { localStorage.removeItem(key) } } - resetOptions() } -window.resetLocalStorageWorld = resetLocalStorageWorld export const openFilePicker = (specificCase?: 'resourcepack') => { // create and show input picker let picker: HTMLInputElement = document.body.querySelector('input#file-zip-picker')! @@ -646,13 +222,74 @@ export const openFilePicker = (specificCase?: 'resourcepack') => { picker.click() } -export const resetStateAfterDisconnect = () => { - miscUiState.gameLoaded = false - miscUiState.loadedDataVersion = null - miscUiState.singleplayer = false - miscUiState.flyingSquid = false - miscUiState.wanOpened = false - miscUiState.currentDisplayQr = null +const tryToDetectResourcePack = async () => { + const askInstall = async () => { + // todo investigate browserfs read errors + return alert('ATM You can install texturepacks only via options menu.') + // if (confirm('Resource pack detected, do you want to install it?')) { + // await installTexturePackFromHandle() + // } + } - fsState.saveLoaded = false + if (fs.existsSync('/world/pack.mcmeta')) { + await askInstall() + return true + } + // const jszip = new JSZip() + // let loaded = await jszip.loadAsync(file) + // if (loaded.file('pack.mcmeta')) { + // loaded = null + // askInstall() + // return true + // } + // loaded = null +} + + +const SUPPORT_WRITE = true + +export const openWorldDirectory = async (dragndropHandle?: FileSystemDirectoryHandle) => { + // let _directoryHandle: FileSystemDirectoryHandle + // if (dragndropHandle) { + // _directoryHandle = dragndropHandle + // } else { + // try { + // _directoryHandle = await window.showDirectoryPicker({ + // id: 'select-world', // important: this is used to remember user choice (start directory) + // }) + // } catch (err) { + // if (err instanceof DOMException && err.name === 'AbortError') return + // throw err + // } + // } + // const directoryHandle = _directoryHandle + + // const requestResult = SUPPORT_WRITE && !options.preferLoadReadonly ? await directoryHandle.requestPermission?.({ mode: 'readwrite' }) : undefined + // const writeAccess = requestResult === 'granted' + + // const doContinue = writeAccess || !SUPPORT_WRITE || options.disableLoadPrompts || confirm('Continue in readonly mode?') + // if (!doContinue) return + // await new Promise(resolve => { + // browserfs.configure({ + // fs: 'MountableFileSystem', + // options: { + // ...defaultMountablePoints, + // '/world': { + // fs: 'FileSystemAccess', + // options: { + // handle: directoryHandle + // } + // } + // }, + // }, (e) => { + // if (e) throw e + // resolve() + // }) + // }) + + // localFsState.isReadonly = !writeAccess + // localFsState.syncFs = false + // localFsState.inMemorySave = false + // localFsState.remoteBackend = false + // await loadSave() } diff --git a/src/builtinCommands.ts b/src/builtinCommands.ts index 51c98f2ad..493013a01 100644 --- a/src/builtinCommands.ts +++ b/src/builtinCommands.ts @@ -1,12 +1,12 @@ import fs from 'fs' import { join } from 'path' import JSZip from 'jszip' -import { readLevelDat } from './loadSave' +import { fsState, readLevelDat } from './loadSave' import { closeWan, openToWanAndCopyJoinLink } from './localServerMultiplayer' -import { copyFilesAsync, uniqueFileNameFromWorldName } from './browserfs' import { saveServer } from './flyingSquidUtils' import { setLoadingScreenStatus } from './utils' import { displayClientChat } from './botUtils' +import { copyFilesAsync, uniqueFileNameFromWorldName } from './integratedServer/browserfsShared' const notImplemented = () => { return 'Not implemented yet' @@ -68,7 +68,7 @@ export const exportWorld = async (path: string, type: 'zip' | 'folder', zipName // todo include in help const exportLoadedWorld = async () => { await saveServer() - let { worldFolder } = localServer!.options + let worldFolder = fsState.inMemorySavePath if (!worldFolder.startsWith('/')) worldFolder = `/${worldFolder}` await exportWorld(worldFolder, 'zip') } diff --git a/src/controls.ts b/src/controls.ts index cb1c132bc..c1ab91838 100644 --- a/src/controls.ts +++ b/src/controls.ts @@ -302,8 +302,9 @@ const alwaysPressedHandledCommand = (command: Command) => { export function lockUrl () { let newQs = '' - if (fsState.saveLoaded) { - const save = localServer!.options.worldFolder.split('/').at(-1) + if (fsState.saveLoaded && fsState.inMemorySave) { + const worldFolder = fsState.inMemorySavePath + const save = worldFolder.split('/').at(-1) newQs = `loadSave=${save}` } else if (process.env.NODE_ENV === 'development') { newQs = `reconnect=1` @@ -457,10 +458,14 @@ export const f3Keybinds = [ console.warn('forcefully removed chunk from scene') } } - if (localServer) { - //@ts-expect-error not sure why it is private... maybe revisit api? - localServer.players[0].world.columns = {} - } + + viewer.world.chunksReset() // todo + + // TODO! + // if (localServer) { + // //@ts-expect-error not sure why it is private... maybe revisit api? + // localServer.players[0].world.columns = {} + // } void reloadChunks() }, mobileTitle: 'Reload chunks', @@ -531,8 +536,20 @@ export const f3Keybinds = [ const proxyPing = await bot['pingProxy']() void showOptionsModal(`${username}: last known total latency (ping): ${playerPing}. Connected to ${lastConnectOptions.value?.proxy} with current ping ${proxyPing}. Player UUID: ${uuid}`, []) }, - mobileTitle: 'Show Proxy & Ping Details' - } + mobileTitle: 'Show Proxy & Ping Details', + show: () => !miscUiState.singleplayer + }, + { + key: 'KeyL', + async action () { + if (viewer.world.rendering) { + viewer.world.webgpuChannel.stopRender() + } else { + viewer.world.webgpuChannel.startRender() + } + }, + mobileTitle: 'Toggle rendering' + }, ] const hardcodedPressedKeys = new Set() @@ -541,7 +558,7 @@ document.addEventListener('keydown', (e) => { if (hardcodedPressedKeys.has('F3')) { const keybind = f3Keybinds.find((v) => v.key === e.code) if (keybind) { - keybind.action() + void keybind.action() e.stopPropagation() } return @@ -768,6 +785,10 @@ window.addEventListener('keydown', (e) => { if (e.code === 'KeyL' && e.altKey) { console.clear() } + if (e.code === 'KeyK' && e.altKey) { + // eslint-disable-next-line no-debugger + debugger + } }) // #endregion diff --git a/src/createLocalServer.ts b/src/createLocalServer.ts index d0beac9a2..9b2d206bf 100644 --- a/src/createLocalServer.ts +++ b/src/createLocalServer.ts @@ -1,11 +1,10 @@ -import { LocalServer } from './customServer' +import { createMCServer } from 'flying-squid/dist' -const { createMCServer } = require('flying-squid/dist') - -export const startLocalServer = (serverOptions) => { +export const startLocalServer = (serverOptions, LocalServer) => { const passOptions = { ...serverOptions, Server: LocalServer } - const server: NonNullable = createMCServer(passOptions) + const server = createMCServer(passOptions) server.formatMessage = (message) => `[server] ${message}` + //@ts-expect-error server.options = passOptions //@ts-expect-error todo remove server.looseProtocolMode = true diff --git a/src/customClient.js b/src/customClient.js deleted file mode 100644 index b6c85fcc8..000000000 --- a/src/customClient.js +++ /dev/null @@ -1,78 +0,0 @@ -import { options } from './optionsStorage' - -//@ts-check -const { EventEmitter } = require('events') -const debug = require('debug')('minecraft-protocol') -const states = require('minecraft-protocol/src/states') - -window.serverDataChannel ??= {} -export const customCommunication = { - sendData(data) { - setTimeout(() => { - window.serverDataChannel[this.isServer ? 'emitClient' : 'emitServer'](data) - }) - }, - receiverSetup(processData) { - window.serverDataChannel[this.isServer ? 'emitServer' : 'emitClient'] = (data) => { - processData(data) - } - } -} - -class CustomChannelClient extends EventEmitter { - constructor(isServer, version) { - super() - this.version = version - this.isServer = !!isServer - this.state = states.HANDSHAKING - } - - get state() { - return this.protocolState - } - - setSerializer(state) { - customCommunication.receiverSetup.call(this, (/** @type {{name, params, state?}} */parsed) => { - if (!options.excludeCommunicationDebugEvents.includes(parsed.name)) { - debug(`receive in ${this.isServer ? 'server' : 'client'}: ${parsed.name}`) - } - this.emit(parsed.name, parsed.params, parsed) - this.emit('packet_name', parsed.name, parsed.params, parsed) - }) - } - - // eslint-disable-next-line @typescript-eslint/adjacent-overload-signatures, grouped-accessor-pairs - set state(newProperty) { - const oldProperty = this.protocolState - this.protocolState = newProperty - - this.setSerializer(this.protocolState) - - this.emit('state', newProperty, oldProperty) - } - - end(reason) { - this._endReason = reason - this.emit('end', this._endReason) // still emits on server side only, doesn't send anything to our client - } - - write(name, params) { - if (!options.excludeCommunicationDebugEvents.includes(name)) { - debug(`[${this.state}] from ${this.isServer ? 'server' : 'client'}: ` + name) - debug(params) - } - - this.emit('writePacket', name, params) - customCommunication.sendData.call(this, { name, params, state: this.state }) - } - - writeBundle(packets) { - // no-op - } - - writeRaw(buffer) { - // no-op - } -} - -export default CustomChannelClient diff --git a/src/customServer.ts b/src/customServer.ts deleted file mode 100644 index 972636fea..000000000 --- a/src/customServer.ts +++ /dev/null @@ -1,20 +0,0 @@ -import EventEmitter from 'events' - -import CustomChannelClient from './customClient' - -export class LocalServer extends EventEmitter.EventEmitter { - socketServer = null - cipher = null - decipher = null - clients = {} - - constructor (public version, public customPackets, public hideErrors = false) { - super() - } - - listen () { - this.emit('connection', new CustomChannelClient(true, this.version)) - } - - close () { } -} diff --git a/src/devtools.ts b/src/devtools.ts index 16337c1d7..7852506e6 100644 --- a/src/devtools.ts +++ b/src/devtools.ts @@ -19,6 +19,7 @@ window.inspectPlayer = () => require('fs').promises.readFile('/world/playerdata/ Object.defineProperty(window, 'debugSceneChunks', { get () { + if (!(viewer.world instanceof WorldRendererThree)) return return (viewer.world as WorldRendererThree).getLoadedChunksRelative?.(bot.entity.position, true) }, }) diff --git a/src/flyingSquidEvents.ts b/src/flyingSquidEvents.ts deleted file mode 100644 index 7231dd276..000000000 --- a/src/flyingSquidEvents.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { saveServer } from './flyingSquidUtils' -import { watchUnloadForCleanup } from './gameUnload' -import { showModal } from './globalState' -import { options } from './optionsStorage' -import { chatInputValueGlobal } from './react/Chat' -import { showNotification } from './react/NotificationProvider' - -export default () => { - localServer!.on('warpsLoaded', () => { - if (!localServer) return - showNotification(`${localServer.warps.length} Warps loaded`, 'Use /warp to teleport to a warp point.', false, 'label-alt', () => { - chatInputValueGlobal.value = '/warp ' - showModal({ reactType: 'chat' }) - }) - }) - - if (options.singleplayerAutoSave) { - const autoSaveInterval = setInterval(() => { - if (options.singleplayerAutoSave) { - void saveServer(true) - } - }, 2000) - watchUnloadForCleanup(() => { - clearInterval(autoSaveInterval) - }) - } -} diff --git a/src/globals.d.ts b/src/globals.d.ts index 7fc9baecc..f16ed290f 100644 --- a/src/globals.d.ts +++ b/src/globals.d.ts @@ -14,7 +14,6 @@ declare const __type_bot: typeof bot declare const viewer: import('prismarine-viewer/viewer/lib/viewer').Viewer declare const worldView: import('prismarine-viewer/viewer/lib/worldDataEmitter').WorldDataEmitter | undefined declare const addStatPerSec: (name: string) => void -declare const localServer: import('flying-squid/dist/index').FullServer & { options } | undefined /** all currently loaded mc data */ declare const mcData: Record declare const loadedData: import('minecraft-data').IndexedData & { sounds: Record } @@ -33,4 +32,17 @@ declare interface Document { exitPointerLock?(): void } +declare module '*.frag' { + const png: string + export default png +} +declare module '*.vert' { + const png: string + export default png +} +declare module '*.wgsl' { + const png: string + export default png +} + declare interface Window extends Record { } diff --git a/src/googledrive.ts b/src/googledrive.ts index 3846add3e..65a7f75bb 100644 --- a/src/googledrive.ts +++ b/src/googledrive.ts @@ -4,7 +4,6 @@ import React from 'react' import { loadScript } from 'prismarine-viewer/viewer/lib/utils' import { loadGoogleDriveApi, loadInMemorySave } from './react/SingleplayerProvider' import { setLoadingScreenStatus } from './utils' -import { mountGoogleDriveFolder } from './browserfs' import { showOptionsModal } from './react/SelectOption' const CLIENT_ID = '137156026346-igv2gkjsj2hlid92rs3q7cjjnc77s132.apps.googleusercontent.com' diff --git a/src/index.ts b/src/index.ts index 93bbe6b90..d68340e7b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,7 +24,7 @@ import { options, watchValue } from './optionsStorage' import './reactUi' import { contro, lockUrl, onBotCreate } from './controls' import './dragndrop' -import { possiblyCleanHandle, resetStateAfterDisconnect } from './browserfs' +import { resetStateAfterDisconnect } from './browserfs' import { watchOptionsAfterViewerInit, watchOptionsAfterWorldViewInit } from './watchOptions' import downloadAndOpenFile from './downloadAndOpenFile' @@ -60,7 +60,8 @@ import { import { pointerLock, toMajorVersion, - setLoadingScreenStatus + setLoadingScreenStatus, + logAction } from './utils' import { isCypress } from './standaloneUtils' @@ -74,7 +75,6 @@ import dayCycle from './dayCycle' import { onAppLoad, resourcepackReload } from './resourcePack' import { ConnectPeerOptions, connectToPeer } from './localServerMultiplayer' -import CustomChannelClient from './customClient' import { loadScript } from 'prismarine-viewer/viewer/lib/utils' import { registerServiceWorker } from './serviceWorker' import { appStatusState, lastConnectOptions } from './react/AppStatusProvider' @@ -87,7 +87,6 @@ import { downloadSoundsIfNeeded } from './soundSystem' import { ua } from './react/utils' import { handleMovementStickDelta, joystickPointer } from './react/TouchAreasControls' import { possiblyHandleStateVariable } from './googledrive' -import flyingSquidEvents from './flyingSquidEvents' import { hideNotification, notificationProxy, showNotification } from './react/NotificationProvider' import { saveToBrowserMemory } from './react/PauseScreen' import { ViewerWrapper } from 'prismarine-viewer/viewer/lib/viewerWrapper' @@ -99,10 +98,13 @@ import { signInMessageState } from './react/SignInMessageProvider' import { updateAuthenticatedAccountData, updateLoadedServerData } from './react/ServersListProvider' import { versionToNumber } from 'prismarine-viewer/viewer/prepare/utils' import packetsPatcher from './packetsPatcher' +// import { ViewerBase } from 'prismarine-viewer/viewer/lib/viewerWrapper' import { mainMenuState } from './react/MainMenuRenderApp' import { ItemsRenderer } from 'mc-assets/dist/itemsRenderer' import './mobileShim' import { parseFormattedMessagePacket } from './botUtils' +import { addNewStat } from 'prismarine-viewer/viewer/lib/ui/newStats' +import { destroyLocalServerMain, startLocalServerMain } from './integratedServer/main' window.debug = debug window.THREE = THREE @@ -203,6 +205,7 @@ let lastMouseMove: number const updateCursor = () => { worldInteractions.update() } +let mouseEvents = 0 function onCameraMove (e) { if (e.type !== 'touchmove' && !pointerLock.hasPointerLock) return e.stopPropagation?.() @@ -217,8 +220,14 @@ function onCameraMove (e) { y: e.movementY * mouseSensY * 0.0001 }) updateCursor() + mouseEvents++ + viewer.setFirstPersonCamera(null, bot.entity.yaw, bot.entity.pitch) } -window.addEventListener('mousemove', onCameraMove, { capture: true }) +setInterval(() => { + // console.log('mouseEvents', mouseEvents) + mouseEvents = 0 +}, 1000) +window.addEventListener('mousemove', onCameraMove, { capture: true, passive: false }) contro.on('stickMovement', ({ stick, vector }) => { if (!isGameActive(true)) return if (stick !== 'right') return @@ -303,6 +312,11 @@ async function connect (connectOptions: ConnectOptions) { console.log(`connecting to ${server.host}:${server.port} with ${username}`) + const playType = connectOptions.server ? 'Server' : connectOptions.singleplayer ? 'Singleplayer' : 'P2P Multiplayer' + const info = connectOptions.server ? `${server.host}:${server.port}` : connectOptions.singleplayer ? + (fsState.usingIndexFileUrl || fsState.remoteBackend ? 'remote' : fsState.inMemorySave ? 'IndexedDB' : fsState.syncFs ? 'ZIP' : 'Folder') : '-' + logAction('Play', playType, `v${connectOptions.botVersion} : ${info}`) + const startDisplayViewer = Date.now() hideCurrentScreens() setLoadingScreenStatus('Logging in') @@ -312,7 +326,7 @@ async function connect (connectOptions: ConnectOptions) { if (ended) return ended = true viewer.resetAll() - localServer = window.localServer = window.server = undefined + void destroyLocalServerMain(false) renderWrapper.postRender = () => { } if (bot) { @@ -332,9 +346,9 @@ async function connect (connectOptions: ConnectOptions) { } const cleanFs = () => { if (singleplayer && !fsState.inMemorySave) { - possiblyCleanHandle(() => { - // todo: this is not enough, we need to wait for all async operations to finish - }) + // possiblyCleanHandle(() => { + // // todo: this is not enough, we need to wait for all async operations to finish + // }) } } let lastPacket = undefined as string | undefined @@ -389,6 +403,8 @@ async function connect (connectOptions: ConnectOptions) { const serverOptions = defaultsDeep({}, connectOptions.serverOverrides ?? {}, options.localServerOptions, defaultServerOptions) Object.assign(serverOptions, connectOptions.serverOverridesFlat ?? {}) window._LOAD_MC_DATA() // start loading data (if not loaded yet) + addNewStat('loaded-chunks', undefined, 220, 0) + addNewStat('downloaded-chunks', 90, 200, 20) const downloadMcData = async (version: string) => { if (connectOptions.authenticatedAccount && (versionToNumber(version) < versionToNumber('1.19.4') || versionToNumber(version) >= versionToNumber('1.21'))) { // todo support it (just need to fix .export crash) @@ -421,14 +437,28 @@ async function connect (connectOptions: ConnectOptions) { } } viewer.world.blockstatesModels = await import('mc-assets/dist/blockStatesModels.json') - void viewer.setVersion(version, options.useVersionsTextures === 'latest' ? version : options.useVersionsTextures) + const mcData = MinecraftData(version) + window.loadedData = mcData + const promise = viewer.setVersion(version, options.useVersionsTextures === 'latest' ? version : options.useVersionsTextures) + const isWebgpu = true + if (isWebgpu) { + await promise + } + viewer.world.postRender = () => { + renderWrapper.postRender() + } + viewer.world.preRender = () => { + renderWrapper.preRender() + } } + // serverOptions.version = '1.18.1' const downloadVersion = connectOptions.botVersion || (singleplayer ? serverOptions.version : undefined) if (downloadVersion) { await downloadMcData(downloadVersion) } + let CustomClient if (singleplayer) { // SINGLEPLAYER EXPLAINER: // Note 1: here we have custom sync communication between server Client (flying-squid) and game client (mineflayer) @@ -442,23 +472,9 @@ 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 setLoadingScreenStatus('Starting local server') - localServer = window.localServer = window.server = startLocalServer(serverOptions) - // todo need just to call quit if started - // loadingScreen.maybeRecoverable = false - // init world, todo: do it for any async plugins - if (!localServer.pluginsReady) { - await new Promise(resolve => { - localServer.once('pluginsReady', resolve) - }) - } + CustomClient = (await startLocalServerMain(serverOptions)).CustomClient - localServer.on('newPlayer', (player) => { - // it's you! - player.on('loadingStatus', (newStatus) => { - setLoadingScreenStatus(newStatus, false, false, true) - }) - }) - flyingSquidEvents() + // flyingSquidEvents() } if (connectOptions.authenticatedAccount) username = 'you' @@ -499,7 +515,7 @@ async function connect (connectOptions: ConnectOptions) { ...singleplayer ? { version: serverOptions.version, connect () { }, - Client: CustomChannelClient as any, + Client: CustomClient, } : {}, onMsaCode (data) { signInMessageState.code = data.user_code @@ -678,10 +694,8 @@ async function connect (connectOptions: ConnectOptions) { // don't use spawn event, player can be dead bot.once(spawnEarlier ? 'forcedMove' : 'health', () => { errorAbortController.abort() - const mcData = MinecraftData(bot.version) - window.PrismarineBlock = PrismarineBlock(mcData.version.minecraftVersion!) - window.PrismarineItem = PrismarineItem(mcData.version.minecraftVersion!) - window.loadedData = mcData + window.PrismarineBlock = PrismarineBlock(loadedData.version.minecraftVersion!) + window.PrismarineItem = PrismarineItem(loadedData.version.minecraftVersion!) window.Vec3 = Vec3 window.pathfinder = pathfinder @@ -696,6 +710,11 @@ async function connect (connectOptions: ConnectOptions) { if (process.env.NODE_ENV === 'development' && !localStorage.lockUrl && new URLSearchParams(location.search).size === 0) { lockUrl() } + logAction('Joined', playType, `Time: ${Date.now() - startDisplayViewer}ms`) + if (connectOptions.server) { + logAction('Server Version', bot.version) + logAction('Auth', connectOptions.authenticatedAccount ? 'Authenticated' : 'Offline') + } updateDataAfterJoin() if (connectOptions.autoLoginPassword) { bot.chat(`/login ${connectOptions.autoLoginPassword}`) @@ -713,8 +732,9 @@ async function connect (connectOptions: ConnectOptions) { void initVR() - renderWrapper.postRender = () => { - viewer.setFirstPersonCamera(null, bot.entity.yaw, bot.entity.pitch) + renderWrapper.preRender = () => { + // viewer.setFirstPersonCamera(null, bot.entity.yaw, bot.entity.pitch) + bot['doPhysics']() } @@ -898,13 +918,14 @@ async function connect (connectOptions: ConnectOptions) { }, 600) setLoadingScreenStatus(undefined) - const start = Date.now() + const startLoadingChunks = Date.now() let done = false void viewer.world.renderUpdateEmitter.on('update', () => { // 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') + console.log('All done and ready! In', (Date.now() - startLoadingChunks) / 1000, 's') + logAction('Chunks Loaded', 'All', `Distance: ${viewer.world.viewDistance} Time: ${(Date.now() - startLoadingChunks) / 1000}s`) viewer.render() // ensure the last state is rendered document.dispatchEvent(new Event('cypress-world-ready')) }) diff --git a/src/integratedServer/browserfsServer.ts b/src/integratedServer/browserfsServer.ts new file mode 100644 index 000000000..bd73e1d71 --- /dev/null +++ b/src/integratedServer/browserfsServer.ts @@ -0,0 +1,50 @@ +import * as fs from 'fs' +import path from 'path' +import { gzip } from 'node-gzip' +import * as nbt from 'prismarine-nbt' +import * as browserfs from 'browserfs' +import { nameToMcOfflineUUID } from '../flyingSquidUtils' +import { configureBrowserFs, defaultMountablePoints, localFsState, mkdirRecursive, mountRemoteFsBackend } from './browserfsShared' + +const readLevelDat = async (path) => { + let levelDatContent + try { + // todo-low cache reading + levelDatContent = await fs.promises.readFile(`${path}/level.dat`) + } catch (err) { + if (err.code === 'ENOENT') { + return undefined + } + throw err + } + const { parsed } = await nbt.parse(Buffer.from(levelDatContent)) + const levelDat = nbt.simplify(parsed).Data + return { levelDat, dataRaw: parsed.value.Data!.value as Record } +} + +export const onWorldOpened = async (username: string, root: string) => { + // const { levelDat, dataRaw } = (await readLevelDat(root))! + + // const playerUuid = nameToMcOfflineUUID(username) + // const playerDatPath = `${root}/playerdata/${playerUuid}.dat` + // const playerDataOverride = dataRaw.Player + // if (playerDataOverride) { + // const playerDat = await gzip(nbt.writeUncompressed({ name: '', ...playerDataOverride })) + // if (localFsState.isReadonly) { + // fs forceCachedDataPaths[playerDatPath] = playerDat + // } else { + // await mkdirRecursive(path.dirname(playerDatPath)) + // await fs.promises.writeFile(playerDatPath, playerDat) + // } + // } +} + +export const mountFsBackend = async () => { + if (localFsState.remoteBackend) { + await mountRemoteFsBackend(localFsState) + } else if (localFsState.inMemorySave) { + await new Promise(resolve => { + configureBrowserFs(resolve) + }) + } +} diff --git a/src/integratedServer/browserfsShared.ts b/src/integratedServer/browserfsShared.ts new file mode 100644 index 000000000..76d51a2b2 --- /dev/null +++ b/src/integratedServer/browserfsShared.ts @@ -0,0 +1,409 @@ +import { join } from 'path' +import { promisify } from 'util' +import fs from 'fs' +import sanitizeFilename from 'sanitize-filename' +import { oneOf } from '@zardoy/utils' +import * as browserfs from 'browserfs' +import { proxy } from 'valtio' + +const { GoogleDriveFileSystem } = require('google-drive-browserfs/src/backends/GoogleDrive') // disable type checking + +browserfs.install(globalThis) +export const defaultMountablePoints = { + '/world': { fs: 'LocalStorage' }, // will be removed in future + '/data': { fs: 'IndexedDB' }, + '/resourcepack': { fs: 'InMemory' }, // temporary storage for currently loaded resource pack +} as Record +if (typeof localStorage === 'undefined') { + delete defaultMountablePoints['/world'] +} +export const configureBrowserFs = (onDone) => { + browserfs.configure({ + fs: 'MountableFileSystem', + options: defaultMountablePoints, + }, async (e) => { + // todo disable singleplayer button + if (e) throw e + onDone() + }) +} + +export const initialFsState = { + isReadonly: false, + syncFs: false, + inMemorySave: false, + inMemorySavePath: '', + saveLoaded: false, + + remoteBackend: false, + remoteBackendBaseUrl: '', + usingIndexFileUrl: '', + forceCachedDataPaths: {}, + forceRedirectPaths: {} +} +export const localFsState = { + ...initialFsState +} + +export const currentInternalFsState = proxy({ + openReadOperations: 0, + openWriteOperations: 0, + openOperations: 0, +}) + +globalThis.fs ??= fs +const promises = new Proxy(Object.fromEntries(['readFile', 'writeFile', 'stat', 'mkdir', 'rmdir', 'unlink', 'rename', /* 'copyFile', */'readdir'].map(key => [key, promisify(fs[key])])), { + get (target, p: string, receiver) { + if (!target[p]) throw new Error(`Not implemented fs.promises.${p}`) + return (...args) => { + // browser fs bug: if path doesn't start with / dirname will return . which would cause infinite loop, so we need to normalize paths + if (typeof args[0] === 'string' && !args[0].startsWith('/')) args[0] = '/' + args[0] + const toRemap = Object.entries(localFsState.forceRedirectPaths).find(([from]) => args[0].startsWith(from)) + if (toRemap) { + args[0] = args[0].replace(toRemap[0], toRemap[1]) + } + // Write methods + // todo issue one-time warning (in chat I guess) + const readonly = localFsState.isReadonly && !(args[0].startsWith('/data') && !localFsState.inMemorySave) // allow copying worlds from external providers such as zip + if (readonly) { + if (oneOf(p, 'readFile', 'writeFile') && localFsState.forceCachedDataPaths[args[0]]) { + if (p === 'readFile') { + return Promise.resolve(localFsState.forceCachedDataPaths[args[0]]) + } else if (p === 'writeFile') { + localFsState.forceCachedDataPaths[args[0]] = args[1] + console.debug('Skipped writing to readonly fs', args[0]) + return Promise.resolve() + } + } + if (oneOf(p, 'writeFile', 'mkdir', 'rename')) return + } + if (p === 'open' && localFsState.isReadonly) { + args[1] = 'r' // read-only, zipfs throw otherwise + } + if (p === 'readFile') { + currentInternalFsState.openReadOperations++ + } else if (p === 'writeFile') { + currentInternalFsState.openWriteOperations++ + } + return target[p](...args).finally(() => { + if (p === 'readFile') { + currentInternalFsState.openReadOperations-- + } else if (p === 'writeFile') { + currentInternalFsState.openWriteOperations-- + } + }) + } + } +}) +promises.open = async (...args) => { + //@ts-expect-error + const fd = await promisify(fs.open)(...args) + return { + ...Object.fromEntries(['read', 'write', 'close'].map(x => [x, async (...args) => { + return new Promise(resolve => { + // todo it results in world corruption on interactions eg block placements + if (x === 'write' && localFsState.isReadonly) { + resolve({ buffer: Buffer.from([]), bytesRead: 0 }) + return + } + + if (x === 'read') { + currentInternalFsState.openReadOperations++ + } else if (x === 'write' || x === 'close') { + currentInternalFsState.openWriteOperations++ + } + fs[x](fd, ...args, (err, bytesRead, buffer) => { + if (x === 'read') { + currentInternalFsState.openReadOperations-- + } else if (x === 'write' || x === 'close') { + // todo that's not correct + currentInternalFsState.openWriteOperations-- + } + if (err) throw err + // todo if readonly probably there is no need to open at all (return some mocked version - check reload)? + if (x === 'write' && !localFsState.isReadonly) { + // flush data, though alternatively we can rely on close in unload + fs.fsync(fd, () => { }) + } + resolve({ buffer, bytesRead }) + }) + }) + }])), + // for debugging + fd, + filename: args[0], + async close () { + return new Promise(resolve => { + fs.close(fd, (err) => { + if (err) { + throw err + } else { + resolve() + } + }) + }) + } + } +} +globalThis.promises = promises +if (typeof localStorage !== 'undefined') { + //@ts-expect-error + fs.promises = promises +} + +// for testing purposes, todo move it to core patch +const removeFileRecursiveSync = (path) => { + for (const file of fs.readdirSync(path)) { + const curPath = join(path, file) + if (fs.lstatSync(curPath).isDirectory()) { + // recurse + removeFileRecursiveSync(curPath) + fs.rmdirSync(curPath) + } else { + // delete file + fs.unlinkSync(curPath) + } + } +} + +globalThis.removeFileRecursiveSync = removeFileRecursiveSync + +export const mkdirRecursive = async (path: string) => { + const parts = path.split('/') + let current = '' + for (const part of parts) { + current += part + '/' + try { + // eslint-disable-next-line no-await-in-loop + await fs.promises.mkdir(current) + } catch (err) { + } + } +} + +export const mountRemoteFsBackend = async (fsState: typeof localFsState) => { + const index = await fetch(fsState.usingIndexFileUrl).then(async (res) => res.json()) + await new Promise(async resolve => { + browserfs.configure({ + fs: 'MountableFileSystem', + options: { + ...defaultMountablePoints, + '/world': { + fs: 'HTTPRequest', + options: { + index, + baseUrl: fsState.remoteBackendBaseUrl + } + } + }, + }, (e) => { + if (e) throw e + resolve() + }) + }) +} + +export const uniqueFileNameFromWorldName = async (title: string, savePath: string) => { + const name = sanitizeFilename(title) + let resultPath!: string + // getUniqueFolderName + let i = 0 + let free = false + while (!free) { + try { + resultPath = `${savePath.replace(/\$/, '')}/${name}${i === 0 ? '' : `-${i}`}` + // eslint-disable-next-line no-await-in-loop + await fs.promises.stat(resultPath) + i++ + } catch (err) { + free = true + } + } + return resultPath +} + +export const mountExportFolder = async () => { + let handle: FileSystemDirectoryHandle + try { + handle = await showDirectoryPicker({ + id: 'world-export', + }) + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') return + throw err + } + if (!handle) return false + await new Promise(resolve => { + browserfs.configure({ + fs: 'MountableFileSystem', + options: { + ...defaultMountablePoints, + '/export': { + fs: 'FileSystemAccess', + options: { + handle + } + } + }, + }, (e) => { + if (e) throw e + resolve() + }) + }) + return true +} + +let googleDriveFileSystem + +/** Only cached! */ +export const googleDriveGetFileIdFromPath = (path: string) => { + return googleDriveFileSystem._getExistingFileId(path) +} + +export const mountGoogleDriveFolder = async (readonly: boolean, rootId: string) => { + throw new Error('Google drive is not supported anymore') + // googleDriveFileSystem = new GoogleDriveFileSystem() + // googleDriveFileSystem.rootDirId = rootId + // googleDriveFileSystem.isReadonly = readonly + // await new Promise(resolve => { + // browserfs.configure({ + // fs: 'MountableFileSystem', + // options: { + // ...defaultMountablePoints, + // '/google': googleDriveFileSystem + // }, + // }, (e) => { + // if (e) throw e + // resolve() + // }) + // }) + // localFsState.isReadonly = readonly + // localFsState.syncFs = false + // localFsState.inMemorySave = false + // localFsState.remoteBackend = true + // return true +} + +export async function removeFileRecursiveAsync (path) { + const errors = [] as Array<[string, Error]> + try { + const files = await fs.promises.readdir(path) + + // Use Promise.all to parallelize file/directory removal + await Promise.all(files.map(async (file) => { + const curPath = join(path, file) + const stats = await fs.promises.stat(curPath) + if (stats.isDirectory()) { + // Recurse + await removeFileRecursiveAsync(curPath) + } else { + // Delete file + await fs.promises.unlink(curPath) + } + })) + + // After removing all files/directories, remove the current directory + await fs.promises.rmdir(path) + } catch (error) { + errors.push([path, error]) + } + + if (errors.length) { + setTimeout(() => { + console.error(errors) + throw new Error(`Error removing directories/files: ${errors.map(([path, err]) => `${path}: ${err.message}`).join(', ')}`) + }) + } +} + + +export const possiblyCleanHandle = (callback = () => { }) => { + if (!localFsState.saveLoaded) { + // todo clean handle + browserfs.configure({ + fs: 'MountableFileSystem', + options: defaultMountablePoints, + }, (e) => { + callback() + if (e) throw e + }) + } +} + +const readdirSafe = async (path: string) => { + try { + return await fs.promises.readdir(path) + } catch (err) { + return null + } +} + +export const collectFilesToCopy = async (basePath: string, safe = false): Promise => { + const result: string[] = [] + const countFiles = async (relPath: string) => { + const resolvedPath = join(basePath, relPath) + const files = relPath === '.' && !safe ? await fs.promises.readdir(resolvedPath) : await readdirSafe(resolvedPath) + if (!files) return null + await Promise.all(files.map(async file => { + const res = await countFiles(join(relPath, file)) + if (res === null) { + // is file + result.push(join(relPath, file)) + } + })) + } + await countFiles('.') + return result +} + +export const existsViaStats = async (path: string) => { + try { + return await fs.promises.stat(path) + } catch (e) { + return false + } +} + +export const fileExistsAsyncOptimized = async (path: string) => { + try { + await fs.promises.readdir(path) + } catch (err) { + if (err.code === 'ENOTDIR') return true + // eslint-disable-next-line sonarjs/prefer-single-boolean-return + if (err.code === 'ENOENT') return false + // throw err + return false + } + return true +} + +export const copyFilesAsync = async (pathSrc: string, pathDest: string, fileCopied?: (name) => void) => { + // query: can't use fs.copy! use fs.promises.writeFile and readFile + const files = await fs.promises.readdir(pathSrc) + + if (!await existsViaStats(pathDest)) { + await fs.promises.mkdir(pathDest, { recursive: true }) + } + + // Use Promise.all to parallelize file/directory copying + await Promise.all(files.map(async (file) => { + const curPathSrc = join(pathSrc, file) + const curPathDest = join(pathDest, file) + const stats = await fs.promises.stat(curPathSrc) + if (stats.isDirectory()) { + // Recurse + await fs.promises.mkdir(curPathDest) + await copyFilesAsync(curPathSrc, curPathDest, fileCopied) + } else { + // Copy file + try { + await fs.promises.writeFile(curPathDest, await fs.promises.readFile(curPathSrc) as any) + console.debug('copied file', curPathSrc, curPathDest) + } catch (err) { + console.error('Error copying file', curPathSrc, curPathDest, err) + throw err + } + fileCopied?.(curPathDest) + } + })) +} diff --git a/src/integratedServer/customClient.js b/src/integratedServer/customClient.js new file mode 100644 index 000000000..3f60b2de3 --- /dev/null +++ b/src/integratedServer/customClient.js @@ -0,0 +1,76 @@ +//@ts-check +const { EventEmitter } = require('events') +const debug = require('debug')('minecraft-protocol') +const states = require('minecraft-protocol/src/states') + +// window.serverDataChannel ??= {} +// export const customCommunication = { +// sendData(data) { +// setTimeout(() => { +// window.serverDataChannel[this.isServer ? 'emitClient' : 'emitServer'](data) +// }) +// }, +// receiverSetup(processData) { +// window.serverDataChannel[this.isServer ? 'emitServer' : 'emitClient'] = (data) => { +// processData(data) +// } +// } +// } + +export const createLocalServerClientImpl = (sendData, receiverSetup, excludeCommunicationDebugEvents = []) => { + return class CustomChannelClient extends EventEmitter { + constructor(isServer, version) { + super() + this.version = version + this.isServer = !!isServer + this.state = states.HANDSHAKING + } + + get state() { + return this.protocolState + } + + setSerializer(state) { + receiverSetup.call(this, (/** @type {{name, params, state?}} */parsed) => { + if (!excludeCommunicationDebugEvents.includes(parsed.name)) { + debug(`receive in ${this.isServer ? 'server' : 'client'}: ${parsed.name}`) + } + this.emit(parsed.name, parsed.params, parsed) + this.emit('packet_name', parsed.name, parsed.params, parsed) + }) + } + + // eslint-disable-next-line @typescript-eslint/adjacent-overload-signatures, grouped-accessor-pairs + set state(newProperty) { + const oldProperty = this.protocolState + this.protocolState = newProperty + + this.setSerializer(this.protocolState) + + this.emit('state', newProperty, oldProperty) + } + + end(reason) { + this._endReason = reason + this.emit('end', this._endReason) // still emits on server side only, doesn't send anything to our client + } + + write(name, params) { + if (!excludeCommunicationDebugEvents.includes(name)) { + debug(`[${this.state}] from ${this.isServer ? 'server' : 'client'}: ` + name) + debug(params) + } + + this.emit('writePacket', name, params) + sendData.call(this, { name, params, state: this.state }) + } + + writeBundle(packets) { + // no-op + } + + writeRaw(buffer) { + // no-op + } + } +} diff --git a/src/integratedServer/customServer.ts b/src/integratedServer/customServer.ts new file mode 100644 index 000000000..abe3ca323 --- /dev/null +++ b/src/integratedServer/customServer.ts @@ -0,0 +1,23 @@ +import EventEmitter from 'events' + +import { createLocalServerClientImpl } from './customClient' + +export const createCustomServerImpl = (...args: Parameters) => { + const CustomChannelClient = createLocalServerClientImpl(...args) + return class LocalServer extends EventEmitter.EventEmitter { + socketServer = null + cipher = null + decipher = null + clients = {} + + constructor (public version, public customPackets, public hideErrors = false) { + super() + } + + listen () { + this.emit('connection', new CustomChannelClient(true, this.version)) + } + + close () { } + } +} diff --git a/src/integratedServer/main.ts b/src/integratedServer/main.ts new file mode 100644 index 000000000..209c12d03 --- /dev/null +++ b/src/integratedServer/main.ts @@ -0,0 +1,130 @@ +import { useWorkerProxy } from 'prismarine-viewer/examples/workerProxy' +import { options } from '../optionsStorage' +import { setLoadingScreenStatus } from '../utils' +import { chatInputValueGlobal } from '../react/Chat' +import { showModal } from '../globalState' +import { showNotification } from '../react/NotificationProvider' +import { fsState } from '../loadSave' +import type { workerProxyType, BackEvents, CustomAppSettings } from './worker' +import { createLocalServerClientImpl } from './customClient' +import { getMcDataForWorker } from './workerMcData.mjs' + +// eslint-disable-next-line import/no-mutable-exports +export let serverChannel: typeof workerProxyType['__workerProxy'] | undefined +let worker: Worker | undefined +let lastOptions: any +let lastCustomSettings: CustomAppSettings + +const addEventListener = (type: T, listener: (data: BackEvents[T]) => void) => { + if (!worker) throw new Error('Worker not started yet') + worker.addEventListener('message', e => { + if (e.data.type === type) { + listener(e.data.data) + } + }) +} + +export const getLocalServerOptions = () => { + return lastOptions +} + +const restorePatchedDataDeep = (data) => { + // add _isBuffer to Uint8Array + if (data instanceof Uint8Array) { + //@ts-expect-error + data._isBuffer = true + return data + } + if (Array.isArray(data)) { + for (const item of data) { + restorePatchedDataDeep(item) + } + return data + } + if (typeof data === 'object' && data !== null) { + // eslint-disable-next-line guard-for-in + for (const key in data) { + restorePatchedDataDeep(data[key]) + } + } +} + +export const updateLocalServerSettings = (settings: Partial) => { + lastCustomSettings = { ...lastCustomSettings, ...settings } + serverChannel?.updateSettings(settings) +} + +export const startLocalServerMain = async (serverOptions: { version: any, worldFolder? }) => { + worker = new Worker('./integratedServer.js') + serverChannel = useWorkerProxy(worker, true) + const readyPromise = new Promise((resolve, reject) => { + addEventListener('ready', () => { + resolve() + }) + worker!.addEventListener('error', (err) => { + reject(err.error ?? 'Unknown error with the worker, check that integratedServer.js could be loaded from the server') + }) + }) + + fsState.inMemorySavePath = serverOptions.worldFolder ?? '' + void serverChannel.start({ + options: serverOptions, + mcData: await getMcDataForWorker(serverOptions.version), + settings: lastCustomSettings, + fsState: structuredClone(fsState) + }) + + await readyPromise + + const CustomClient = createLocalServerClientImpl((data) => { + if (!serverChannel) console.warn(`Server is destroyed (trying to send ${data.name} packet)`) + serverChannel?.packet(data) + }, (processData) => { + addEventListener('packet', (data) => { + restorePatchedDataDeep(data) + processData(data) + if (data.name === 'map_chunk') { + addStatPerSec('map_chunk') + } + }) + }, options.excludeCommunicationDebugEvents) + setupEvents() + return { + CustomClient + } +} + +const setupEvents = () => { + addEventListener('loadingStatus', (newStatus) => { + setLoadingScreenStatus(newStatus, false, false, true) + }) + addEventListener('notification', ({ message, title, isError, suggestCommand }) => { + const clickAction = () => { + if (suggestCommand) { + chatInputValueGlobal.value = suggestCommand + showModal({ reactType: 'chat' }) + } + } + + showNotification(title, message, isError ?? false, 'label-alt', clickAction) + }) +} + +export const destroyLocalServerMain = async (throwErr = true) => { + if (!worker) { + if (throwErr) { + throw new Error('Worker not started yet') + } + return + } + + void serverChannel!.quit() + await new Promise(resolve => { + addEventListener('quit', () => { + resolve() + }) + }) + worker.terminate() + worker = undefined + lastOptions = undefined +} diff --git a/src/integratedServer/worker.ts b/src/integratedServer/worker.ts new file mode 100644 index 000000000..592050bcb --- /dev/null +++ b/src/integratedServer/worker.ts @@ -0,0 +1,149 @@ +import { createWorkerProxy } from 'prismarine-viewer/examples/workerProxy' +import { startLocalServer } from '../createLocalServer' +import defaultServerOptions from '../defaultLocalServerOptions' +import { createCustomServerImpl } from './customServer' +import { localFsState } from './browserfsShared' +import { mountFsBackend, onWorldOpened } from './browserfsServer' + +let server: import('flying-squid/dist/index').FullServer & { options } + +export interface CustomAppSettings { + autoSave: boolean + stopLoad: boolean +} + +export interface BackEvents { + ready: {} + quit: {} + packet: any + otherPlayerPacket: { + player: string + packet: any + } + loadingStatus: string + notification: { + title: string, + message: string, + suggestCommand?: string, + isError?: boolean, + } +} + +const postMessage = (type: T, data?: BackEvents[T], ...args) => { + try { + globalThis.postMessage({ type, data }, ...args) + } catch (err) { + // eslint-disable-next-line no-debugger + debugger + } +} + +let processDataGlobal +let globalSettings: Partial = {} + +const collectTransferables = (data, collected) => { + if (data instanceof Uint8Array) { + collected.push(data.buffer) + return + } + if (Array.isArray(data)) { + for (const item of data) { + collectTransferables(item, collected) + } + return + } + if (typeof data === 'object' && data !== null) { + // eslint-disable-next-line guard-for-in + for (const key in data) { + collectTransferables(data[key], collected) + } + } +} + +const startServer = async (serverOptions) => { + const LocalServer = createCustomServerImpl((data) => { + const transferrables = [] + collectTransferables(data, transferrables) + postMessage('packet', data, transferrables) + }, (processData) => { + processDataGlobal = processData + }) + server = globalThis.server = startLocalServer(serverOptions, LocalServer) as any + + // todo need just to call quit if started + // loadingScreen.maybeRecoverable = false + // init world, todo: do it for any async plugins + if (!server.pluginsReady) { + await new Promise(resolve => { + server.once('pluginsReady', resolve) + }) + } + let wasNew = true + server.on('newPlayer', (player) => { + if (!wasNew) return + wasNew = false + // it's you! + player.on('loadingStatus', (newStatus) => { + postMessage('loadingStatus', newStatus) + }) + }) + setupServer() + postMessage('ready') +} + +const setupServer = () => { + server!.on('warpsLoaded', () => { + postMessage('notification', { + title: `${server.warps.length} Warps loaded`, + suggestCommand: '/warp ', + message: 'Use /warp to teleport to a warp point.', + }) + }) + server!.on('newPlayer', (player) => { + player.stopChunkUpdates = globalSettings.stopLoad ?? false + }) + updateSettings(true) +} + +const updateSettings = (initial = true) => { + if (!server) return + for (const player of server.players) { + player.stopChunkUpdates = globalSettings.stopLoad ?? false + } +} + +export const workerProxyType = createWorkerProxy({ + async start ({ options, mcData, settings, fsState }: { options: any, mcData: any, settings: CustomAppSettings, fsState: typeof localFsState }) { + globalSettings = settings + //@ts-expect-error + globalThis.mcData = mcData + Object.assign(localFsState, fsState) + await mountFsBackend() + // onWorldOpened(username, root) + + void startServer(options) + }, + packet (data) { + if (!processDataGlobal) throw new Error('processDataGlobal is not set yet') + processDataGlobal(data) + }, + updateSettings (settings) { + globalSettings = settings + updateSettings(false) + }, + async quit () { + try { + await server?.quit() + } catch (err) { + console.error(err) + } + postMessage('quit') + } +}) + +setInterval(() => { + if (server && globalSettings.autoSave) { + // TODO! + // void saveServer(true) + } +}, 2000) diff --git a/src/integratedServer/workerMcData.mjs b/src/integratedServer/workerMcData.mjs new file mode 100644 index 000000000..946bd683f --- /dev/null +++ b/src/integratedServer/workerMcData.mjs @@ -0,0 +1,20 @@ +//@ts-check + +export const dynamicMcDataFiles = ['language', 'blocks', 'items', 'attributes', 'particles', 'effects', 'enchantments', 'instruments', 'foods', 'entities', 'materials', 'version', 'windows', 'tints', 'biomes', 'recipes', 'blockCollisionShapes', 'loginPacket', 'protocol', 'sounds'] + +const toMajorVersion = version => { + const [a, b] = (String(version)).split('.') + return `${a}.${b}` +} + +export const getMcDataForWorker = async (version) => { + const mcDataRaw = await import('minecraft-data/data.js') // path is not actual + const allMcData = mcDataRaw.pc[version] ?? mcDataRaw.pc[toMajorVersion(version)] + const mcData = { + version: JSON.parse(JSON.stringify(allMcData.version)) + } + for (const key of dynamicMcDataFiles) { + mcData[key] = allMcData[key] + } + return mcData +} diff --git a/src/loadSave.ts b/src/loadSave.ts index 6c7da6bb2..9ef4ff980 100644 --- a/src/loadSave.ts +++ b/src/loadSave.ts @@ -2,27 +2,21 @@ import fs from 'fs' import path from 'path' import * as nbt from 'prismarine-nbt' import { proxy } from 'valtio' -import { gzip } from 'node-gzip' import { versionToNumber } from 'prismarine-viewer/viewer/prepare/utils' +import { gzip } from 'node-gzip' import { options } from './optionsStorage' import { nameToMcOfflineUUID, disconnect } from './flyingSquidUtils' -import { existsViaStats, forceCachedDataPaths, forceRedirectPaths, mkdirRecursive } from './browserfs' import { isMajorVersionGreater } from './utils' import { activeModalStacks, insertActiveModalStack, miscUiState } from './globalState' import supportedVersions from './supportedVersions.mjs' +import { existsViaStats, initialFsState, mkdirRecursive } from './integratedServer/browserfsShared' // todo include name of opened handle (zip)! // additional fs metadata -export const fsState = proxy({ - isReadonly: false, - syncFs: false, - inMemorySave: false, - saveLoaded: false, - openReadOperations: 0, - openWriteOperations: 0, - remoteBackend: false -}) + +export const fsState = proxy(structuredClone(initialFsState)) +globalThis.fsState = fsState const PROPOSE_BACKUP = true @@ -58,19 +52,6 @@ export const loadSave = async (root = '/world') => { const disablePrompts = options.disableLoadPrompts - // todo do it in singleplayer as well - // eslint-disable-next-line guard-for-in - for (const key in forceCachedDataPaths) { - - delete forceCachedDataPaths[key] - } - // eslint-disable-next-line guard-for-in - for (const key in forceRedirectPaths) { - - delete forceRedirectPaths[key] - } - // todo check jsHeapSizeLimit - const warnings: string[] = [] const { levelDat, dataRaw } = (await readLevelDat(root))! if (levelDat === undefined) { @@ -123,7 +104,7 @@ export const loadSave = async (root = '/world') => { if (playerDataOverride) { const playerDat = await gzip(nbt.writeUncompressed({ name: '', ...playerDataOverride })) if (fsState.isReadonly) { - forceCachedDataPaths[playerDatPath] = playerDat + fsState.forceCachedDataPaths[playerDatPath] = playerDat } else { await mkdirRecursive(path.dirname(playerDatPath)) await fs.promises.writeFile(playerDatPath, playerDat) @@ -162,7 +143,7 @@ export const loadSave = async (root = '/world') => { for (const rootRemapFile of rootRemapFiles) { // eslint-disable-next-line no-await-in-loop if (await existsViaStats(path.join(root, '..', rootRemapFile))) { - forceRedirectPaths[path.join(root, rootRemapFile)] = path.join(root, '..', rootRemapFile) + fsState.forceRedirectPaths[path.join(root, rootRemapFile)] = path.join(root, '..', rootRemapFile) } } diff --git a/src/localServerMultiplayer.ts b/src/localServerMultiplayer.ts index 7d147d0d7..dcfaadef5 100644 --- a/src/localServerMultiplayer.ts +++ b/src/localServerMultiplayer.ts @@ -3,6 +3,7 @@ import Peer, { DataConnection } from 'peerjs' import Client from 'minecraft-protocol/src/client' import { resolveTimeout, setLoadingScreenStatus } from './utils' import { miscUiState } from './globalState' +import { getLocalServerOptions } from './integratedServer/main' class CustomDuplex extends Duplex { constructor (options, public writeAction) { @@ -28,7 +29,7 @@ export const getJoinLink = () => { url.searchParams.delete(key) } url.searchParams.set('connectPeer', peerInstance.id) - url.searchParams.set('peerVersion', localServer!.options.version) + url.searchParams.set('peerVersion', getLocalServerOptions().version) const host = (overridePeerJsServer ?? miscUiState.appConfig?.peerJsServer) ?? undefined if (host) { // TODO! use miscUiState.appConfig.peerJsServer @@ -48,7 +49,7 @@ const copyJoinLink = async () => { } export const openToWanAndCopyJoinLink = async (writeText: (text) => void, doCopy = true) => { - if (!localServer) return + if (!getLocalServerOptions()) return if (peerInstance) { if (doCopy) await copyJoinLink() return 'Already opened to wan. Join link copied' @@ -64,7 +65,7 @@ export const openToWanAndCopyJoinLink = async (writeText: (text) => void, doCopy peer.on('connection', (connection) => { console.log('connection') const serverDuplex = new CustomDuplex({}, async (data) => connection.send(data)) - const client = new Client(true, localServer.options.version, undefined) + const client = new Client(true, getLocalServerOptions().version, undefined) client.setSocket(serverDuplex) localServer._server.emit('connection', client) diff --git a/src/optionsGuiScheme.tsx b/src/optionsGuiScheme.tsx index c6a979c16..5d397b35b 100644 --- a/src/optionsGuiScheme.tsx +++ b/src/optionsGuiScheme.tsx @@ -128,7 +128,7 @@ export const guiOptionsScheme: { id, text: 'Render Distance', unit: '', - max: sp ? 16 : 12, + max: sp ? 32 : 16, min: 1 }} /> diff --git a/src/optionsStorage.ts b/src/optionsStorage.ts index 618d99e88..ef5753509 100644 --- a/src/optionsStorage.ts +++ b/src/optionsStorage.ts @@ -26,7 +26,7 @@ const defaultOptions = { messagesLimit: 200, volume: 50, // fov: 70, - fov: 75, + fov: 90, guiScale: 3, autoRequestCompletions: true, touchButtonsSize: 40, @@ -52,6 +52,7 @@ const defaultOptions = { // antiAliasing: false, + webgpuRendererParams: {} as Record, clipWorldBelowY: undefined as undefined | number, // will be removed disableSignsMapsSupport: false, singleplayerAutoSave: false, @@ -80,6 +81,7 @@ const defaultOptions = { autoParkour: false, vrSupport: true, // doesn't directly affect the VR mode, should only disable the button which is annoying to android users renderDebug: (isDev ? 'advanced' : 'basic') as 'none' | 'advanced' | 'basic', + externalLoggingService: true, // advanced bot options autoRespawn: false, @@ -92,7 +94,7 @@ const defaultOptions = { minimapOptimizations: true, displayBossBars: false, // boss bar overlay was removed for some reason, enable safely disabledUiParts: [] as string[], - neighborChunkUpdates: true + neighborChunkUpdates: false } function getDefaultTouchControlsPositions () { diff --git a/src/react/ButtonWithTooltip.tsx b/src/react/ButtonWithTooltip.tsx index d93720070..e308d8fd5 100644 --- a/src/react/ButtonWithTooltip.tsx +++ b/src/react/ButtonWithTooltip.tsx @@ -9,14 +9,15 @@ interface Props extends React.ComponentProps { localStorageKey?: string | null offset?: number } + alwaysTooltip?: string } const ARROW_HEIGHT = 7 const GAP = 0 -export default ({ initialTooltip, ...args }: Props) => { +export default ({ initialTooltip, alwaysTooltip, ...args }: Props) => { const { localStorageKey = 'firstTimeTooltip', offset = 0 } = initialTooltip - const [showTooltips, setShowTooltips] = useState(localStorageKey ? localStorage[localStorageKey] !== 'false' : true) + const [showTooltips, setShowTooltips] = useState(alwaysTooltip || (localStorageKey ? localStorage[localStorageKey] !== 'false' : true)) useEffect(() => { let timeout @@ -67,7 +68,7 @@ export default ({ initialTooltip, ...args }: Props) => { zIndex: 11 }} > - {initialTooltip.content} + {alwaysTooltip || initialTooltip.content}
diff --git a/src/react/CreateWorldProvider.tsx b/src/react/CreateWorldProvider.tsx index b01f129c5..8af426a46 100644 --- a/src/react/CreateWorldProvider.tsx +++ b/src/react/CreateWorldProvider.tsx @@ -1,7 +1,7 @@ import { hideCurrentModal, showModal } from '../globalState' import defaultLocalServerOptions from '../defaultLocalServerOptions' -import { mkdirRecursive, uniqueFileNameFromWorldName } from '../browserfs' import supportedVersions from '../supportedVersions.mjs' +import { mkdirRecursive, uniqueFileNameFromWorldName } from '../integratedServer/browserfsShared' import CreateWorld, { WorldCustomize, creatingWorldState } from './CreateWorld' import { getWorldsPath } from './SingleplayerProvider' import { useIsModalActive } from './utilsApp' diff --git a/src/react/DebugOverlay.module.css b/src/react/DebugOverlay.module.css index 9ea30ebfb..2815a3875 100644 --- a/src/react/DebugOverlay.module.css +++ b/src/react/DebugOverlay.module.css @@ -11,12 +11,12 @@ } .debug-left-side { - top: 1px; + top: 25px; left: 1px; } .debug-right-side { - top: 5px; + top: 25px; right: 1px; /* limit renderer long text width */ width: 50%; diff --git a/src/react/MainMenu.tsx b/src/react/MainMenu.tsx index 45196f4c5..9ca3a7341 100644 --- a/src/react/MainMenu.tsx +++ b/src/react/MainMenu.tsx @@ -59,30 +59,29 @@ export default ({
- - Connect to server -
Singleplayer + mapsProvider && openURL(httpsRegex.test(mapsProvider) ? mapsProvider : 'https://' + mapsProvider, false)} + alwaysTooltip='CHECK MAPS PERF!' + /> +
+ + Multiplayer +
- - {mapsProvider && - openURL(httpsRegex.test(mapsProvider) ? mapsProvider : 'https://' + mapsProvider, false)} - />} ) } diff --git a/src/react/MainMenuRenderApp.tsx b/src/react/MainMenuRenderApp.tsx index f62cf1652..04c70644c 100644 --- a/src/react/MainMenuRenderApp.tsx +++ b/src/react/MainMenuRenderApp.tsx @@ -4,8 +4,9 @@ import { proxy, subscribe, useSnapshot } from 'valtio' import { useEffect, useState } from 'react' import { activeModalStack, miscUiState, openOptionsMenu, showModal } from '../globalState' import { openGithub, setLoadingScreenStatus } from '../utils' -import { openFilePicker, copyFilesAsync, mkdirRecursive, openWorldDirectory, removeFileRecursiveAsync } from '../browserfs' +import { openWorldDirectory, openFilePicker } from '../browserfs' +import { mkdirRecursive, copyFilesAsync, removeFileRecursiveAsync } from '../integratedServer/browserfsShared' import MainMenu from './MainMenu' import { DiscordButton } from './DiscordButton' @@ -73,8 +74,10 @@ export default () => { } }, []) - let mapsProviderUrl = appConfig?.mapsProvider - if (mapsProviderUrl && location.origin !== 'https://mcraft.fun') mapsProviderUrl = mapsProviderUrl + '?to=' + encodeURIComponent(location.href) + const mapsProviderUrl = appConfig?.mapsProvider && new URL(appConfig?.mapsProvider) + if (mapsProviderUrl && location.origin !== 'https://mcraft.fun') { + mapsProviderUrl.searchParams.set('to', location.href) + } // todo clean, use custom csstransition return @@ -113,7 +116,7 @@ export default () => { openFilePicker() } }} - mapsProvider={mapsProviderUrl} + mapsProvider={mapsProviderUrl?.toString()} versionStatus={versionStatus} versionTitle={versionTitle} onVersionStatusClick={async () => { diff --git a/src/react/PauseScreen.tsx b/src/react/PauseScreen.tsx index 75b94872b..921cd322c 100644 --- a/src/react/PauseScreen.tsx +++ b/src/react/PauseScreen.tsx @@ -18,7 +18,7 @@ import { fsState } from '../loadSave' import { disconnect } from '../flyingSquidUtils' import { pointerLock, setLoadingScreenStatus } from '../utils' import { closeWan, openToWanAndCopyJoinLink, getJoinLink } from '../localServerMultiplayer' -import { collectFilesToCopy, fileExistsAsyncOptimized, mkdirRecursive, uniqueFileNameFromWorldName } from '../browserfs' +import { collectFilesToCopy, fileExistsAsyncOptimized, mkdirRecursive, uniqueFileNameFromWorldName } from '../integratedServer/browserfsShared' import { useIsModalActive } from './utilsApp' import { showOptionsModal } from './SelectOption' import Button from './Button' @@ -44,9 +44,8 @@ export const saveToBrowserMemory = async () => { } }) }) - //@ts-expect-error - const { worldFolder } = localServer.options - const saveRootPath = await uniqueFileNameFromWorldName(worldFolder.split('/').pop(), `/data/worlds`) + const worldFolder = fsState.inMemorySavePath + const saveRootPath = await uniqueFileNameFromWorldName(worldFolder.split('/').pop()!, `/data/worlds`) await mkdirRecursive(saveRootPath) console.log('made world folder', saveRootPath) const allRootPaths = [...usedServerPathsV1] @@ -86,7 +85,7 @@ export const saveToBrowserMemory = async () => { const srcPath = join(worldFolder, copyPath) const savePath = join(saveRootPath, copyPath) await mkdirRecursive(savePath) - await fs.promises.writeFile(savePath, await fs.promises.readFile(srcPath)) + await fs.promises.writeFile(savePath, await fs.promises.readFile(srcPath) as any) upProgress(totalSIze) if (isRegionFiles) { const regionFile = copyPath.split('/').at(-1)! @@ -203,7 +202,10 @@ export default () => { if (fsStateSnap.inMemorySave || !singleplayer) { return showOptionsModal('World actions...', []) } - const action = await showOptionsModal('World actions...', ['Save to browser memory']) + const action = await showOptionsModal('World actions...', [ + ...!fsStateSnap.inMemorySave && singleplayer ? ['Save to browser memory'] : [], + 'Dump loaded chunks' + ]) if (action === 'Save to browser memory') { const path = await saveToBrowserMemory() if (!path) return @@ -214,6 +216,9 @@ export default () => { // fsState.isReadonly = false // fsState.remoteBackend = false } + if (action === 'Dump loaded chunks') { + // viewer.world.exportLoadedTiles() + } } if (!isModalActive) return null @@ -255,7 +260,7 @@ export default () => { ) : null} {!lockConnect && <> } diff --git a/src/react/SingleplayerProvider.tsx b/src/react/SingleplayerProvider.tsx index 16cf8f654..b94164aa6 100644 --- a/src/react/SingleplayerProvider.tsx +++ b/src/react/SingleplayerProvider.tsx @@ -3,11 +3,12 @@ import { proxy, subscribe, useSnapshot } from 'valtio' import { useEffect, useRef, useState } from 'react' import { loadScript } from 'prismarine-viewer/viewer/lib/utils' import { fsState, loadSave, longArrayToNumber, readLevelDat } from '../loadSave' -import { googleDriveGetFileIdFromPath, mountExportFolder, mountGoogleDriveFolder, removeFileRecursiveAsync } from '../browserfs' +// import { googleDriveGetFileIdFromPath, mountExportFolder, mountGoogleDriveFolder, removeFileRecursiveAsync } from '../browserfs' import { hideCurrentModal, showModal } from '../globalState' import { haveDirectoryPicker, setLoadingScreenStatus } from '../utils' import { exportWorld } from '../builtinCommands' import { googleProviderState, useGoogleLogIn, GoogleDriveProvider, isGoogleDriveAvailable, APP_ID } from '../googledrive' +import { mountExportFolder, removeFileRecursiveAsync } from '../integratedServer/browserfsShared' import Singleplayer, { WorldProps } from './Singleplayer' import { useIsModalActive } from './utilsApp' import { showOptionsModal } from './SelectOption' diff --git a/src/resourcePack.ts b/src/resourcePack.ts index 0ab9ec493..530f6e76f 100644 --- a/src/resourcePack.ts +++ b/src/resourcePack.ts @@ -2,7 +2,6 @@ import { join, dirname, basename } from 'path' import fs from 'fs' import JSZip from 'jszip' import { proxy, subscribe } from 'valtio' -import { mkdirRecursive, removeFileRecursiveAsync } from './browserfs' import { setLoadingScreenStatus } from './utils' import { showNotification } from './react/NotificationProvider' import { options } from './optionsStorage' @@ -10,6 +9,7 @@ import { showOptionsModal } from './react/SelectOption' import { appStatusState } from './react/AppStatusProvider' import { appReplacableResources, resourcesContentOriginal } from './generated/resources' import { loadedGameState } from './globalState' +import { mkdirRecursive, removeFileRecursiveAsync } from './integratedServer/browserfsShared' export const resourcePackState = proxy({ resourcePackInstalled: false, @@ -381,7 +381,7 @@ const updateTextures = async () => { } } if (viewer.world.active) { - await viewer.world.updateTexturesData() + await viewer.world.updateTexturesData(true) } } diff --git a/src/shims/empty.ts b/src/shims/empty.ts index f0a766d36..625313a26 100644 --- a/src/shims/empty.ts +++ b/src/shims/empty.ts @@ -1 +1,2 @@ export { } +export default {} diff --git a/src/shims/fs.js b/src/shims/fs.js index b238a3760..df83f9698 100644 --- a/src/shims/fs.js +++ b/src/shims/fs.js @@ -1,3 +1,12 @@ const BrowserFS = require('browserfs') -module.exports = BrowserFS.BFSRequire('fs') +globalThis.fs ??= BrowserFS.BFSRequire('fs') +globalThis.fs.promises = new Proxy({}, { + get(target, p) { + return (...args) => { + return globalThis.promises[p](...args) + } + } +}) + +module.exports = globalThis.fs diff --git a/src/shims/perf_hooks_replacement.js b/src/shims/perf_hooks_replacement.js index 69b0e2ed5..6c19053a2 100644 --- a/src/shims/perf_hooks_replacement.js +++ b/src/shims/perf_hooks_replacement.js @@ -1 +1 @@ -module.exports.performance = window.performance +module.exports.performance = globalThis.performance diff --git a/src/styles.css b/src/styles.css index 817fae8ca..0034c5cba 100644 --- a/src/styles.css +++ b/src/styles.css @@ -181,6 +181,15 @@ body::xr-overlay #viewer-canvas { color: #999; } +.webgpu-debug-ui { + top: 68px !important; + background: transparent; +} + +.webgpu-debug-ui .title { + background: transparent !important; +} + @media screen and (min-width: 430px) { .span-2 { grid-column: span 2; diff --git a/src/topRightStats.ts b/src/topRightStats.ts index 4bcd7264e..1a31e5d8e 100644 --- a/src/topRightStats.ts +++ b/src/topRightStats.ts @@ -39,6 +39,7 @@ if (hasRamPanel) { addStat(stats2.dom) } +const hideStats = localStorage.hideStats || isCypress() || true export const toggleStatsVisibility = (visible: boolean) => { if (visible) { stats.dom.style.display = 'block' @@ -51,7 +52,6 @@ export const toggleStatsVisibility = (visible: boolean) => { } } -const hideStats = localStorage.hideStats || isCypress() if (hideStats) { toggleStatsVisibility(false) } diff --git a/src/utils.ts b/src/utils.ts index f0d04b55a..04dbafbae 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -65,6 +65,16 @@ export const pointerLock = { } } +export const logAction = (category: string, action: string, value?: string, label?: string) => { + if (!options.externalLoggingService) return + window.loggingServiceChannel?.({ + category, + action, + value, + label + }) +} + window.getScreenRefreshRate = getScreenRefreshRate /** diff --git a/src/watchOptions.ts b/src/watchOptions.ts index 4b69726c9..5867254ef 100644 --- a/src/watchOptions.ts +++ b/src/watchOptions.ts @@ -3,10 +3,13 @@ import { subscribeKey } from 'valtio/utils' import { WorldRendererThree } from 'prismarine-viewer/viewer/lib/worldrendererThree' import { isMobile } from 'prismarine-viewer/viewer/lib/simpleUtils' +import { WorldRendererWebgpu } from 'prismarine-viewer/viewer/lib/worldrendererWebgpu' +import { defaultWebgpuRendererParams } from 'prismarine-viewer/examples/webgpuRendererShared' import { options, watchValue } from './optionsStorage' import { reloadChunks } from './utils' import { miscUiState } from './globalState' import { toggleStatsVisibility } from './topRightStats' +import { updateLocalServerSettings } from './integratedServer/main' subscribeKey(options, 'renderDistance', reloadChunks) subscribeKey(options, 'multiplayerRenderDistance', reloadChunks) @@ -62,18 +65,30 @@ export const watchOptionsAfterViewerInit = () => { viewer.world.mesherConfig.clipWorldBelowY = o.clipWorldBelowY viewer.world.mesherConfig.disableSignsMapsSupport = o.disableSignsMapsSupport if (isChanged) { - (viewer.world as WorldRendererThree).rerenderAllChunks() + if (viewer.world instanceof WorldRendererThree) { + (viewer.world as WorldRendererThree).rerenderAllChunks() + } } }) + watchValue(options, o => { + updateLocalServerSettings({ + autoSave: o.singleplayerAutoSave + }) + }) + viewer.world.mesherConfig.smoothLighting = options.smoothLighting subscribeKey(options, 'smoothLighting', () => { - viewer.world.mesherConfig.smoothLighting = options.smoothLighting; - (viewer.world as WorldRendererThree).rerenderAllChunks() + viewer.world.mesherConfig.smoothLighting = options.smoothLighting + if (viewer.world instanceof WorldRendererThree) { + (viewer.world as WorldRendererThree).rerenderAllChunks() + } }) subscribeKey(options, 'newVersionsLighting', () => { - viewer.world.mesherConfig.enableLighting = !bot.supportFeature('blockStateId') || options.newVersionsLighting; - (viewer.world as WorldRendererThree).rerenderAllChunks() + viewer.world.mesherConfig.enableLighting = !bot.supportFeature('blockStateId') || options.newVersionsLighting + if (viewer.world instanceof WorldRendererThree) { + (viewer.world as WorldRendererThree).rerenderAllChunks() + } }) customEvents.on('gameLoaded', () => { viewer.world.mesherConfig.enableLighting = !bot.supportFeature('blockStateId') || options.newVersionsLighting @@ -81,21 +96,48 @@ export const watchOptionsAfterViewerInit = () => { watchValue(options, o => { if (!(viewer.world instanceof WorldRendererThree)) return - viewer.world.starField.enabled = o.starfieldRendering + (viewer.world as WorldRendererThree).starField.enabled = o.starfieldRendering }) watchValue(options, o => { viewer.world.neighborChunkUpdates = o.neighborChunkUpdates }) + watchValue(options, o => { + viewer.powerPreference = o.gpuPreference + }) + + onRendererParamsUpdate() + + if (viewer.world instanceof WorldRendererWebgpu) { + Object.assign(viewer.world.rendererParams, options.webgpuRendererParams) + const oldUpdateRendererParams = viewer.world.updateRendererParams.bind(viewer.world) + viewer.world.updateRendererParams = (...args) => { + oldUpdateRendererParams(...args) + Object.assign(options.webgpuRendererParams, viewer.world.rendererParams) + onRendererParamsUpdate() + } + } +} + +const onRendererParamsUpdate = () => { + if (worldView) { + worldView.allowPositionUpdate = viewer.world.rendererParams.allowChunksViewUpdate + } + updateLocalServerSettings({ + stopLoad: !viewer.world.rendererParams.allowChunksViewUpdate + }) } let viewWatched = false export const watchOptionsAfterWorldViewInit = () => { + onRendererParamsUpdate() if (viewWatched) return viewWatched = true watchValue(options, o => { if (!worldView) return worldView.keepChunksDistance = o.keepChunksDistance worldView.handDisplay = o.handDisplay + + // worldView.allowPositionUpdate = o.webgpuRendererParams.allowChunksViewUpdate }) } diff --git a/src/worldInteractions.ts b/src/worldInteractions.ts index d6674ce31..55a981de9 100644 --- a/src/worldInteractions.ts +++ b/src/worldInteractions.ts @@ -6,6 +6,7 @@ import * as THREE from 'three' import { Vec3 } from 'vec3' import { LineMaterial } from 'three-stdlib' import { Entity } from 'prismarine-entity' +import { WorldRendererCommon } from 'prismarine-viewer/viewer/lib/worldrendererCommon' import destroyStage0 from '../assets/destroy_stage_0.png' import destroyStage1 from '../assets/destroy_stage_1.png' import destroyStage2 from '../assets/destroy_stage_2.png' @@ -372,7 +373,7 @@ class WorldInteraction { // Update state if (cursorChanged) { - viewer.world.setHighlightCursorBlock(cursorBlock?.position ?? null, allShapes.map(shape => { + (viewer.world as WorldRendererCommon).setHighlightCursorBlock(cursorBlock?.position ?? null, allShapes.map(shape => { return getDataFromShape(shape) })) } diff --git a/src/worldSaveWorker.ts b/src/worldSaveWorker.ts new file mode 100644 index 000000000..88a0ccafd --- /dev/null +++ b/src/worldSaveWorker.ts @@ -0,0 +1,76 @@ +import './workerWorkaround' +import fs from 'fs' +import './fs2' +import { Anvil } from 'prismarine-provider-anvil' +import WorldLoader from 'prismarine-world' + +import * as browserfs from 'browserfs' +import { generateSpiralMatrix } from 'flying-squid/dist/utils' +import '../dist/mc-data/1.14' +import { oneOf } from '@zardoy/utils' + +console.log('install') +browserfs.install(window) +window.fs = fs + +export interface ReadChunksRequest { + version: string, + +} + +onmessage = (msg) => { + globalThis.readSkylight = false + if (msg.data.type === 'readChunks') { + browserfs.configure({ + fs: 'MountableFileSystem', + options: { + '/data': { fs: 'IndexedDB' }, + }, + }, async () => { + const version = '1.14.4' + const AnvilLoader = Anvil(version) + const World = WorldLoader(version) as any + // const folder = '/data/worlds/Greenfield v0.5.3-3/region' + const { folder } = msg.data + const world = new World(() => { + throw new Error('Not implemented') + }, new AnvilLoader(folder)) + // const chunks = generateSpiralMatrix(20) + const { chunks } = msg.data + // const spawn = { + // x: 113, + // y: 64, + // } + console.log('starting...') + console.time('columns') + const loadedColumns = [] as any[] + const columnToTransfarable = (chunk) => { + return { + biomes: chunk.biomes, + // blockEntities: chunk.blockEntities, + // sectionMask: chunk.sectionMask, + sections: chunk.sections, + // skyLightMask: chunk.skyLightMask, + // blockLightMask: chunk.blockLightMask, + // skyLightSections: chunk.skyLightSections, + // blockLightSections: chunk.blockLightSections + } + } + + for (const chunk of chunks) { + const column = await world.getColumn(chunk[0], chunk[1]) + if (!column) throw new Error(`Column ${chunk[0]} ${chunk[1]} not found`) + postMessage({ + column: columnToTransfarable(column) + }) + } + postMessage({ + type: 'done', + }) + + console.timeEnd('columns') + }) + } +} + +// window.fs = fs diff --git a/tsconfig.json b/tsconfig.json index addc64f19..95f39d697 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,8 +15,8 @@ "forceConsistentCasingInFileNames": true, "useUnknownInCatchVariables": false, "skipLibCheck": true, - "experimentalDecorators": true, "strictBindCallApply": true, + "experimentalDecorators": true, // this the only options that allows smooth transition from js to ts (by not dropping types from js files) // however might need to consider includeing *only needed libraries* instead of using this "maxNodeModuleJsDepth": 1, diff --git a/vitest.config.ts b/vitest.config.ts index c27621d57..d68d160a6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,7 +8,8 @@ export default defineConfig({ '../../src/markdownToFormattedText.test.ts', '../../src/react/parseKeybindingName.test.ts', 'lib/mesher/test/tests.test.ts', - 'sign-renderer/tests.test.ts' + 'sign-renderer/tests.test.ts', // prismarine-viewer/viewer/sign-renderer/tests.test.ts + '../examples/chunksStorage.test.ts' ], }, })