diff --git a/.eslintrc.json b/.eslintrc.json index 3552f6a7b..ee307312b 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/.npmrc b/.npmrc index b602bc2e8..9ddf5ed05 100644 --- a/.npmrc +++ b/.npmrc @@ -1,3 +1,3 @@ public-hoist-pattern=* ignore-workspace-root-check=true -shell-emulator=true +shell-emulator=false 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..4edc68f38 --- /dev/null +++ b/buildWorkers.mjs @@ -0,0 +1,276 @@ +//@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', + 'flying-squid/dist': 'flying-squid/src' +} + +const result = await (watch ? context : build)({ + bundle: true, + platform: 'browser', + // entryPoints: ['renderer/playground/webgpuRendererWorker.ts', 'src/worldSaveWorker.ts'], + entryPoints: ['./renderer/playground/webgpuRendererWorker.ts'], + outdir: 'renderer/dist/', + sourcemap: watch ? 'inline' : 'external', + minify: !watch, + treeShaking: true, + logLevel: 'info', + alias: sharedAliases, + plugins: [ + { + name: 'writeOutput', + setup (build) { + build.onEnd(({ outputFiles }) => { + fs.mkdirSync('renderer/public', { recursive: true }) + fs.mkdirSync('dist', { recursive: true }) + for (const file of outputFiles) { + for (const dir of ['renderer/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 2ede8070f..a45665beb 100644 --- a/config.json +++ b/config.json @@ -2,7 +2,7 @@ "version": 1, "defaultHost": "", "defaultProxy": "https://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 c72e591fd..d2a834c00 100644 --- a/package.json +++ b/package.json @@ -5,9 +5,9 @@ "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", "build-single-file": "SINGLE_FILE_BUILD=true rsbuild build", "prepare-project": "tsx scripts/genShims.ts && tsx scripts/makeOptimizedMcData.mjs && tsx scripts/genLargeDataAliases.ts", @@ -24,14 +24,16 @@ "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 renderer/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 renderer/rsbuild.config.ts", - "watch-playground": "rsbuild dev --config renderer/rsbuild.config.ts" + "watch-playground": "rsbuild dev --config renderer/rsbuild.config.ts", + "postinstall": "cd node_modules/flying-squid && pnpm run gen-source-index" }, "keywords": [ "prismarine", @@ -54,11 +56,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", @@ -76,7 +80,7 @@ "esbuild-plugin-polyfill-node": "^0.3.0", "express": "^4.18.2", "filesize": "^10.0.12", - "flying-squid": "npm:@zardoy/flying-squid@^0.0.58", + "flying-squid": "github:zardoy/space-squid#webgpu", "fs-extra": "^11.1.1", "google-drive-browserfs": "github:zardoy/browserfs#google-drive", "jszip": "^3.10.1", @@ -111,6 +115,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", @@ -213,6 +218,9 @@ "updateConfig": { "ignoreDependencies": [] }, + "neverBuiltDependencies": [ + "flying-squid" + ], "patchedDependencies": { "three@0.154.0": "patches/three@0.154.0.patch", "pixelarticons@1.8.1": "patches/pixelarticons@1.8.1.patch", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 933acaa31..20bf74e3e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,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) @@ -68,6 +71,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 @@ -120,8 +126,8 @@ importers: specifier: ^10.0.12 version: 10.0.12 flying-squid: - specifier: npm:@zardoy/flying-squid@^0.0.58 - version: '@zardoy/flying-squid@0.0.58(encoding@0.1.13)' + specifier: github:zardoy/space-squid#webgpu + version: '@zardoy/flying-squid@https://codeload.github.com/zardoy/space-squid/tar.gz/730ddb70221fc61de40f67c0039eea8977585ec2(encoding@0.1.13)' fs-extra: specifier: ^11.1.1 version: 11.1.1 @@ -224,6 +230,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 @@ -430,6 +439,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) @@ -438,7 +450,7 @@ importers: version: 1.3.6 prismarine-block: specifier: github:zardoy/prismarine-block#next-era - version: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9(prismarine-registry@1.11.0) + version: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 prismarine-chunk: specifier: github:zardoy/prismarine-chunk#master version: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/e68e9a423b5b1907535878fb636f12c28a1a9374(minecraft-data@3.83.1) @@ -476,10 +488,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 renderer/viewer/sign-renderer: dependencies: @@ -2611,6 +2619,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: @@ -3162,6 +3178,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==} @@ -3474,6 +3493,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==} @@ -3541,10 +3563,10 @@ packages: engines: {node: '>=8'} hasBin: true - '@zardoy/flying-squid@0.0.58': - resolution: {integrity: sha512-qkSoaYRpVQaAvcVgZDTe0i4PxaK2l2B6i7GfRCEsyYFl3UaNQYBwwocXqLrIwhsc63bwXa0XQe8UNUubz+A4eA==} + '@zardoy/flying-squid@https://codeload.github.com/zardoy/space-squid/tar.gz/730ddb70221fc61de40f67c0039eea8977585ec2': + resolution: {tarball: https://codeload.github.com/zardoy/space-squid/tar.gz/730ddb70221fc61de40f67c0039eea8977585ec2} + version: 0.0.0-dev engines: {node: '>=8'} - hasBin: true '@zardoy/maxrects-packer@2.7.4': resolution: {integrity: sha512-ZIDcSdtSg6EhKFxGYWCcTnA/0YVbpixBL+psUS6ncw4IvdDF5hWauMU3XeCfYwrT/88QFgAq/Pafxt+P9OJyoQ==} @@ -6926,9 +6948,9 @@ packages: version: 1.54.0 engines: {node: '>=22'} - minecraft-protocol@https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/9e116c3dd4682b17c4e2c80249a2447a093d9284: - resolution: {tarball: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/9e116c3dd4682b17c4e2c80249a2447a093d9284} - version: 1.57.0 + minecraft-protocol@https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/9c6a4b2504a21fed7d31bd5383284c331d39c237: + resolution: {tarball: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/9c6a4b2504a21fed7d31bd5383284c331d39c237} + version: 1.55.0 engines: {node: '>=22'} minecraft-wrap@1.5.1: @@ -7151,6 +7173,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 @@ -8364,6 +8390,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 @@ -9073,6 +9103,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'} @@ -12123,6 +12156,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 @@ -13129,6 +13168,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 @@ -13553,6 +13596,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.4.0)': @@ -13632,7 +13677,7 @@ snapshots: flatmap: 0.0.3 long: 5.2.3 minecraft-data: 3.83.1 - minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/3bd4dc1b2002cd7badfa5b9cf8dda35cd6cc9ac1(patch_hash=dkeyukcqlupmk563gwxsmjr3yu)(encoding@0.1.13) + minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/9c6a4b2504a21fed7d31bd5383284c331d39c237(encoding@0.1.13) mkdirp: 2.1.6 node-gzip: 1.1.2 node-rsa: 1.1.1 @@ -13657,7 +13702,7 @@ snapshots: - encoding - supports-color - '@zardoy/flying-squid@0.0.58(encoding@0.1.13)': + '@zardoy/flying-squid@https://codeload.github.com/zardoy/space-squid/tar.gz/730ddb70221fc61de40f67c0039eea8977585ec2(encoding@0.1.13)': dependencies: '@tootallnate/once': 2.0.0 chalk: 5.3.0 @@ -13668,14 +13713,14 @@ snapshots: flatmap: 0.0.3 long: 5.2.3 minecraft-data: 3.83.1 - minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/9e116c3dd4682b17c4e2c80249a2447a093d9284(encoding@0.1.13) + minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/3bd4dc1b2002cd7badfa5b9cf8dda35cd6cc9ac1(patch_hash=dkeyukcqlupmk563gwxsmjr3yu)(encoding@0.1.13) mkdirp: 2.1.6 node-gzip: 1.1.2 node-rsa: 1.1.1 prismarine-chunk: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/e68e9a423b5b1907535878fb636f12c28a1a9374(minecraft-data@3.83.1) prismarine-entity: 2.3.1 prismarine-item: 1.16.0 - prismarine-nbt: 2.7.0 + prismarine-nbt: 2.5.0 prismarine-provider-anvil: https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/1d548fac63fe977c8281f0a9a522b37e4d92d0b7(minecraft-data@3.83.1) prismarine-windows: 2.9.0 prismarine-world: https://codeload.github.com/zardoy/prismarine-world/tar.gz/ab2146c9933eef3247c3f64446de4ccc2c484c7c @@ -17950,7 +17995,7 @@ snapshots: node-rsa: 0.4.2 prismarine-auth: 2.4.2(encoding@0.1.13) prismarine-chat: 1.10.1 - prismarine-nbt: 2.5.0 + prismarine-nbt: 2.7.0 prismarine-realms: 1.3.2(encoding@0.1.13) protodef: 1.18.0 readable-stream: 4.5.2 @@ -17986,7 +18031,7 @@ snapshots: - encoding - supports-color - minecraft-protocol@https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/9e116c3dd4682b17c4e2c80249a2447a093d9284(encoding@0.1.13): + minecraft-protocol@https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/9c6a4b2504a21fed7d31bd5383284c331d39c237(encoding@0.1.13): dependencies: '@types/node-rsa': 1.1.4 '@types/readable-stream': 4.0.12 @@ -17994,6 +18039,7 @@ snapshots: buffer-equal: 1.0.1 debug: 4.4.0(supports-color@8.1.1) endian-toggle: 0.0.0 + lodash.get: 4.4.2 lodash.merge: 4.6.2 minecraft-data: 3.83.1 minecraft-folder-path: 1.2.0 @@ -18073,7 +18119,7 @@ snapshots: mineflayer-pathfinder@2.4.4: dependencies: minecraft-data: 3.83.1 - prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9(prismarine-registry@1.11.0) + prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 prismarine-entity: 2.3.1 prismarine-item: 1.16.0 prismarine-nbt: 2.5.0 @@ -18085,7 +18131,7 @@ snapshots: minecraft-data: 3.83.1 minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/5ec3dd4b367fcc039fbcb3edd214fe3cf8178a6d(patch_hash=dkeyukcqlupmk563gwxsmjr3yu)(encoding@0.1.13) prismarine-biome: 1.3.0(minecraft-data@3.83.1)(prismarine-registry@1.11.0) - prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9(prismarine-registry@1.11.0) + prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 prismarine-chat: 1.10.1 prismarine-chunk: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/e68e9a423b5b1907535878fb636f12c28a1a9374(minecraft-data@3.83.1) prismarine-entity: 2.3.1 @@ -18108,7 +18154,7 @@ snapshots: minecraft-data: 3.83.1 minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/5ec3dd4b367fcc039fbcb3edd214fe3cf8178a6d(patch_hash=dkeyukcqlupmk563gwxsmjr3yu)(encoding@0.1.13) prismarine-biome: 1.3.0(minecraft-data@3.83.1)(prismarine-registry@1.11.0) - prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9(prismarine-registry@1.11.0) + prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 prismarine-chat: 1.10.1 prismarine-chunk: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/e68e9a423b5b1907535878fb636f12c28a1a9374(minecraft-data@3.83.1) prismarine-entity: 2.3.1 @@ -18345,6 +18391,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 @@ -18897,7 +18945,7 @@ snapshots: minecraft-data: 3.83.1 prismarine-registry: 1.11.0 - prismarine-block@https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9(prismarine-registry@1.11.0): + prismarine-block@https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9: dependencies: minecraft-data: 3.83.1 prismarine-biome: 1.3.0(minecraft-data@3.83.1)(prismarine-registry@1.11.0) @@ -18905,8 +18953,6 @@ snapshots: prismarine-item: 1.16.0 prismarine-nbt: 2.5.0 prismarine-registry: 1.11.0 - transitivePeerDependencies: - - prismarine-registry prismarine-chat@1.10.1: dependencies: @@ -18917,7 +18963,7 @@ snapshots: prismarine-chunk@https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/e68e9a423b5b1907535878fb636f12c28a1a9374(minecraft-data@3.83.1): dependencies: prismarine-biome: 1.3.0(minecraft-data@3.83.1)(prismarine-registry@1.11.0) - prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9(prismarine-registry@1.11.0) + prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 prismarine-nbt: 2.5.0 prismarine-registry: 1.11.0 smart-buffer: 4.2.0 @@ -18955,7 +19001,7 @@ snapshots: prismarine-provider-anvil@https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/1d548fac63fe977c8281f0a9a522b37e4d92d0b7(minecraft-data@3.83.1): dependencies: - prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9(prismarine-registry@1.11.0) + prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 prismarine-chunk: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/e68e9a423b5b1907535878fb636f12c28a1a9374(minecraft-data@3.83.1) prismarine-nbt: 2.5.0 prismarine-world: https://codeload.github.com/zardoy/prismarine-world/tar.gz/ab2146c9933eef3247c3f64446de4ccc2c484c7c @@ -18979,13 +19025,13 @@ snapshots: prismarine-registry@1.11.0: dependencies: minecraft-data: 3.83.1 - prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9(prismarine-registry@1.11.0) + prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 prismarine-nbt: 2.7.0 prismarine-schematic@1.2.3: dependencies: minecraft-data: 3.83.1 - prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9(prismarine-registry@1.11.0) + prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 prismarine-nbt: 2.5.0 prismarine-world: https://codeload.github.com/zardoy/prismarine-world/tar.gz/ab2146c9933eef3247c3f64446de4ccc2c484c7c vec3: 0.1.10 @@ -19822,6 +19868,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: {} @@ -20735,6 +20786,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/renderer/buildMesherWorker.mjs b/renderer/buildMesherWorker.mjs index 2f9a4a021..47ea57718 100644 --- a/renderer/buildMesherWorker.mjs +++ b/renderer/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' @@ -39,7 +39,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/renderer/package.json b/renderer/package.json index 10049f4f8..10bc29fb8 100644 --- a/renderer/package.json +++ b/renderer/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/renderer/playground.html b/renderer/playground.html index ec4c0f33c..258426feb 100644 --- a/renderer/playground.html +++ b/renderer/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/renderer/playground/Cube.comp.wgsl b/renderer/playground/Cube.comp.wgsl new file mode 100644 index 000000000..6492bf74d --- /dev/null +++ b/renderer/playground/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 > textureLoad(depthTexture, vec2u(pos.x, textureSize.y - pos.y), 0) * 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/renderer/playground/Cube.frag.wgsl b/renderer/playground/Cube.frag.wgsl new file mode 100644 index 000000000..c9dbcbcd0 --- /dev/null +++ b/renderer/playground/Cube.frag.wgsl @@ -0,0 +1,25 @@ +@group(0) @binding(1) var mySampler: sampler; +@group(0) @binding(2) var myTexture: texture_2d; + + struct FragmentOutput { + @builtin(frag_depth) depth: f32, + @location(0) color: vec4f + } + + 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)); +} + +@fragment +fn main( + @location(0) fragUV: vec2f, + @location(1) @interpolate(flat) ColorBlend: vec4f, + @builtin(position) Position: vec4f +) -> FragmentOutput { + let pixelColor = textureSample(myTexture, mySampler, fragUV); + + var output: FragmentOutput; + output.depth = linearize_depth_ndc(Position.z, 0.05, 10000) / 10000; + output.color = vec4f(pixelColor.rgb, 1.0) * ColorBlend;; + return output; +} diff --git a/renderer/playground/Cube.vert.wgsl b/renderer/playground/Cube.vert.wgsl new file mode 100644 index 000000000..9717e8a94 --- /dev/null +++ b/renderer/playground/Cube.vert.wgsl @@ -0,0 +1,101 @@ + +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) ColorBlend: vec4f, +} +@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>; +@group(0) @binding(2) var myTexture: texture_2d; +@group(0) @binding(5) var tileSize: vec2; + +@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 chunk = chunks[cube.cube[2]]; + + let modelIndex : u32 = extractBits(cube.cube[0], 18, 14); ///14 bits + + var cube_position = vec3f(f32(i32(extractBits(cube.cube[0], 0, 4)) + chunk.x * 16), + f32(extractBits(cube.cube[0], 4, 10)), + f32(i32(extractBits(cube.cube[0], 14, 4)) + chunk.z * 16)); + + cube_position += 0.5; + + var colorBlend = vec4f(unpack4xU8(cube.cube[1])); + colorBlend.a = f32(chunk.opacity); + colorBlend /= 255; + + var textureIndex : u32; + var Uv = vec2(uv.x, (1.0 - uv.y)); + let 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; + } + } + + let textureSize = vec2f(textureDimensions(myTexture)); + let tilesPerTexture= textureSize / tileSize; + Uv = vec2(Uv / tilesPerTexture + vec2f(trunc(f32(textureIndex) % tilesPerTexture.y), trunc(f32(textureIndex) / tilesPerTexture.x)) / tilesPerTexture); + + var output: VertexOutput; + output.Position = ViewProjectionMatrix * (position * normal + vec4(cube_position, 0.0)); + output.fragUV = Uv; + output.ColorBlend = colorBlend; + return output; +} diff --git a/renderer/playground/CubeDef.ts b/renderer/playground/CubeDef.ts new file mode 100644 index 000000000..2f4c6889e --- /dev/null +++ b/renderer/playground/CubeDef.ts @@ -0,0 +1,73 @@ +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 +]) + +export const quadVertexCountStrip = 4 + +export const quadVertexArrayStrip = 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, 0, 1, // top-let + 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, 0, // bottom-let +]) \ No newline at end of file diff --git a/renderer/playground/CubeSort.comp.wgsl b/renderer/playground/CubeSort.comp.wgsl new file mode 100644 index 000000000..9a0aa0af7 --- /dev/null +++ b/renderer/playground/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/renderer/playground/TextureAnimation.ts b/renderer/playground/TextureAnimation.ts new file mode 100644 index 000000000..6c5835437 --- /dev/null +++ b/renderer/playground/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/renderer/playground/TouchControls2.tsx b/renderer/playground/TouchControls2.tsx new file mode 100644 index 000000000..669b4fdc4 --- /dev/null +++ b/renderer/playground/TouchControls2.tsx @@ -0,0 +1,79 @@ +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' + +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/renderer/playground/baseScene.ts b/renderer/playground/baseScene.ts index b9e7791da..6963f1314 100644 --- a/renderer/playground/baseScene.ts +++ b/renderer/playground/baseScene.ts @@ -20,16 +20,19 @@ import { BlockNames } from '../../src/mcDataTypes' import { initWithRenderer, statsEnd, statsStart } from '../../src/topRightStats' import { defaultWorldRendererConfig } from '../viewer/lib/worldrendererCommon' import { getSyncWorld } from './shared' +import { defaultWebgpuRendererParams, rendererParamsGui } from './webgpuRendererShared' window.THREE = THREE export class BasePlaygroundScene { + webgpuRendererParams = false continuousRender = false stopRender = 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)) { @@ -133,6 +157,18 @@ export class BasePlaygroundScene { this.onParamsUpdate(property, object) } }) + + 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 @@ -151,19 +187,36 @@ export class BasePlaygroundScene { this.world.setBlock(this.targetPos.offset(xOffset, yOffset, zOffset), block) } + lockCameraInUrl () { + this.params.camera = this.getCameraStateString() + this.updateQs('camera', this.params.camera) + } + 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) @@ -176,9 +229,9 @@ 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 + this.worldConfig.isPlayground = true worldView.addWaitTime = 0 window.worldView = worldView @@ -189,9 +242,17 @@ export class BasePlaygroundScene { // Create viewer const viewer = new Viewer(renderer, this.worldConfig) + viewer.setFirstPersonCamera(null, viewer.camera.rotation.y, viewer.camera.rotation.x) window.viewer = viewer window.world = window.viewer.world - 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 @@ -200,14 +261,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() @@ -224,7 +280,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) @@ -235,13 +291,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', () => { @@ -316,7 +366,6 @@ 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') { // refresh block (main) @@ -328,6 +377,9 @@ export class BasePlaygroundScene { void worldView!.init(this.targetPos) } } + if (e.code === 'KeyT') { + viewer.camera.position.y += 100 * (e.shiftKey ? -1 : 1) + } }) document.addEventListener('visibilitychange', () => { this.windowHidden = document.visibilityState === 'hidden' @@ -369,13 +421,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/renderer/playground/chunksStorage.test.ts b/renderer/playground/chunksStorage.test.ts new file mode 100644 index 000000000..344ad3345 --- /dev/null +++ b/renderer/playground/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/renderer/playground/chunksStorage.ts b/renderer/playground/chunksStorage.ts new file mode 100644 index 000000000..cb74defdf --- /dev/null +++ b/renderer/playground/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/renderer/playground/messageChannel.ts b/renderer/playground/messageChannel.ts new file mode 100644 index 000000000..fa99db319 --- /dev/null +++ b/renderer/playground/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/renderer/playground/scenes/cubesHouse.ts b/renderer/playground/scenes/cubesHouse.ts new file mode 100644 index 000000000..562ec78e8 --- /dev/null +++ b/renderer/playground/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/renderer/playground/scenes/floorRandom.ts b/renderer/playground/scenes/floorRandom.ts index c6d2ccf1c..20ba5b289 100644 --- a/renderer/playground/scenes/floorRandom.ts +++ b/renderer/playground/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/renderer/playground/scenes/floorStoneWorld.ts b/renderer/playground/scenes/floorStoneWorld.ts new file mode 100644 index 000000000..5db1c9354 --- /dev/null +++ b/renderer/playground/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/renderer/playground/scenes/index.ts b/renderer/playground/scenes/index.ts index bf881812d..fe9929ea2 100644 --- a/renderer/playground/scenes/index.ts +++ b/renderer/playground/scenes/index.ts @@ -1,6 +1,6 @@ // 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' diff --git a/renderer/playground/scenes/layers.ts b/renderer/playground/scenes/layers.ts new file mode 100644 index 000000000..e01495750 --- /dev/null +++ b/renderer/playground/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/renderer/playground/shared.ts b/renderer/playground/shared.ts index ba58a57fa..c3eb48fbf 100644 --- a/renderer/playground/shared.ts +++ b/renderer/playground/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,14 @@ export type BlockFaceType = { } export type BlockType = { - faces: BlockFaceType[] + faces?: BlockFaceType[] + visibleFaces: number[] + modelId: number + transparent: boolean + tint?: [number, number, number] // for testing + chunk?: number block: string } diff --git a/renderer/playground/webgpuBlockModels.ts b/renderer/playground/webgpuBlockModels.ts new file mode 100644 index 000000000..2d48c796d --- /dev/null +++ b/renderer/playground/webgpuBlockModels.ts @@ -0,0 +1,167 @@ +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 { WorldRendererWebgpu } from '../viewer/webgpu/worldrendererWebgpu' +import { WEBGPU_FULL_TEXTURES_LIMIT } from './webgpuRendererShared' + +export const prepareCreateWebgpuBlocksModelsData = (worldRenderer: WorldRendererWebgpu, onlyGetInterestedTiles = false) => { + 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(worldRenderer.version) < versionToNumber('1.13') + const PBlockOriginal = PrismarineBlock(worldRenderer.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 validateTileIndex = (tileIndex: number, blockName: string) => { + if (onlyGetInterestedTiles) return + if (tileIndex < 0 || tileIndex >= WEBGPU_FULL_TEXTURES_LIMIT) { + throw new Error(`Tile index ${tileIndex} is out of range for block ${blockName}`) + } + } + const addBlockModel = (state: number, name: string, props: Record, mcBlockData?: IndexedBlock, defaultState = false) => { + const possibleIssues = [] as string[] + const models = worldRenderer.resourcesManager.currentResources!.worldBlockProvider.getAllResolvedModels0_1({ + name, + properties: props + }, isPreflat, possibleIssues, [], [], true) + // 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}`) + } + validateTileIndex(texture.tileIndex, name) + 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 = worldRenderer.resourcesManager.currentResources!.worldBlockProvider.getTextureInfo(textureOverride) + if (!texture) { + console.warn('Missing texture override') + continue + } + const texIndex = texture.tileIndex + allBlocksStateIdToModelIdMap[state] = k + validateTileIndex(texIndex, block.name) + 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/renderer/playground/webgpuRenderer.ts b/renderer/playground/webgpuRenderer.ts new file mode 100644 index 000000000..8860d9885 --- /dev/null +++ b/renderer/playground/webgpuRenderer.ts @@ -0,0 +1,1277 @@ +import * as THREE from 'three' +import * as tweenJs from '@tweenjs/tween.js' +import VolumtetricFragShader from '../viewer/lib/webgpuShaders/RadialBlur/frag.wgsl' +import VolumtetricVertShader from '../viewer/lib/webgpuShaders/RadialBlur/vert.wgsl' +import { BlockFaceType } from './shared' +import { PositionOffset, UVOffset, cubeVertexSize, quadVertexArrayStrip, quadVertexCountStrip } 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: quadVertexArrayStrip.byteLength, + usage: GPUBufferUsage.VERTEX, + mappedAtCreation: true, + }) + + this.verticesBuffer = verticesBuffer + new Float32Array(verticesBuffer.getMappedRange()).set(quadVertexArrayStrip) + 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-strip', + 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-strip', + cullMode: 'none', + }, + }) + + this.depthTexture = 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: presentationFormat, + usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING, + }) + + 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', + }, + ], + } + + 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, + compute: { + module: computeShaderModule, + entryPoint: 'main', + }, + }) + + this.computeSortPipeline = device.createComputePipeline({ + label: 'Culled Instance', + layout: computePipelineLayout, + 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 + + // 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([quadVertexCountStrip, 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: device.createSampler({ + magFilter: 'nearest', + minFilter: 'nearest', + mipmapFilter: 'nearest', + compare: 'less-equal' + }), + }, + { + binding: 2, + resource: this.tempTexture.createView(), + }, + { + binding: 3, + resource: { buffer: this.clearColorBuffer }, + }, + { + binding: 4, + resource: device.createSampler({ + magFilter: 'nearest', + minFilter: 'nearest', + mipmapFilter: 'nearest' + }), + } + ] + }) + + + + 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 isTransparent = block.transparent + + 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(quadVertexCountStrip) + 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/renderer/playground/webgpuRendererShared.ts b/renderer/playground/webgpuRendererShared.ts new file mode 100644 index 000000000..d61aec193 --- /dev/null +++ b/renderer/playground/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: true +} + +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/renderer/playground/webgpuRendererWorker.ts b/renderer/playground/webgpuRendererWorker.ts new file mode 100644 index 000000000..6ac88f07b --- /dev/null +++ b/renderer/playground/webgpuRendererWorker.ts @@ -0,0 +1,311 @@ +/// +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: '', + transparent: false, + } 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/renderer/playground/workerProxy.ts b/renderer/playground/workerProxy.ts new file mode 100644 index 000000000..9d8e7fcc0 --- /dev/null +++ b/renderer/playground/workerProxy.ts @@ -0,0 +1,61 @@ +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) + } + }) + return null as any +} + +/** + * in main thread + * ```ts + * // either: + * import type { importedTypeWorkerProxy } from './worker' + * // or: + * type importedTypeWorkerProxy = import('./worker').importedTypeWorkerProxy + * + * const workerChannel = useWorkerProxy(worker) + * ``` + */ +export const useWorkerProxy = void> }> (worker: Worker | MessagePort, autoTransfer = true): T['__workerProxy'] & { + transfer: (...args: Transferable[]) => T['__workerProxy'] +} => { + // in main thread + return new Proxy({} as any, { + get (target, prop) { + if (prop === 'transfer') { + return (...transferable: Transferable[]) => { + return new Proxy({}, { + get (target, prop) { + return (...args: any[]) => { + worker.postMessage({ + type: prop, + args, + }, transferable) + } + } + }) + } + } + return (...args: any[]) => { + 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 as any[]) + } + } + }) +} + +// const workerProxy = createWorkerProxy({ +// startRender (canvas: HTMLCanvasElement) { +// }, +// }) + +// const worker = useWorkerProxy(null, workerProxy) + +// worker. diff --git a/renderer/rsbuildSharedConfig.ts b/renderer/rsbuildSharedConfig.ts index c39c4454b..576db9358 100644 --- a/renderer/rsbuildSharedConfig.ts +++ b/renderer/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'), ] }, }, @@ -34,11 +46,16 @@ export const appAndRendererSharedConfig = () => defineConfig({ crypto: path.join(__dirname, `../src/shims/crypto.js`), dns: path.join(__dirname, `../src/shims/dns.js`), yggdrasil: path.join(__dirname, `../src/shims/yggdrasilReplacement.ts`), + 'flying-squid/dist': 'flying-squid/src', 'three$': 'three/src/Three.js', 'stats.js$': 'stats.js/src/Stats.js', }, 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 +73,8 @@ export const appAndRendererSharedConfig = () => defineConfig({ }, plugins: [ pluginReact(), - pluginNodePolyfill() + pluginNodePolyfill(), + ...process.env.ENABLE_HTTPS ? [pluginBasicSsl()] : [] ], tools: { rspack (config, helpers) { diff --git a/renderer/viewer/common/webglData.ts b/renderer/viewer/common/webglData.ts new file mode 100644 index 000000000..7bff761cb --- /dev/null +++ b/renderer/viewer/common/webglData.ts @@ -0,0 +1,27 @@ +import { join } from 'path' +import fs from 'fs' + +export type WebglData = ReturnType + +export const prepareWebglData = (blockTexturesDir: string, atlas: any) => { + // 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/renderer/viewer/lib/basePlayerState.ts b/renderer/viewer/lib/basePlayerState.ts index 93f4f6ce6..df8cbc616 100644 --- a/renderer/viewer/lib/basePlayerState.ts +++ b/renderer/viewer/lib/basePlayerState.ts @@ -14,6 +14,9 @@ export type PlayerStateEvents = { heldItemChanged: (item: HandItemBlock | undefined, isLeftHand: boolean) => void } +export type BlockShape = { position: any; width: any; height: any; depth: any; } +export type BlocksShapes = BlockShape[] + export interface IPlayerState { getEyeHeight(): number getMovementState(): MovementState @@ -39,6 +42,21 @@ export interface IPlayerState { ambientLight: number directionalLight: number gameMode?: GameMode + lookingAtBlock?: { + x: number + y: number + z: number + face?: number + shapes: BlocksShapes + } + diggingBlock?: { + x: number + y: number + z: number + stage: number + face?: number + mergedShape?: BlockShape + } } } diff --git a/renderer/viewer/lib/mesher/getPreflatBlock.ts b/renderer/viewer/lib/mesher/getPreflatBlock.ts new file mode 100644 index 000000000..6c9ebfd5a --- /dev/null +++ b/renderer/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/renderer/viewer/lib/mesher/mesher.ts b/renderer/viewer/lib/mesher/mesher.ts index 21e2d8efd..e79a67a0b 100644 --- a/renderer/viewer/lib/mesher/mesher.ts +++ b/renderer/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 } from './models' import { BlockStateModelInfo } from './shared' globalThis.structuredClone ??= (value) => JSON.parse(JSON.stringify(value)) @@ -66,9 +66,10 @@ function setSectionDirty (pos, value = true) { } const softCleanup = () => { - // clean block cache and loaded chunks - world = new World(world.config.version) - globalThis.world = world + if (world) { + world.blockCache = {} + world.blockStateModelInfo = new Map() + } } const handleMessage = data => { @@ -85,7 +86,10 @@ const handleMessage = data => { world.erroredBlockModel = undefined } - world ??= new World(data.config.version) + if (!world) { + world = new World(data.config.version) + setWorld(new World(data.config.version)) + } world.config = { ...world.config, ...data.config } globalThis.world = world globalThis.Vec3 = Vec3 @@ -133,6 +137,7 @@ const handleMessage = data => { } case 'reset': { world = undefined as any + setWorld(undefined as any) // blocksStates = null dirtySections = new Map() // todo also remove cached @@ -141,6 +146,15 @@ const handleMessage = data => { break } + case 'specialBlockState': { + setSpecialBlockState(data.data) + + break + } + case 'webgpuData': { + world.setDataForWebgpuRenderer(data.data) + break + } case 'getCustomBlockModel': { const pos = new Vec3(data.pos.x, data.pos.y, data.pos.z) const chunkKey = `${Math.floor(pos.x / 16) * 16},${Math.floor(pos.z / 16) * 16}` @@ -148,7 +162,6 @@ const handleMessage = data => { global.postMessage({ type: 'customBlockModel', chunkKey, customBlockModel }) break } - // No default } } diff --git a/renderer/viewer/lib/mesher/models.ts b/renderer/viewer/lib/mesher/models.ts index 677886f9c..21936fb50 100644 --- a/renderer/viewer/lib/mesher/models.ts +++ b/renderer/viewer/lib/mesher/models.ts @@ -2,12 +2,22 @@ import { Vec3 } from 'vec3' import worldBlockProvider, { WorldBlockProvider } from 'mc-assets/dist/worldBlockProvider' import legacyJson from '../../../../src/preflatMap.json' import { BlockType } from '../../../playground/shared' -import { World, BlockModelPartsResolved, WorldBlock as Block } from './world' +import { World, BlockModelPartsResolved, WorldBlock as Block, WorldBlock } from './world' import { BlockElement, buildRotationMatrix, elemFaces, matmul3, matmulmat3, vecadd3, vecsub3 } from './modelsGeometryCommon' import { INVISIBLE_BLOCKS } from './worldConstants' import { MesherGeometryOutput, HighestBlockInfo } from './shared' +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 +} + let blockProvider: WorldBlockProvider const tints: any = {} @@ -124,7 +134,7 @@ const isCube = (block: Block) => { })) } -function renderLiquid (world: World, cursor: Vec3, texture: any | undefined, type: number, biome: string, water: boolean, attr: Record) { +function renderLiquid (world: World, cursor: Vec3, texture: any | undefined, type: number, biome: string, liquidType: 'water' | 'lava', attr: Record) { const heights: number[] = [] for (let z = -1; z <= 1; z++) { for (let x = -1; x <= 1; x++) { @@ -141,7 +151,7 @@ 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] as (typeof elemFaces)['down'] const isUp = dir[1] === 1 const neighborPos = cursor.offset(...dir as [number, number, number]) @@ -151,8 +161,8 @@ function renderLiquid (world: World, cursor: Vec3, texture: any | undefined, typ const isGlass = neighbor.name.includes('glass') if ((isCube(neighbor) && !isUp) || neighbor.material === 'plant' || neighbor.getProperties().waterlogged) continue - let tint = [1, 1, 1] - if (water) { + let tint = [1, 1, 1] as [number, number, number] + if (liquidType === 'water') { let m = 1 // Fake lighting to improve lisibility if (Math.abs(dir[0]) > 0) m = 0.6 else if (Math.abs(dir[2]) > 0) m = 0.8 @@ -162,17 +172,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', + const blockKey = `${cursor.x},${cursor.y},${cursor.z}` + const stateId = world.liquidBlocks[liquidType] + tiles[blockKey] ??= { + block: liquidType, faces: [], + visibleFaces: [], + modelId: world.webgpuModelsMapping[stateId], + transparent: liquidType === 'water', + tint } - tiles[`${cursor.x},${cursor.y},${cursor.z}`].faces.push({ + tiles[blockKey].faces!.push({ face, neighbor: `${neighborPos.x},${neighborPos.y},${neighborPos.z}`, side: 0, // todo - textureIndex: 0, + tint, // texture: eFace.texture.name, }) + tiles[blockKey].visibleFaces.push(webgpuSide) } const { u } = texture @@ -234,16 +251,14 @@ const identicalCull = (currentElement: BlockElement, neighbor: Block, direction: let needSectionRecomputeOnChange = false -function renderElement (world: World, cursor: Vec3, element: BlockElement, doAO: boolean, attr: MesherGeometryOutput, globalMatrix: any, globalShift: any, block: Block, biome: string) { +function renderElement (world: World, cursor: Vec3, element: BlockElement, doAO: boolean, attr: MesherGeometryOutput, globalMatrix: any, globalShift: any, block: WorldBlock, biome: string) { const position = cursor - // const key = `${position.x},${position.y},${position.z}` - // if (!globalThis.allowedBlocks.includes(key)) return const cullIfIdentical = block.name.includes('glass') || block.name.includes('ice') // 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, side, webgpuSide } = elemFaces[face] as (typeof elemFaces)['down'] const dir = matmul3(globalMatrix, elemFaces[face].dir) if (eFace.cullface) { @@ -401,23 +416,36 @@ 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, + const blockKey = `${cursor.x},${cursor.y},${cursor.z}` + const modelId = world.webgpuModelsMapping[block.stateId] + if (modelId !== 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[blockKey] ??= { + block: block.name, + visibleFaces: [], + faces: [], + modelId, + transparent: block.transparent, tint: lightWithColor, - //@ts-expect-error debug prop - texture: eFace.texture.debugName || block.name, - } satisfies BlockType['faces'][number]) + } + const needsOnlyOneFace = false + const isTilesEmpty = tiles[blockKey].faces!.length < 1 + if (isTilesEmpty || !needsOnlyOneFace) { + tiles[blockKey].visibleFaces.push(webgpuSide) + tiles[blockKey].faces!.push({ + face, + side, + neighbor: `${neighborPos.x},${neighborPos.y},${neighborPos.z}`, + light: baseLight, + tint: lightWithColor, + //@ts-expect-error debug + texture: eFace.texture.debugName || block.name, + }) + } } } @@ -539,11 +567,11 @@ export function getSectionGeometry (sx, sy, sz, world: World) { 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) + renderLiquid(world, pos, blockProvider.getTextureInfo('water_still'), block.type, biome, 'water', attr) }) 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, 'lava', attr) attr.blocksCount++ } if (block.name !== 'water' && block.name !== 'lava' && !INVISIBLE_BLOCKS.has(block.name)) { @@ -566,16 +594,18 @@ export function getSectionGeometry (sx, sy, sz, world: World) { // #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 (!world.webgpuModelsMapping) { + 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)) } - } - if (globalMatrix) { - globalShift = [8, 8, 8] - globalShift = vecsub3(globalShift, matmul3(globalMatrix, globalShift)) } // #endregion @@ -640,6 +670,9 @@ export function getSectionGeometry (sx, sy, sz, world: World) { } export const setBlockStatesData = (blockstatesModels, blocksAtlas: any, _needTiles = false, useUnknownBlockModel = true, version = 'latest') => { + if (world) { + world.blockCache = {} + } blockProvider = worldBlockProvider(blockstatesModels, blocksAtlas, version) globalThis.blockProvider = blockProvider if (useUnknownBlockModel) { diff --git a/renderer/viewer/lib/mesher/modelsGeometryCommon.ts b/renderer/viewer/lib/mesher/modelsGeometryCommon.ts index 3df205569..a28d8bfa1 100644 --- a/renderer/viewer/lib/mesher/modelsGeometryCommon.ts +++ b/renderer/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/renderer/viewer/lib/mesher/test/mesherTester.ts b/renderer/viewer/lib/mesher/test/mesherTester.ts index e75d803de..c62c9df97 100644 --- a/renderer/viewer/lib/mesher/test/mesherTester.ts +++ b/renderer/viewer/lib/mesher/test/mesherTester.ts @@ -19,7 +19,7 @@ export const setup = (version, initialBlocks: Array<[number[], string]>) => { const getGeometry = () => { const sectionGeometry = getSectionGeometry(0, 0, 0, mesherWorld) - const centerFaces = sectionGeometry.tiles[`${pos.x},${pos.y},${pos.z}`]?.faces.length ?? 0 + const centerFaces = sectionGeometry.tiles[`${pos.x},${pos.y},${pos.z}`]?.faces?.length ?? 0 const totalTiles = Object.values(sectionGeometry.tiles).reduce((acc, val: any) => acc + val.faces.length, 0) const centerTileNeighbors = Object.entries(sectionGeometry.tiles).reduce((acc, [key, val]: any) => { return acc + val.faces.filter((face: any) => face.neighbor === `${pos.x},${pos.y},${pos.z}`).length diff --git a/renderer/viewer/lib/mesher/world.ts b/renderer/viewer/lib/mesher/world.ts index f2757ae62..3b084979b 100644 --- a/renderer/viewer/lib/mesher/world.ts +++ b/renderer/viewer/lib/mesher/world.ts @@ -4,9 +4,10 @@ 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, CustomBlockModels, BlockStateModelInfo, getBlockAssetsCacheKey } from './shared' +import type { AllBlocksStateIdToModelIdMap } from '../../../playground/webgpuBlockModels' +import { BlockStateModelInfo, CustomBlockModels, defaultMesherConfig, getBlockAssetsCacheKey, MesherGeometryOutput } from './shared' import { INVISIBLE_BLOCKS } from './worldConstants' +import { getPreflatBlock } from './getPreflatBlock' const ignoreAoBlocks = Object.keys(moreBlockDataGeneratedJson.noOcclusions) @@ -27,6 +28,7 @@ export type WorldBlock = Omit & { isCube: boolean /** cache */ models?: BlockModelPartsResolved | null + patchedModels: Record _originalProperties?: Record _properties?: Record } @@ -39,9 +41,14 @@ export class World { biomeCache: { [id: number]: mcData.Biome } preflat: boolean erroredBlockModel?: BlockModelPartsResolved + webgpuModelsMapping: AllBlocksStateIdToModelIdMap customBlockModels = new Map() // chunkKey -> blockModels sentBlockStateModels = new Set() blockStateModelInfo = new Map() + liquidBlocks = { + water: (globalThis as any).mcData.blocks.find(x => x.name === 'water').defaultState, + lava: (globalThis as any).mcData.blocks.find(x => x.name === 'lava').defaultState + } constructor (version) { this.Chunk = Chunks(version) as any @@ -116,7 +123,7 @@ export class World { return this.getColumn(Math.floor(pos.x / 16) * 16, Math.floor(pos.z / 16) * 16) } - getBlock (pos: Vec3, blockProvider?: WorldBlockProvider, attr?: { hadErrors?: boolean }): WorldBlock | null { + getBlock (pos: Vec3, blockProvider?: WorldBlockProvider, 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) @@ -135,6 +142,7 @@ export class World { if (!this.blockCache[cacheKey]) { const b = column.getBlock(locInChunk) as unknown as WorldBlock + b.patchedModels = {} if (modelOverride) { b.name = modelOverride } @@ -146,21 +154,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 + }) } } @@ -241,15 +240,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/renderer/viewer/lib/ui/newStats.ts b/renderer/viewer/lib/ui/newStats.ts index 8ccd4e57a..4a1b0a0fb 100644 --- a/renderer/viewer/lib/ui/newStats.ts +++ b/renderer/viewer/lib/ui/newStats.ts @@ -7,7 +7,7 @@ let lastY = 40 export const addNewStat = (id: string, width = 80, x = rightOffset, y = lastY) => { const pane = document.createElement('div') 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)' @@ -19,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 +35,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/renderer/viewer/lib/webgpuShaders/RadialBlur/frag.wgsl b/renderer/viewer/lib/webgpuShaders/RadialBlur/frag.wgsl new file mode 100644 index 000000000..42c4a7b61 --- /dev/null +++ b/renderer/viewer/lib/webgpuShaders/RadialBlur/frag.wgsl @@ -0,0 +1,77 @@ +// Fragment shader +@group(0) @binding(0) var tex: texture_depth_2d; +@group(0) @binding(1) var mySampler: sampler_comparison; +@group(0) @binding(2) var texColor: texture_2d; +@group(0) @binding(3) var clearColor: vec4; +@group(0) @binding(4) var colorSampler: sampler; +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 = textureSampleCompare(tex, mySampler, uvs, 1.0); + var col : f32; + var outTex = textureSample(texColor, colorSampler, 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 = textureSampleCompare(tex, mySampler, uvs, 1.0); + 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/renderer/viewer/lib/webgpuShaders/RadialBlur/vert.wgsl b/renderer/viewer/lib/webgpuShaders/RadialBlur/vert.wgsl new file mode 100644 index 000000000..623aa31f2 --- /dev/null +++ b/renderer/viewer/lib/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/renderer/viewer/lib/workerProxy.ts b/renderer/viewer/lib/workerProxy.ts index a27c817d9..9d8e7fcc0 100644 --- a/renderer/viewer/lib/workerProxy.ts +++ b/renderer/viewer/lib/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/renderer/viewer/lib/worldrendererCommon.ts b/renderer/viewer/lib/worldrendererCommon.ts index 5f845dbcd..8454ed16e 100644 --- a/renderer/viewer/lib/worldrendererCommon.ts +++ b/renderer/viewer/lib/worldrendererCommon.ts @@ -53,6 +53,7 @@ export const defaultWorldRendererConfig = { export type WorldRendererConfig = typeof defaultWorldRendererConfig export abstract class WorldRendererCommon { + timeOfTheDay = 0 worldSizeParams = { minY: 0, worldHeight: 256 } active = false @@ -184,7 +185,7 @@ export abstract class WorldRendererCommon if (this.mainThreadRendering) { this.fpsUpdate() } - }, 1000) + }, 500) } fpsUpdate () { @@ -495,10 +496,23 @@ export abstract class WorldRendererCommon } getMesherConfig (): MesherConfig { + let skyLight = 15 + const timeOfDay = this.timeOfTheDay + if (timeOfDay < 0 || timeOfDay > 24_000) { + // + } else if (timeOfDay <= 6000 || timeOfDay >= 18_000) { + skyLight = 15 + } else if (timeOfDay > 6000 && timeOfDay < 12_000) { + skyLight = 15 - ((timeOfDay - 6000) / 6000) * 15 + } else if (timeOfDay >= 12_000 && timeOfDay < 18_000) { + skyLight = ((timeOfDay - 12_000) / 6000) * 15 + } + + skyLight = Math.floor(skyLight) return { version: this.version, enableLighting: this.worldRendererConfig.enableLighting, - skyLight: 15, + skyLight, smoothLighting: this.worldRendererConfig.smoothLighting, outputFormat: this.outputFormat, textureSize: this.resourcesManager.currentResources!.blocksAtlasParser.atlas.latest.width, @@ -608,7 +622,7 @@ export abstract class WorldRendererCommon this.logWorkerWork(`-> unloadChunk ${JSON.stringify({ x, z })}`) delete this.finishedChunks[`${x},${z}`] this.allChunksFinished = Object.keys(this.finishedChunks).length === this.chunksLength - if (!this.allChunksFinished) { + if (Object.keys(this.finishedChunks).length === 0) { this.allLoadedIn = undefined this.initialChunkLoadWasStartedIn = undefined } @@ -735,18 +749,11 @@ export abstract class WorldRendererCommon worldEmitter.on('time', (timeOfDay) => { this.timeUpdated?.(timeOfDay) - let skyLight = 15 if (timeOfDay < 0 || timeOfDay > 24_000) { throw new Error('Invalid time of day. It should be between 0 and 24000.') - } else if (timeOfDay <= 6000 || timeOfDay >= 18_000) { - skyLight = 15 - } else if (timeOfDay > 6000 && timeOfDay < 12_000) { - skyLight = 15 - ((timeOfDay - 6000) / 6000) * 15 - } else if (timeOfDay >= 12_000 && timeOfDay < 18_000) { - skyLight = ((timeOfDay - 12_000) / 6000) * 15 } - skyLight = Math.floor(skyLight) // todo: remove this after optimization + this.timeOfTheDay = timeOfDay // if (this.worldRendererConfig.skyLight === skyLight) return // this.worldRendererConfig.skyLight = skyLight diff --git a/renderer/viewer/three/graphicsBackend.ts b/renderer/viewer/three/graphicsBackend.ts index 3ee34ea72..ca18d77cd 100644 --- a/renderer/viewer/three/graphicsBackend.ts +++ b/renderer/viewer/three/graphicsBackend.ts @@ -19,8 +19,6 @@ const getBackendMethods = (worldRenderer: WorldRendererThree) => { playEntityAnimation: worldRenderer.entities.playAnimation.bind(worldRenderer.entities), damageEntity: worldRenderer.entities.handleDamageEvent.bind(worldRenderer.entities), updatePlayerSkin: worldRenderer.entities.updatePlayerSkin.bind(worldRenderer.entities), - setHighlightCursorBlock: worldRenderer.cursorBlock.setHighlightCursorBlock.bind(worldRenderer.cursorBlock), - updateBreakAnimation: worldRenderer.cursorBlock.updateBreakAnimation.bind(worldRenderer.cursorBlock), changeHandSwingingState: worldRenderer.changeHandSwingingState.bind(worldRenderer), getHighestBlocks: worldRenderer.getHighestBlocks.bind(worldRenderer), rerenderAllChunks: worldRenderer.rerenderAllChunks.bind(worldRenderer), diff --git a/renderer/viewer/three/world/cursorBlock.ts b/renderer/viewer/three/world/cursorBlock.ts index ab0c4854f..277ee6f9f 100644 --- a/renderer/viewer/three/world/cursorBlock.ts +++ b/renderer/viewer/three/world/cursorBlock.ts @@ -3,6 +3,7 @@ import { LineMaterial, LineSegmentsGeometry, Wireframe } from 'three-stdlib' import { Vec3 } from 'vec3' import { subscribeKey } from 'valtio/utils' import { Block } from 'prismarine-block' +import { BlockShape, BlocksShapes } from 'renderer/viewer/lib/basePlayerState' import { WorldRendererThree } from '../worldrendererThree' import destroyStage0 from '../../../../assets/destroy_stage_0.png' import destroyStage1 from '../../../../assets/destroy_stage_1.png' @@ -89,15 +90,13 @@ export class CursorBlock { this.prevColor = this.worldRenderer.worldRendererConfig.highlightBlockColor } - updateBreakAnimation (block: Block | undefined, stage: number | null) { + updateBreakAnimation (blockPosition: { x: number, y: number, z: number } | undefined, stage: number | null, mergedShape?: BlockShape) { this.hideBreakAnimation() - if (stage === null || !block) return + if (stage === null || !blockPosition || !mergedShape) return - const mergedShape = bot.mouse.getMergedCursorShape(block) - if (!mergedShape) return - const { position, width, height, depth } = bot.mouse.getDataFromShape(mergedShape) + const { position, width, height, depth } = mergedShape this.blockBreakMesh.scale.set(width * 1.001, height * 1.001, depth * 1.001) - position.add(block.position) + position.add(blockPosition) this.blockBreakMesh.position.set(position.x, position.y, position.z) this.blockBreakMesh.visible = true; @@ -119,7 +118,7 @@ export class CursorBlock { } } - setHighlightCursorBlock (blockPos: Vec3 | null, shapePositions?: Array<{ position: any; width: any; height: any; depth: any; }>): void { + setHighlightCursorBlock (blockPos: Vec3 | null, shapePositions?: BlocksShapes): void { if (blockPos && this.interactionLines && blockPos.equals(this.interactionLines.blockPos)) { return } diff --git a/renderer/viewer/three/worldrendererThree.ts b/renderer/viewer/three/worldrendererThree.ts index ac31d6864..bdbb350c0 100644 --- a/renderer/viewer/three/worldrendererThree.ts +++ b/renderer/viewer/three/worldrendererThree.ts @@ -132,6 +132,12 @@ export class WorldRendererThree extends WorldRendererCommon { if (!value) return this.directionalLight.intensity = value }) + this.onReactiveValueUpdated('lookingAtBlock', (value) => { + this.cursorBlock.setHighlightCursorBlock(value ? new Vec3(value.x, value.y, value.z) : null, value?.shapes) + }) + this.onReactiveValueUpdated('diggingBlock', (value) => { + this.cursorBlock.updateBreakAnimation(value ? { x: value.x, y: value.y, z: value.z } : undefined, value?.stage ?? null, value?.mergedShape) + }) } changeHandSwingingState (isAnimationPlaying: boolean, isLeft = false) { diff --git a/renderer/viewer/webgpu/graphicsBackendWebgpu.ts b/renderer/viewer/webgpu/graphicsBackendWebgpu.ts new file mode 100644 index 000000000..67ade7c5a --- /dev/null +++ b/renderer/viewer/webgpu/graphicsBackendWebgpu.ts @@ -0,0 +1,49 @@ +import { WorldRendererWebgpu } from 'renderer/viewer/webgpu/worldrendererWebgpu' +import { updateLocalServerSettings } from 'src/integratedServer/main' +import { defaultWebgpuRendererParams } from 'renderer/playground/webgpuRendererShared' +import { GraphicsBackend, GraphicsInitOptions } from '../../../src/appViewer' + +export type WebgpuInitOptions = GraphicsInitOptions<{ + allowChunksViewUpdate?: boolean +}> + +const createWebgpuBackend = (initOptions: WebgpuInitOptions) => { + let worldRenderer: WorldRendererWebgpu | undefined + + const backend: GraphicsBackend = { + id: 'webgpu', + startPanorama () { + + }, + async startWorld (displayOptions) { + initOptions.rendererSpecificSettings.allowChunksViewUpdate ??= defaultWebgpuRendererParams.allowChunksViewUpdate + const onSettingsUpdate = () => { + displayOptions.worldView.allowPositionUpdate = initOptions.rendererSpecificSettings.allowChunksViewUpdate! + updateLocalServerSettings({ + stopLoad: !displayOptions.worldView.allowPositionUpdate + }) + } + onSettingsUpdate() + + worldRenderer = new WorldRendererWebgpu(initOptions, displayOptions) + globalThis.world = worldRenderer + await worldRenderer.readyPromise + }, + disconnect () { + globalThis.world = undefined + worldRenderer?.destroy() + }, + soundSystem: undefined, + setRendering (rendering) { + + }, + updateCamera (pos, yaw, pitch) { + worldRenderer?.setFirstPersonCamera(pos, yaw, pitch) + }, + backendMethods: {} + } + return backend +} + +createWebgpuBackend.id = 'webgpu' +export default createWebgpuBackend diff --git a/renderer/viewer/webgpu/worldrendererWebgpu.ts b/renderer/viewer/webgpu/worldrendererWebgpu.ts new file mode 100644 index 000000000..ba573daa7 --- /dev/null +++ b/renderer/viewer/webgpu/worldrendererWebgpu.ts @@ -0,0 +1,465 @@ +import { Vec3 } from 'vec3' +// import { addBlocksSection, addWebgpuListener, webgpuChannel } from '../../examples/webgpuRendererMain' +import { pickObj } from '@zardoy/utils' +import { GUI } from 'lil-gui' +import { WebgpuInitOptions } from 'renderer/viewer/webgpu/graphicsBackendWebgpu' +import * as THREE from 'three' +import { prepareCreateWebgpuBlocksModelsData } from '../../playground/webgpuBlockModels' +import type { workerProxyType } from '../../playground/webgpuRendererWorker' +import { useWorkerProxy } from '../../playground/workerProxy' +import { defaultWebgpuRendererParams, rendererParamsGui } from '../../playground/webgpuRendererShared' +import { WorldRendererCommon, WorldRendererConfig } from '../lib/worldrendererCommon' +import { MesherGeometryOutput } from '../lib/mesher/shared' +import { addNewStat, addNewStat2, updateStatText } from '../lib/ui/newStats' +import { isMobile } from '../lib/simpleUtils' +import { WorldRendererThree } from '../three/worldrendererThree' +import { DisplayWorldOptions, GraphicsInitOptions } from '../../../src/appViewer' + +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 + camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000) + + webgpuChannel: typeof workerProxyType['__workerProxy'] = this.getPlaceholderChannel() + rendererDevice = '...' + + constructor (public initOptions: WebgpuInitOptions, public displayOptions: DisplayWorldOptions) { + super(initOptions.resourcesManager, displayOptions, initOptions) + + 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})`) + }) + + this.rendererParams = { ...defaultWebgpuRendererParams, ...this.initOptions.rendererSpecificSettings } + + this.init() + } + + override 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() + } + + // Clean up canvas + const canvas = document.getElementById('viewer-canvas') + if (canvas) { + canvas.remove() + } + + // Clean up problem reporter + const problemReporter = document.querySelector('.renderer-problem-reporter') + if (problemReporter) { + problemReporter.remove() + } + + super.destroy() + } + + 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) + for (const key in params) { + // save only if displayed in gui + if (rendererParamsGui[key]) { + this.initOptions.setRendererSpecificSettings(key, params[key]) + } + } + } + + 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.displayOptions.inWorldRenderingConfig.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): Promise { + return Promise.all([ + super.setVersion(version), + // 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 ? -this.worldMinYRender : 0, 0] + }) + } + super.addColumn(x, z, data, _) + } + + override watchReactivePlayerState () { + void this.readyWorkerPromise.then(async () => { + // todo hack, allow init to be executed in code outsde + await new Promise(resolve => { + setTimeout(resolve,) + }) + super.watchReactivePlayerState() + this.onReactiveValueUpdated('lookingAtBlock', (value) => { + this.setHighlightCursorBlock(value ? new Vec3(value.x, value.y, value.z) : undefined) + }) + }) + } + + allChunksLoaded (): void { + 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]) + } + + setFirstPersonCamera (pos: Vec3 | null, yaw: number, pitch: number) { + const cam = this.camera + const yOffset = this.displayOptions.playerState.getEyeHeight() + + this.camera = cam + this.updateCamera(pos?.offset(0, yOffset, 0) ?? null, yaw, pitch) + } + + 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) { + } + + override async updateAssetsData (resourcePackUpdate = false): Promise { + const { blocksDataModelDebug: blocksDataModelBefore, interestedTextureTiles } = prepareCreateWebgpuBlocksModelsData(this, true) + const interestedBlockTiles = [...interestedTextureTiles].map(x => x.replace('block/', '')) + this.resourcesManager.currentConfig!.includeOnlyBlocks = interestedBlockTiles + await this.resourcesManager.recreateBlockAtlas() + await super.updateAssetsData() + const { blocksDataModel, blocksDataModelDebug, allBlocksStateIdToModelIdMap } = prepareCreateWebgpuBlocksModelsData(this) + // this.webgpuChannel.updateModels(blocksDataModel) + this.sendDataForWebgpuRenderer({ allBlocksStateIdToModelIdMap }) + void this.initWebgpu(blocksDataModel) + if (resourcePackUpdate) { + const blob = await fetch(this.resourcesManager.currentResources!.blocksAtlasParser.latestImage).then(async (res) => res.blob()) + this.webgpuChannel.updateTexture(blob) + } + + // restore block assets parser for gui + this.resourcesManager.currentConfig!.includeOnlyBlocks = undefined + await this.resourcesManager.recreateBlockAtlas() + } + + updateShowChunksBorder (value: boolean) { + // todo + } + + changeBackgroundColor (color: [number, number, number]) { + this.webgpuChannel.updateBackground(color) + } + + cursorBlockPosition: Vec3 | undefined + setHighlightCursorBlock (position?: Vec3): void { + const useChangeWorker = true + if (this.cursorBlockPosition) { + const worker = this.workers[this.getWorkerNumber(this.cursorBlockPosition, useChangeWorker)] + worker.postMessage({ type: 'specialBlockState', data: { value: null, position: this.cursorBlockPosition } }) + this.setSectionDirty(this.cursorBlockPosition, true, useChangeWorker) + } + + this.cursorBlockPosition = position + if (this.cursorBlockPosition) { + const worker = this.workers[this.getWorkerNumber(this.cursorBlockPosition, useChangeWorker)] + worker.postMessage({ type: 'specialBlockState', data: { value: 'highlight', position: this.cursorBlockPosition } }) + this.setSectionDirty(this.cursorBlockPosition, true, useChangeWorker) + } + } + + + removeColumn (x, z) { + super.removeColumn(x, z) + + for (let y = this.worldSizeParams.minY; y < this.worldSizeParams.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.displayOptions.isPlayground + const playground = false + const image = this.resourcesManager.currentResources!.blocksAtlasParser.latestImage + const imageBlob = await fetch(image).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('../../playground/webgpuRendererWorker') + } + addWebgpuDebugUi(this.worker, playground, this) + 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.initOptions.config.powerPreference } + ) + + 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) + } + + worldStop () { + this.webgpuChannel.stopRender() + } +} + +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, worldRenderer: WorldRendererWebgpu) => { + // 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) + // worldRenderer.rendererDevice = `${e.data.device} WebGL data: ${WorldRendererThree.getRendererInfo(new THREE.WebGLRenderer())}` + } + }) + + 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(worldRenderer.rendererParams)) { + const [key, value] = rendererParam + if (!rendererParamsGui[key]) continue + // eslint-disable-next-line @typescript-eslint/no-loop-func + gui.add(worldRenderer.rendererParams, key).onChange((newVal) => { + worldRenderer.updateRendererParams({ [key]: newVal }) + if (rendererParamsGui[key]?.qsReload) { + const searchParams = new URLSearchParams(window.location.search) + searchParams.set(key, String(value)) + window.location.search = searchParams.toString() + } + }) + } + worldRenderer.abortController.signal.addEventListener('abort', () => { + gui.destroy() + }) + } +} diff --git a/rsbuild.config.ts b/rsbuild.config.ts index 36ccb9b0c..30f164326 100644 --- a/rsbuild.config.ts +++ b/rsbuild.config.ts @@ -111,6 +111,7 @@ const appConfig = defineConfig({ js: 'source-map', css: true, }, + cleanDistPath: false, distPath: SINGLE_FILE_BUILD ? { html: './single', } : undefined, @@ -194,6 +195,12 @@ const appConfig = defineConfig({ } else if (!dev) { await execAsync('pnpm run build-mesher') } + if (fs.existsSync('./renderer/dist/webgpuRendererWorker.js')) { + // copy worker + fs.copyFileSync('./renderer/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/appViewer.ts b/src/appViewer.ts index 1b973ad28..0f29b9a66 100644 --- a/src/appViewer.ts +++ b/src/appViewer.ts @@ -155,6 +155,12 @@ export class AppViewer { } } + async startWithBot () { + const renderDistance = miscUiState.singleplayer ? options.renderDistance : options.multiplayerRenderDistance + await this.startWorld(bot.world, renderDistance) + this.worldView!.listenToBot(bot) + } + async startWorld (world, renderDistance: number, playerStateSend: IPlayerState = this.playerState) { if (this.currentDisplay === 'world') throw new Error('World already started') this.currentDisplay = 'world' @@ -185,11 +191,7 @@ export class AppViewer { } resetBackend (cleanState = false) { - if (cleanState) { - this.currentState = undefined - this.currentDisplay = null - this.worldView = undefined - } + this.disconnectBackend(cleanState) if (this.backendLoader) { this.loadBackend(this.backendLoader) } @@ -216,7 +218,12 @@ export class AppViewer { this.resourcesManager.destroy() } - disconnectBackend () { + disconnectBackend (cleanState = false) { + if (cleanState) { + this.currentState = undefined + this.currentDisplay = null + this.worldView = undefined + } if (this.backend) { this.backend.disconnect() this.backend = undefined @@ -225,7 +232,7 @@ export class AppViewer { const { promise, resolve } = Promise.withResolvers() this.worldReady = promise this.resolveWorldReady = resolve - Object.assign(this.rendererState, getDefaultRendererState()) + this.rendererState = proxy(getDefaultRendererState()) // this.queuedDisplay = undefined } diff --git a/src/appViewerLoad.ts b/src/appViewerLoad.ts index 8110ac890..702906979 100644 --- a/src/appViewerLoad.ts +++ b/src/appViewerLoad.ts @@ -1,4 +1,5 @@ import { subscribeKey } from 'valtio/utils' +import createWebgpuBackend from 'renderer/viewer/webgpu/graphicsBackendWebgpu' import createGraphicsBackend from 'renderer/viewer/three/graphicsBackend' import { options } from './optionsStorage' import { appViewer } from './appViewer' @@ -8,6 +9,7 @@ import { showNotification } from './react/NotificationProvider' const backends = [ createGraphicsBackend, + createWebgpuBackend, ] const loadBackend = () => { let backend = backends.find(backend => backend.id === options.activeRenderer) @@ -37,3 +39,13 @@ const animLoop = () => { requestAnimationFrame(animLoop) watchOptionsAfterViewerInit() + +// reset backend when renderer changes + +subscribeKey(options, 'activeRenderer', () => { + if (appViewer.currentDisplay === 'world' && bot) { + appViewer.resetBackend(true) + loadBackend() + void appViewer.startWithBot() + } +}) diff --git a/src/benchmark.ts b/src/benchmark.ts index 9539c9b0b..79532613a 100644 --- a/src/benchmark.ts +++ b/src/benchmark.ts @@ -295,7 +295,7 @@ export const registerOpenBenchmarkListener = () => { e.preventDefault() // add ?openBenchmark=true to url without reload const url = new URL(window.location.href) - url.searchParams.set('openBenchmark', 'true') + url.searchParams.set('openBenchmark', 'dir') window.history.replaceState({}, '', url.toString()) void openBenchmark() } diff --git a/src/browserfs.ts b/src/browserfs.ts index a4ae96ccc..5f5577124 100644 --- a/src/browserfs.ts +++ b/src/browserfs.ts @@ -1,22 +1,18 @@ +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 { ConnectOptions } from 'src/connect' +import { resetOptions } from './optionsStorage' import { fsState, loadSave } from './loadSave' -import { installResourcepackPack, installTexturePackFromHandle, updateTexturePackInstalledState } from './resourcePack' +import { installResourcepackPack, updateTexturePackInstalledState } from './resourcePack' import { miscUiState } from './globalState' import { setLoadingScreenStatus } from './appStatus' import { VALID_REPLAY_EXTENSIONS, openFile } from './packetsReplay/replayPackets' -import { getFixedFilesize } from './downloadAndOpenFile' -import { packetsReplayState } from './react/state/packetsReplayState' import { createFullScreenProgressReporter } from './core/progressReporter' import { showNotification } from './react/NotificationProvider' import { resetAppStorage } from './react/appStorageProvider' -import { ConnectOptions } from './connect' +import { copyFilesAsync, existsViaStats, mountRemoteFsBackend } from './integratedServer/browserfsShared' const { GoogleDriveFileSystem } = require('google-drive-browserfs/src/backends/GoogleDrive') browserfs.install(window) @@ -53,359 +49,6 @@ browserfs.configure({ miscUiState.singleplayerAvailable = 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 - } - } - 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 - } -} - -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 copyFilesAsyncWithProgress = async (pathSrc: string, pathDest: string, throwRootNotExist = true, addMsg = '') => { const stat = await existsViaStats(pathSrc) if (!stat) { @@ -448,60 +91,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) { @@ -527,58 +119,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'], connectOptions?: Partial) => { - 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 @@ -618,7 +196,7 @@ export const openWorldZip = async (...args: Parameters try { return await openWorldZipInner(...args) } finally { - possiblyCleanHandle() + // possiblyCleanHandle() } } @@ -671,13 +249,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 a292c5cd0..65f7f55e8 100644 --- a/src/builtinCommands.ts +++ b/src/builtinCommands.ts @@ -2,13 +2,13 @@ import fs from 'fs' import { join } from 'path' import JSZip from 'jszip' import { getThreeJsRendererMethods } from 'renderer/viewer/three/threeJsMethods' +import { miscUiState } from './globalState' import { fsState, readLevelDat } from './loadSave' import { closeWan, openToWanAndCopyJoinLink } from './localServerMultiplayer' -import { copyFilesAsync, uniqueFileNameFromWorldName } from './browserfs' import { saveServer } from './flyingSquidUtils' import { setLoadingScreenStatus } from './appStatus' import { displayClientChat } from './botUtils' -import { miscUiState } from './globalState' +import { copyFilesAsync, uniqueFileNameFromWorldName } from './integratedServer/browserfsShared' const notImplemented = () => { return 'Not implemented yet' diff --git a/src/controls.ts b/src/controls.ts index d4522eae3..5e1d3b31c 100644 --- a/src/controls.ts +++ b/src/controls.ts @@ -598,10 +598,14 @@ export const f3Keybinds: Array<{ // 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', @@ -945,6 +949,9 @@ window.addEventListener('keydown', (e) => { // eslint-disable-next-line no-debugger debugger } + if (e.code === 'KeyJ' && e.altKey && !e.shiftKey && !e.ctrlKey && !e.metaKey) { + options.activeRenderer = options.activeRenderer === 'webgpu' ? 'threejs' : 'webgpu' + } }) // #endregion diff --git a/src/createLocalServer.ts b/src/createLocalServer.ts index d0beac9a2..2e60e4ae5 100644 --- a/src/createLocalServer.ts +++ b/src/createLocalServer.ts @@ -1,11 +1,11 @@ -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.mcData.loginPacket ??= mcData.loginPacket 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/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 b8741a120..58443f6aa 100644 --- a/src/globals.d.ts +++ b/src/globals.d.ts @@ -14,7 +14,6 @@ declare const __type_bot: typeof bot declare const appViewer: import('./appViewer').AppViewer declare const worldView: import('renderer/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 } diff --git a/src/index.ts b/src/index.ts index f51311aec..1240a0831 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,8 +27,6 @@ import { options } from './optionsStorage' import './reactUi' import { lockUrl, onBotCreate } from './controls' import './dragndrop' -import { possiblyCleanHandle, resetStateAfterDisconnect } from './browserfs' -import { watchOptionsAfterViewerInit, watchOptionsAfterWorldViewInit } from './watchOptions' import downloadAndOpenFile from './downloadAndOpenFile' import fs from 'fs' @@ -54,13 +52,12 @@ import { parseServerAddress } from './parseServerAddress' import { setLoadingScreenStatus } from './appStatus' import { isCypress } from './standaloneUtils' -import { startLocalServer, unsupportedLocalServerFeatures } from './createLocalServer' +import { unsupportedLocalServerFeatures } from './createLocalServer' import defaultServerOptions from './defaultLocalServerOptions' import dayCycle from './dayCycle' import { onAppLoad, resourcepackReload, resourcePackState } from './resourcePack' import { ConnectPeerOptions, connectToPeer } from './localServerMultiplayer' -import CustomChannelClient from './customClient' import { registerServiceWorker } from './serviceWorker' import { appStatusState, lastConnectOptions } from './react/AppStatusProvider' @@ -69,12 +66,11 @@ import { watchFov } from './rendererUtils' import { loadInMemorySave } from './react/SingleplayerProvider' import { possiblyHandleStateVariable } from './googledrive' -import flyingSquidEvents from './flyingSquidEvents' import { showNotification } from './react/NotificationProvider' import { saveToBrowserMemory } from './react/PauseScreen' import './devReload' import './water' -import { ConnectOptions, loadMinecraftData, getVersionAutoSelect, downloadOtherGameData, downloadAllMinecraftData } from './connect' +import { ConnectOptions, getVersionAutoSelect, downloadOtherGameData, downloadAllMinecraftData } from './connect' import { ref, subscribe } from 'valtio' import { signInMessageState } from './react/SignInMessageProvider' import { updateAuthenticatedAccountData, updateLoadedServerData, updateServerConnectionHistory } from './react/serversStorage' @@ -93,8 +89,9 @@ import ping from './mineflayer/plugins/ping' import mouse from './mineflayer/plugins/mouse' import { startLocalReplayServer } from './packetsReplay/replayPackets' import { localRelayServerPlugin } from './mineflayer/plugins/packetsRecording' -import { createConsoleLogProgressReporter, createFullScreenProgressReporter, ProgressReporter } from './core/progressReporter' +import { createFullScreenProgressReporter, ProgressReporter } from './core/progressReporter' import { appViewer } from './appViewer' +import { destroyLocalServerMain, startLocalServerMain } from './integratedServer/main' import './appViewerLoad' import { registerOpenBenchmarkListener } from './benchmark' @@ -181,8 +178,6 @@ export async function connect (connectOptions: ConnectOptions) { updateServerConnectionHistory(parsedServer.host, connectOptions.botVersion) } - const { renderDistance: renderDistanceSingleplayer, multiplayerRenderDistance } = options - const parsedServer = parseServerAddress(connectOptions.server) const server = { host: parsedServer.host, port: parsedServer.port } if (connectOptions.proxy?.startsWith(':')) { @@ -214,7 +209,7 @@ export async function connect (connectOptions: ConnectOptions) { ended = true progress.end() // dont reset viewer so we can still do debugging - localServer = window.localServer = window.server = undefined + void destroyLocalServerMain(false) gameAdditionalState.viewerConnection = false if (bot) { @@ -232,9 +227,9 @@ export 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 @@ -283,7 +278,7 @@ export async function connect (connectOptions: ConnectOptions) { net['setProxy']({ hostname: proxy.host, port: proxy.port }) } - const renderDistance = singleplayer ? renderDistanceSingleplayer : multiplayerRenderDistance + const renderDistance = miscUiState.singleplayer ? options.renderDistance : options.multiplayerRenderDistance let updateDataAfterJoin = () => { } let localServer let localReplaySession: ReturnType | undefined @@ -347,6 +342,7 @@ export async function connect (connectOptions: ConnectOptions) { finalVersion = localReplaySession.version } + let CustomClient: any if (singleplayer) { // SINGLEPLAYER EXPLAINER: // Note 1: here we have custom sync communication between server Client (flying-squid) and game client (mineflayer) @@ -359,28 +355,28 @@ export async function connect (connectOptions: ConnectOptions) { // Client (class) of flying-squid (in server/login.js of mc-protocol): onLogin handler: skip most logic & go to loginClient() which assigns uuid and sends 'success' back to client (onLogin handler) and emits 'login' on the server (login.js in flying-squid handler) // flying-squid: 'login' -> player.login -> now sends 'login' event to the client (handled in many plugins in mineflayer) -> then 'update_health' is sent which emits 'spawn' in mineflayer - localServer = window.localServer = window.server = startLocalServer(serverOptions) + CustomClient = (await startLocalServerMain(serverOptions)).CustomClient connectOptions?.connectEvents?.serverCreated?.() - // todo need just to call quit if started - // loadingScreen.maybeRecoverable = false - // init world, todo: do it for any async plugins - if (!localServer.pluginsReady) { - await progress.executeWithMessage( - 'Starting local server', - async () => { - await new Promise(resolve => { - localServer.once('pluginsReady', resolve) - }) - } - ) - } - - localServer.on('newPlayer', (player) => { - player.on('loadingStatus', (newStatus) => { - progress.setMessage(newStatus) - }) - }) - flyingSquidEvents() + // // todo need just to call quit if started + // // loadingScreen.maybeRecoverable = false + // // init world, todo: do it for any async plugins + // if (!localServer.pluginsReady) { + // await progress.executeWithMessage( + // 'Starting local server', + // async () => { + // await new Promise(resolve => { + // localServer.once('pluginsReady', resolve) + // }) + // } + // ) + // } + + // localServer.on('newPlayer', (player) => { + // player.on('loadingStatus', (newStatus) => { + // progress.setMessage(newStatus) + // }) + // }) + // flyingSquidEvents() } if (connectOptions.authenticatedAccount) username = 'you' @@ -468,11 +464,11 @@ export async function connect (connectOptions: ConnectOptions) { ...singleplayer ? { version: serverOptions.version, connect () { }, - Client: CustomChannelClient as any, + Client: CustomClient, } : {}, ...localReplaySession ? { connect () { }, - Client: CustomChannelClient as any, + // Client: CustomChannelClient as any, } : {}, onMsaCode (data) { signInMessageState.code = data.user_code @@ -724,8 +720,7 @@ export async function connect (connectOptions: ConnectOptions) { console.log('bot spawned - starting viewer') - await appViewer.startWorld(bot.world, renderDistance) - appViewer.worldView!.listenToBot(bot) + await appViewer.startWithBot() initMotionTracking() dayCycle() 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..6e1cd889a --- /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((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..79a21cf4d --- /dev/null +++ b/src/integratedServer/main.ts @@ -0,0 +1,137 @@ +import { useWorkerProxy } from 'renderer/playground/workerProxy' +import { options } from '../optionsStorage' +import { chatInputValueGlobal } from '../react/Chat' +import { showModal } from '../globalState' +import { showNotification } from '../react/NotificationProvider' +import { fsState } from '../loadSave' +import { setLoadingScreenStatus } from '../appStatus' +import type { workerProxyType, BackEvents, CustomAppSettings } from './worker' +import { createLocalServerClientImpl } from './customClient' +import { getMcDataForWorker } from './workerMcData.mjs' + +Error.stackTraceLimit = 100 + +// 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 = { + autoSave: true, + stopLoad: true, +} + +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) { + return Buffer.from(data) + } + if (typeof data === 'object' && data !== null) { + // eslint-disable-next-line guard-for-in + for (const key in data) { + data[key] = restorePatchedDataDeep(data[key]) + } + } + return data +} + +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) => { + const restored = restorePatchedDataDeep(data) + // incorrect flying squid packet on pre 1.13 + if (data.name === 'custom_payload' && data.params.channel === 'MC|Brand') { + return + } + processData(restored) + 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 Promise.race([ + new Promise(resolve => { + addEventListener('quit', () => { + resolve() + }) + }), + new Promise((_, reject) => { + setTimeout(() => reject(new Error('Server quit timeout after 5s')), 5000) + }) + ]) + worker.terminate() + worker = undefined + lastOptions = undefined +} diff --git a/src/integratedServer/worker.ts b/src/integratedServer/worker.ts new file mode 100644 index 000000000..294e7d812 --- /dev/null +++ b/src/integratedServer/worker.ts @@ -0,0 +1,163 @@ +import { createWorkerProxy } from 'renderer/playground/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(restorePatchedDataDeep(data)) + }, + updateSettings (settings) { + globalSettings = settings + updateSettings(false) + }, + async quit () { + try { + await server?.quit() + } catch (err) { + console.error(err) + } + postMessage('quit') + } +}) + +const restorePatchedDataDeep = (data) => { + // add _isBuffer to Uint8Array + if (data instanceof Uint8Array) { + return Buffer.from(data) + } + if (typeof data === 'object' && data !== null) { + // eslint-disable-next-line guard-for-in + for (const key in data) { + data[key] = restorePatchedDataDeep(data[key]) + } + } + return data +} + +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 7c9f72775..4dda790be 100644 --- a/src/loadSave.ts +++ b/src/loadSave.ts @@ -6,26 +6,19 @@ import { gzip } from 'node-gzip' import { versionToNumber } from 'renderer/viewer/common/utils' 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 { ConnectOptions } from './connect' +import { existsViaStats, initialFsState, mkdirRecursive } from './integratedServer/browserfsShared' import { appQueryParams } from './appParams' // 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, - inMemorySavePath: '' -}) + +export const fsState = proxy(structuredClone(initialFsState)) +globalThis.fsState = fsState const PROPOSE_BACKUP = true @@ -61,19 +54,6 @@ export const loadSave = async (root = '/world', connectOptions?: Partial { 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 @@ -49,7 +50,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' @@ -65,7 +66,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/mineflayer/plugins/mouse.ts b/src/mineflayer/plugins/mouse.ts index 219a8be88..eca2f5b85 100644 --- a/src/mineflayer/plugins/mouse.ts +++ b/src/mineflayer/plugins/mouse.ts @@ -11,20 +11,32 @@ import { sendVideoInteraction, videoCursorInteraction } from '../../customChanne function cursorBlockDisplay (bot: Bot) { const updateCursorBlock = (data?: { block: Block }) => { if (!data?.block) { - getThreeJsRendererMethods()?.setHighlightCursorBlock(null) + playerState.reactive.lookingAtBlock = undefined return } const { block } = data - getThreeJsRendererMethods()?.setHighlightCursorBlock(block.position, bot.mouse.getBlockCursorShapes(block).map(shape => { - return bot.mouse.getDataFromShape(shape) - })) + playerState.reactive.lookingAtBlock = { + x: block.position.x, + y: block.position.y, + z: block.position.z, + shapes: bot.mouse.getBlockCursorShapes(block).map(shape => { + return bot.mouse.getDataFromShape(shape) + }) + } } bot.on('highlightCursorBlock', updateCursorBlock) bot.on('blockBreakProgressStage', (block, stage) => { - getThreeJsRendererMethods()?.updateBreakAnimation(block, stage) + const mergedShape = bot.mouse.getMergedCursorShape(block) + playerState.reactive.diggingBlock = stage ? { + x: block.position.x, + y: block.position.y, + z: block.position.z, + stage, + mergedShape: mergedShape ? bot.mouse.getDataFromShape(mergedShape) : undefined + } : undefined }) } diff --git a/src/optionsGuiScheme.tsx b/src/optionsGuiScheme.tsx index ef8d9a8eb..61999d5bc 100644 --- a/src/optionsGuiScheme.tsx +++ b/src/optionsGuiScheme.tsx @@ -73,6 +73,7 @@ export const guiOptionsScheme: { text: 'Renderer', values: [ ['threejs', 'Three.js (stable)'], + ['webgpu', 'WebGPU (new)'], ], }, }, @@ -153,7 +154,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 4d76ba0cb..c77fd8d50 100644 --- a/src/optionsStorage.ts +++ b/src/optionsStorage.ts @@ -27,7 +27,7 @@ const defaultOptions = { volume: 50, enableMusic: false, // fov: 70, - fov: 75, + fov: 90, guiScale: 3, autoRequestCompletions: true, touchButtonsSize: 40, @@ -97,6 +97,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, @@ -111,7 +112,7 @@ const defaultOptions = { disabledUiParts: [] as string[], neighborChunkUpdates: true, highlightBlockColor: 'auto' as 'auto' | 'blue' | 'classic', - activeRenderer: 'threejs', + activeRenderer: 'webgpu', rendererSharedOptions: { _experimentalSmoothChunkLoading: true, _renderByChunks: false diff --git a/src/packetsReplay/replayPackets.ts b/src/packetsReplay/replayPackets.ts index 87d03a9cb..8e86dd848 100644 --- a/src/packetsReplay/replayPackets.ts +++ b/src/packetsReplay/replayPackets.ts @@ -4,11 +4,11 @@ import { ParsedReplayPacket, parseReplayContents } from 'mcraft-fun-mineflayer/b import { PACKETS_REPLAY_FILE_EXTENSION, WORLD_STATE_FILE_EXTENSION } from 'mcraft-fun-mineflayer/build/worldState' import MinecraftData from 'minecraft-data' import { GameMode } from 'mineflayer' +// import { LocalServer } from '../customServer' import { UserError } from '../mineflayer/userError' import { packetsReplayState } from '../react/state/packetsReplayState' import { getFixedFilesize } from '../react/simpleUtils' import { appQueryParams } from '../appParams' -import { LocalServer } from '../customServer' const SUPPORTED_FORMAT_VERSION = 1 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/MainMenu.tsx b/src/react/MainMenu.tsx index 85c5367e4..8032f2ce6 100644 --- a/src/react/MainMenu.tsx +++ b/src/react/MainMenu.tsx @@ -94,16 +94,6 @@ export default ({
- - Connect to server -
@@ -123,8 +113,9 @@ export default ({ disabled={!mapsProvider} // className={styles['maps-provider']} icon={pixelartIcons.map} - initialTooltip={{ content: 'Explore maps to play from provider!', placement: 'top-start' }} + initialTooltip={{ content: 'Explore maps to play from provider!', placement: 'top' }} onClick={() => mapsProvider && openURL(httpsRegex.test(mapsProvider) ? mapsProvider : 'https://' + mapsProvider, false)} + alwaysTooltip='CHECK MAPS PERF!' />
+ + Multiplayer +