diff --git a/.travis.yml b/.travis.yml index 14344474093..25b7795e13d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -119,7 +119,7 @@ matrix: - name: Javascript export template (release, emscripten latest) stage: build - env: PLATFORM=javascript TOOLS=no TARGET=release CACHE_NAME=${PLATFORM}-emcc-latest EXTRA_ARGS="module_glslang_enabled=no" + env: PLATFORM=javascript TOOLS=no TARGET=release CACHE_NAME=${PLATFORM}-emcc-latest EXTRA_ARGS="use_closure_compiler=yes" os: linux compiler: clang addons: diff --git a/platform/javascript/SCsub b/platform/javascript/SCsub index 85a633442e8..d3cd8f76b75 100644 --- a/platform/javascript/SCsub +++ b/platform/javascript/SCsub @@ -10,8 +10,11 @@ javascript_files = [ 'os_javascript.cpp', ] -build = env.add_program(['#bin/godot${PROGSUFFIX}.js', '#bin/godot${PROGSUFFIX}.wasm'], javascript_files); -js, wasm = build +build_targets = ['#bin/godot${PROGSUFFIX}.js', '#bin/godot${PROGSUFFIX}.wasm'] +if env['threads_enabled']: + build_targets.append('#bin/godot${PROGSUFFIX}.worker.js') + +build = env.add_program(build_targets, javascript_files) js_libraries = [ 'http_request.js', @@ -27,18 +30,38 @@ for module in js_modules: env.Append(LINKFLAGS=['--pre-js', env.File(module).path]) env.Depends(build, js_modules) -wrapper_start = env.File('pre.js') -wrapper_end = env.File('engine.js') -js_wrapped = env.Textfile('#bin/godot', [wrapper_start, js, wrapper_end], TEXTFILESUFFIX='${PROGSUFFIX}.wrapped.js') +engine = [ + 'engine/preloader.js', + 'engine/loader.js', + 'engine/utils.js', + 'engine/engine.js', +] +externs = [ + env.File('#platform/javascript/engine/externs.js') +] +js_engine = env.CreateEngineFile('#bin/godot${PROGSUFFIX}.engine.js', engine, externs) +env.Depends(js_engine, externs) + +wrap_list = [ + build[0], + js_engine, +] +js_wrapped = env.Textfile('#bin/godot', [env.File(f) for f in wrap_list], TEXTFILESUFFIX='${PROGSUFFIX}.wrapped.js') zip_dir = env.Dir('#bin/.javascript_zip') -zip_files = env.InstallAs([ +out_files = [ zip_dir.File('godot.js'), zip_dir.File('godot.wasm'), zip_dir.File('godot.html') -], [ +] +in_files = [ js_wrapped, - wasm, + build[1], '#misc/dist/html/full-size.html' -]) +] +if env['threads_enabled']: + in_files.append(build[2]) + out_files.append(zip_dir.File('godot.worker.js')) + +zip_files = env.InstallAs(out_files, in_files) env.Zip('#bin/godot', zip_files, ZIPROOT=zip_dir, ZIPSUFFIX='${PROGSUFFIX}${ZIPSUFFIX}', ZIPCOMSTR='Archving $SOURCES as $TARGET') diff --git a/platform/javascript/detect.py b/platform/javascript/detect.py index 17668333647..fb02752aa7e 100644 --- a/platform/javascript/detect.py +++ b/platform/javascript/detect.py @@ -1,5 +1,6 @@ import os +from emscripten_helpers import parse_config, run_closure_compiler, create_engine_file def is_active(): return True @@ -18,6 +19,8 @@ def get_opts(): return [ # eval() can be a security concern, so it can be disabled. BoolVariable('javascript_eval', 'Enable JavaScript eval interface', True), + BoolVariable('threads_enabled', 'Enable WebAssembly Threads support (limited browser support)', False), + BoolVariable('use_closure_compiler', 'Use closure compiler to minimize Javascript code', False), ] @@ -37,7 +40,7 @@ def configure(env): ## Build type - if env['target'] != 'debug': + if env['target'] == 'release': # Use -Os to prioritize optimizing for reduced file size. This is # particularly valuable for the web platform because it directly # decreases download time. @@ -46,38 +49,55 @@ def configure(env): # run-time performance. env.Append(CCFLAGS=['-Os']) env.Append(LINKFLAGS=['-Os']) - if env['target'] == 'release_debug': - env.Append(CPPDEFINES=['DEBUG_ENABLED']) - # Retain function names for backtraces at the cost of file size. - env.Append(LINKFLAGS=['--profiling-funcs']) - else: + elif env['target'] == 'release_debug': + env.Append(CCFLAGS=['-Os']) + env.Append(LINKFLAGS=['-Os']) + env.Append(CPPDEFINES=['DEBUG_ENABLED']) + # Retain function names for backtraces at the cost of file size. + env.Append(LINKFLAGS=['--profiling-funcs']) + else: # 'debug' env.Append(CPPDEFINES=['DEBUG_ENABLED']) env.Append(CCFLAGS=['-O1', '-g']) env.Append(LINKFLAGS=['-O1', '-g']) env.Append(LINKFLAGS=['-s', 'ASSERTIONS=1']) - ## Compiler configuration + if env['tools']: + if not env['threads_enabled']: + raise RuntimeError("Threads must be enabled to build the editor. Please add the 'threads_enabled=yes' option") + # Tools need more memory. Initial stack memory in bytes. See `src/settings.js` in emscripten repository (will be renamed to INITIAL_MEMORY). + env.Append(LINKFLAGS=['-s', 'TOTAL_MEMORY=33554432']) + else: + # Disable exceptions and rtti on non-tools (template) builds + # These flags help keep the file size down. + env.Append(CCFLAGS=['-fno-exceptions', '-fno-rtti']) + # Don't use dynamic_cast, necessary with no-rtti. + env.Append(CPPDEFINES=['NO_SAFE_CAST']) + ## Copy env variables. env['ENV'] = os.environ - em_config_file = os.getenv('EM_CONFIG') or os.path.expanduser('~/.emscripten') - if not os.path.exists(em_config_file): - raise RuntimeError("Emscripten configuration file '%s' does not exist" % em_config_file) - with open(em_config_file) as f: - em_config = {} - try: - # Emscripten configuration file is a Python file with simple assignments. - exec(f.read(), em_config) - except StandardError as e: - raise RuntimeError("Emscripten configuration file '%s' is invalid:\n%s" % (em_config_file, e)) - if 'BINARYEN_ROOT' in em_config and os.path.isdir(os.path.join(em_config.get('BINARYEN_ROOT'), 'emscripten')): - # New style, emscripten path as a subfolder of BINARYEN_ROOT - env.PrependENVPath('PATH', os.path.join(em_config.get('BINARYEN_ROOT'), 'emscripten')) - elif 'EMSCRIPTEN_ROOT' in em_config: - # Old style (but can be there as a result from previous activation, so do last) - env.PrependENVPath('PATH', em_config.get('EMSCRIPTEN_ROOT')) - else: - raise RuntimeError("'BINARYEN_ROOT' or 'EMSCRIPTEN_ROOT' missing in Emscripten configuration file '%s'" % em_config_file) + # LTO + if env['use_lto']: + env.Append(CCFLAGS=['-s', 'WASM_OBJECT_FILES=0']) + env.Append(LINKFLAGS=['-s', 'WASM_OBJECT_FILES=0']) + env.Append(LINKFLAGS=['--llvm-lto', '1']) + + # Closure compiler + if env['use_closure_compiler']: + # For emscripten support code. + env.Append(LINKFLAGS=['--closure', '1']) + # Register builder for our Engine files + jscc = env.Builder(generator=run_closure_compiler, suffix='.cc.js', src_suffix='.js') + env.Append(BUILDERS = {'BuildJS' : jscc}) + + # Add method that joins/compiles our Engine files. + env.AddMethod(create_engine_file, "CreateEngineFile") + + # Closure compiler extern and support for ecmascript specs (const, let, etc). + env['ENV']['EMCC_CLOSURE_ARGS'] = '--language_in ECMASCRIPT6' + + em_config = parse_config() + env.PrependENVPath('PATH', em_config['EMCC_ROOT']) env['CC'] = 'emcc' env['CXX'] = 'em++' @@ -104,44 +124,31 @@ def configure(env): env['LIBPREFIXES'] = ['$LIBPREFIX'] env['LIBSUFFIXES'] = ['$LIBSUFFIX'] - ## Compile flags - env.Prepend(CPPPATH=['#platform/javascript']) env.Append(CPPDEFINES=['JAVASCRIPT_ENABLED', 'UNIX_ENABLED']) - # No multi-threading (SharedArrayBuffer) available yet, - # once feasible also consider memory buffer size issues. - env.Append(CPPDEFINES=['NO_THREADS']) - - # Disable exceptions and rtti on non-tools (template) builds - if not env['tools']: - # These flags help keep the file size down. - env.Append(CCFLAGS=['-fno-exceptions', '-fno-rtti']) - # Don't use dynamic_cast, necessary with no-rtti. - env.Append(CPPDEFINES=['NO_SAFE_CAST']) - if env['javascript_eval']: env.Append(CPPDEFINES=['JAVASCRIPT_EVAL_ENABLED']) - ## Link flags + # Thread support (via SharedArrayBuffer). + if env['threads_enabled']: + env.Append(CPPDEFINES=['PTHREAD_NO_RENAME']) + env.Append(CCFLAGS=['-s', 'USE_PTHREADS=1']) + env.Append(LINKFLAGS=['-s', 'USE_PTHREADS=1']) + env.Append(LINKFLAGS=['-s', 'PTHREAD_POOL_SIZE=4']) + env.Append(LINKFLAGS=['-s', 'WASM_MEM_MAX=2048MB']) + else: + env.Append(CPPDEFINES=['NO_THREADS']) + + # Reduce code size by generating less support code (e.g. skip NodeJS support). + env.Append(LINKFLAGS=['-s', 'ENVIRONMENT=web,worker']) # We use IDBFS in javascript_main.cpp. Since Emscripten 1.39.1 it needs to # be linked explicitly. env.Append(LIBS=['idbfs.js']) env.Append(LINKFLAGS=['-s', 'BINARYEN=1']) - - # Only include the JavaScript support code for the web environment - # (i.e. exclude Node.js and other unused environments). - # This makes the JavaScript support code about 4 KB smaller. - env.Append(LINKFLAGS=['-s', 'ENVIRONMENT=web']) - - # This needs to be defined for Emscripten using 'fastcomp' (default pre-1.39.0) - # and undefined if using 'upstream'. And to make things simple, earlier - # Emscripten versions didn't include 'fastcomp' in their path, so we check - # against the presence of 'upstream' to conditionally add the flag. - if not "upstream" in em_config['EMSCRIPTEN_ROOT']: - env.Append(LINKFLAGS=['-s', 'BINARYEN_TRAP_MODE=\'clamp\'']) + env.Append(LINKFLAGS=['-s', 'MODULARIZE=1', '-s', 'EXPORT_NAME="Godot"']) # Allow increasing memory buffer size during runtime. This is efficient # when using WebAssembly (in comparison to asm.js) and works well for @@ -153,8 +160,5 @@ def configure(env): env.Append(LINKFLAGS=['-s', 'INVOKE_RUN=0']) - # TODO: Reevaluate usage of this setting now that engine.js manages engine runtime. - env.Append(LINKFLAGS=['-s', 'NO_EXIT_RUNTIME=1']) - - #adding flag due to issue with emscripten 1.38.41 callMain method https://github.com/emscripten-core/emscripten/blob/incoming/ChangeLog.md#v13841-08072019 - env.Append(LINKFLAGS=['-s', 'EXTRA_EXPORTED_RUNTIME_METHODS=["callMain"]']) + # callMain for manual start, FS for preloading. + env.Append(LINKFLAGS=['-s', 'EXTRA_EXPORTED_RUNTIME_METHODS=["callMain", "FS"]']) diff --git a/platform/javascript/emscripten_helpers.py b/platform/javascript/emscripten_helpers.py new file mode 100644 index 00000000000..bda5b40a74c --- /dev/null +++ b/platform/javascript/emscripten_helpers.py @@ -0,0 +1,37 @@ +import os + +def parse_config(): + em_config_file = os.getenv('EM_CONFIG') or os.path.expanduser('~/.emscripten') + if not os.path.exists(em_config_file): + raise RuntimeError("Emscripten configuration file '%s' does not exist" % em_config_file) + + normalized = {} + em_config = {} + with open(em_config_file) as f: + try: + # Emscripten configuration file is a Python file with simple assignments. + exec(f.read(), em_config) + except StandardError as e: + raise RuntimeError("Emscripten configuration file '%s' is invalid:\n%s" % (em_config_file, e)) + normalized['EMCC_ROOT'] = em_config.get('EMSCRIPTEN_ROOT') + normalized['NODE_JS'] = em_config.get('NODE_JS') + normalized['CLOSURE_BIN'] = os.path.join(normalized['EMCC_ROOT'], 'node_modules', '.bin', 'google-closure-compiler') + return normalized + + +def run_closure_compiler(target, source, env, for_signature): + cfg = parse_config() + cmd = [cfg['NODE_JS'], cfg['CLOSURE_BIN']] + cmd.extend(['--compilation_level', 'ADVANCED_OPTIMIZATIONS']) + for f in env['JSEXTERNS']: + cmd.extend(['--externs', f.get_abspath()]) + for f in source: + cmd.extend(['--js', f.get_abspath()]) + cmd.extend(['--js_output_file', target[0].get_abspath()]) + return ' '.join(cmd) + + +def create_engine_file(env, target, source, externs): + if env['use_closure_compiler']: + return env.BuildJS(target, source, JSEXTERNS=externs) + return env.Textfile(target, [env.File(s) for s in source]) diff --git a/platform/javascript/engine.js b/platform/javascript/engine.js deleted file mode 100644 index 227accadb0e..00000000000 --- a/platform/javascript/engine.js +++ /dev/null @@ -1,411 +0,0 @@ - // The following is concatenated with generated code, and acts as the end - // of a wrapper for said code. See pre.js for the other part of the - // wrapper. - exposedLibs['PATH'] = PATH; - exposedLibs['FS'] = FS; - return Module; - }, -}; - -(function() { - var engine = Engine; - - var DOWNLOAD_ATTEMPTS_MAX = 4; - - var basePath = null; - var wasmFilenameExtensionOverride = null; - var engineLoadPromise = null; - - var loadingFiles = {}; - - function getPathLeaf(path) { - - while (path.endsWith('/')) - path = path.slice(0, -1); - return path.slice(path.lastIndexOf('/') + 1); - } - - function getBasePath(path) { - - if (path.endsWith('/')) - path = path.slice(0, -1); - if (path.lastIndexOf('.') > path.lastIndexOf('/')) - path = path.slice(0, path.lastIndexOf('.')); - return path; - } - - function getBaseName(path) { - - return getPathLeaf(getBasePath(path)); - } - - Engine = function Engine() { - - this.rtenv = null; - - var LIBS = {}; - - var initPromise = null; - var unloadAfterInit = true; - - var preloadedFiles = []; - - var resizeCanvasOnStart = true; - var progressFunc = null; - var preloadProgressTracker = {}; - var lastProgress = { loaded: 0, total: 0 }; - - var canvas = null; - var executableName = null; - var locale = null; - var stdout = null; - var stderr = null; - - this.init = function(newBasePath) { - - if (!initPromise) { - initPromise = Engine.load(newBasePath).then( - instantiate.bind(this) - ); - requestAnimationFrame(animateProgress); - if (unloadAfterInit) - initPromise.then(Engine.unloadEngine); - } - return initPromise; - }; - - function instantiate(wasmBuf) { - - var rtenvProps = { - engine: this, - ENV: {}, - }; - if (typeof stdout === 'function') - rtenvProps.print = stdout; - if (typeof stderr === 'function') - rtenvProps.printErr = stderr; - rtenvProps.instantiateWasm = function(imports, onSuccess) { - WebAssembly.instantiate(wasmBuf, imports).then(function(result) { - onSuccess(result.instance); - }); - return {}; - }; - - return new Promise(function(resolve, reject) { - rtenvProps.onRuntimeInitialized = resolve; - rtenvProps.onAbort = reject; - rtenvProps.thisProgram = executableName; - rtenvProps.engine.rtenv = Engine.RuntimeEnvironment(rtenvProps, LIBS); - }); - } - - this.preloadFile = function(pathOrBuffer, destPath) { - - if (pathOrBuffer instanceof ArrayBuffer) { - pathOrBuffer = new Uint8Array(pathOrBuffer); - } else if (ArrayBuffer.isView(pathOrBuffer)) { - pathOrBuffer = new Uint8Array(pathOrBuffer.buffer); - } - if (pathOrBuffer instanceof Uint8Array) { - preloadedFiles.push({ - path: destPath, - buffer: pathOrBuffer - }); - return Promise.resolve(); - } else if (typeof pathOrBuffer === 'string') { - return loadPromise(pathOrBuffer, preloadProgressTracker).then(function(xhr) { - preloadedFiles.push({ - path: destPath || pathOrBuffer, - buffer: xhr.response - }); - }); - } else { - throw Promise.reject("Invalid object for preloading"); - } - }; - - this.start = function() { - - return this.init().then( - Function.prototype.apply.bind(synchronousStart, this, arguments) - ); - }; - - this.startGame = function(execName, mainPack) { - - executableName = execName; - var mainArgs = [ '--main-pack', getPathLeaf(mainPack) ]; - - return Promise.all([ - this.init(getBasePath(execName)), - this.preloadFile(mainPack, getPathLeaf(mainPack)) - ]).then( - Function.prototype.apply.bind(synchronousStart, this, mainArgs) - ); - }; - - function synchronousStart() { - - if (canvas instanceof HTMLCanvasElement) { - this.rtenv.canvas = canvas; - } else { - var firstCanvas = document.getElementsByTagName('canvas')[0]; - if (firstCanvas instanceof HTMLCanvasElement) { - this.rtenv.canvas = firstCanvas; - } else { - throw new Error("No canvas found"); - } - } - - var actualCanvas = this.rtenv.canvas; - // canvas can grab focus on click - if (actualCanvas.tabIndex < 0) { - actualCanvas.tabIndex = 0; - } - // necessary to calculate cursor coordinates correctly - actualCanvas.style.padding = 0; - actualCanvas.style.borderWidth = 0; - actualCanvas.style.borderStyle = 'none'; - // disable right-click context menu - actualCanvas.addEventListener('contextmenu', function(ev) { - ev.preventDefault(); - }, false); - // until context restoration is implemented - actualCanvas.addEventListener('webglcontextlost', function(ev) { - alert("WebGL context lost, please reload the page"); - ev.preventDefault(); - }, false); - - if (locale) { - this.rtenv.locale = locale; - } else { - this.rtenv.locale = navigator.languages ? navigator.languages[0] : navigator.language; - } - this.rtenv.locale = this.rtenv.locale.split('.')[0]; - this.rtenv.resizeCanvasOnStart = resizeCanvasOnStart; - - preloadedFiles.forEach(function(file) { - var dir = LIBS.PATH.dirname(file.path); - try { - LIBS.FS.stat(dir); - } catch (e) { - if (e.code !== 'ENOENT') { - throw e; - } - LIBS.FS.mkdirTree(dir); - } - // With memory growth, canOwn should be false. - LIBS.FS.createDataFile(file.path, null, new Uint8Array(file.buffer), true, true, false); - }, this); - - preloadedFiles = null; - initPromise = null; - this.rtenv.callMain(arguments); - } - - this.setProgressFunc = function(func) { - progressFunc = func; - }; - - this.setResizeCanvasOnStart = function(enabled) { - resizeCanvasOnStart = enabled; - }; - - function animateProgress() { - - var loaded = 0; - var total = 0; - var totalIsValid = true; - var progressIsFinal = true; - - [loadingFiles, preloadProgressTracker].forEach(function(tracker) { - Object.keys(tracker).forEach(function(file) { - if (!tracker[file].final) - progressIsFinal = false; - if (!totalIsValid || tracker[file].total === 0) { - totalIsValid = false; - total = 0; - } else { - total += tracker[file].total; - } - loaded += tracker[file].loaded; - }); - }); - if (loaded !== lastProgress.loaded || total !== lastProgress.total) { - lastProgress.loaded = loaded; - lastProgress.total = total; - if (typeof progressFunc === 'function') - progressFunc(loaded, total); - } - if (!progressIsFinal) - requestAnimationFrame(animateProgress); - } - - this.setCanvas = function(elem) { - canvas = elem; - }; - - this.setExecutableName = function(newName) { - - executableName = newName; - }; - - this.setLocale = function(newLocale) { - - locale = newLocale; - }; - - this.setUnloadAfterInit = function(enabled) { - - if (enabled && !unloadAfterInit && initPromise) { - initPromise.then(Engine.unloadEngine); - } - unloadAfterInit = enabled; - }; - - this.setStdoutFunc = function(func) { - - var print = function(text) { - if (arguments.length > 1) { - text = Array.prototype.slice.call(arguments).join(" "); - } - func(text); - }; - if (this.rtenv) - this.rtenv.print = print; - stdout = print; - }; - - this.setStderrFunc = function(func) { - - var printErr = function(text) { - if (arguments.length > 1) - text = Array.prototype.slice.call(arguments).join(" "); - func(text); - }; - if (this.rtenv) - this.rtenv.printErr = printErr; - stderr = printErr; - }; - - - }; // Engine() - - Engine.RuntimeEnvironment = engine.RuntimeEnvironment; - - Engine.isWebGLAvailable = function(majorVersion = 1) { - - var testContext = false; - try { - var testCanvas = document.createElement('canvas'); - if (majorVersion === 1) { - testContext = testCanvas.getContext('webgl') || testCanvas.getContext('experimental-webgl'); - } else if (majorVersion === 2) { - testContext = testCanvas.getContext('webgl2') || testCanvas.getContext('experimental-webgl2'); - } - } catch (e) {} - return !!testContext; - }; - - Engine.setWebAssemblyFilenameExtension = function(override) { - - if (String(override).length === 0) { - throw new Error('Invalid WebAssembly filename extension override'); - } - wasmFilenameExtensionOverride = String(override); - } - - Engine.load = function(newBasePath) { - - if (newBasePath !== undefined) basePath = getBasePath(newBasePath); - if (engineLoadPromise === null) { - if (typeof WebAssembly !== 'object') - return Promise.reject(new Error("Browser doesn't support WebAssembly")); - // TODO cache/retrieve module to/from idb - engineLoadPromise = loadPromise(basePath + '.' + (wasmFilenameExtensionOverride || 'wasm')).then(function(xhr) { - return xhr.response; - }); - engineLoadPromise = engineLoadPromise.catch(function(err) { - engineLoadPromise = null; - throw err; - }); - } - return engineLoadPromise; - }; - - Engine.unload = function() { - engineLoadPromise = null; - }; - - function loadPromise(file, tracker) { - if (tracker === undefined) - tracker = loadingFiles; - return new Promise(function(resolve, reject) { - loadXHR(resolve, reject, file, tracker); - }); - } - - function loadXHR(resolve, reject, file, tracker) { - - var xhr = new XMLHttpRequest; - xhr.open('GET', file); - if (!file.endsWith('.js')) { - xhr.responseType = 'arraybuffer'; - } - ['loadstart', 'progress', 'load', 'error', 'abort'].forEach(function(ev) { - xhr.addEventListener(ev, onXHREvent.bind(xhr, resolve, reject, file, tracker)); - }); - xhr.send(); - } - - function onXHREvent(resolve, reject, file, tracker, ev) { - - if (this.status >= 400) { - - if (this.status < 500 || ++tracker[file].attempts >= DOWNLOAD_ATTEMPTS_MAX) { - reject(new Error("Failed loading file '" + file + "': " + this.statusText)); - this.abort(); - return; - } else { - setTimeout(loadXHR.bind(null, resolve, reject, file, tracker), 1000); - } - } - - switch (ev.type) { - case 'loadstart': - if (tracker[file] === undefined) { - tracker[file] = { - total: ev.total, - loaded: ev.loaded, - attempts: 0, - final: false, - }; - } - break; - - case 'progress': - tracker[file].loaded = ev.loaded; - tracker[file].total = ev.total; - break; - - case 'load': - tracker[file].final = true; - resolve(this); - break; - - case 'error': - if (++tracker[file].attempts >= DOWNLOAD_ATTEMPTS_MAX) { - tracker[file].final = true; - reject(new Error("Failed loading file '" + file + "'")); - } else { - setTimeout(loadXHR.bind(null, resolve, reject, file, tracker), 1000); - } - break; - - case 'abort': - tracker[file].final = true; - reject(new Error("Loading file '" + file + "' was aborted.")); - break; - } - } -})(); diff --git a/platform/javascript/engine/engine.js b/platform/javascript/engine/engine.js new file mode 100644 index 00000000000..6d7509377fc --- /dev/null +++ b/platform/javascript/engine/engine.js @@ -0,0 +1,184 @@ +Function('return this')()['Engine'] = (function() { + + var unloadAfterInit = true; + var canvas = null; + var resizeCanvasOnStart = false; + var customLocale = 'en_US'; + var wasmExt = '.wasm'; + + var preloader = new Preloader(); + var loader = new Loader(); + var rtenv = null; + + var executableName = ''; + var loadPath = ''; + var loadPromise = null; + var initPromise = null; + var stderr = null; + var stdout = null; + var progressFunc = null; + + function load(basePath) { + if (loadPromise == null) { + loadPath = basePath; + loadPromise = preloader.loadPromise(basePath + wasmExt); + preloader.setProgressFunc(progressFunc); + requestAnimationFrame(preloader.animateProgress); + } + return loadPromise; + }; + + function unload() { + loadPromise = null; + }; + + /** @constructor */ + function Engine() {}; + + Engine.prototype.init = /** @param {string=} basePath */ function(basePath) { + if (initPromise) { + return initPromise; + } + if (!loadPromise) { + if (!basePath) { + initPromise = Promise.reject(new Error("A base path must be provided when calling `init` and the engine is not loaded.")); + return initPromise; + } + load(basePath); + } + var config = {} + if (typeof stdout === 'function') + config.print = stdout; + if (typeof stderr === 'function') + config.printErr = stderr; + initPromise = loader.init(loadPromise, loadPath, config).then(function() { + return new Promise(function(resolve, reject) { + rtenv = loader.env; + if (unloadAfterInit) { + loadPromise = null; + } + resolve(); + }); + }); + return initPromise; + }; + + /** @type {function(string, string):Object} */ + Engine.prototype.preloadFile = function(file, path) { + return preloader.preload(file, path); + }; + + /** @type {function(...string):Object} */ + Engine.prototype.start = function() { + // Start from arguments. + var args = []; + for (var i = 0; i < arguments.length; i++) { + args.push(arguments[i]); + } + var me = this; + return new Promise(function(resolve, reject) { + return me.init().then(function() { + if (!(canvas instanceof HTMLCanvasElement)) { + canvas = Utils.findCanvas(); + } + rtenv['locale'] = customLocale; + rtenv['canvas'] = canvas; + rtenv['thisProgram'] = executableName; + rtenv['resizeCanvasOnStart'] = resizeCanvasOnStart; + loader.start(preloader.preloadedFiles, args).then(function() { + loader = null; + initPromise = null; + resolve(); + }); + }); + }); + }; + + Engine.prototype.startGame = function(execName, mainPack) { + // Start and init with execName as loadPath if not inited. + executableName = execName; + var me = this; + return Promise.all([ + this.init(execName), + this.preloadFile(mainPack, mainPack) + ]).then(function() { + return me.start('--main-pack', mainPack); + }); + }; + + Engine.prototype.setWebAssemblyFilenameExtension = function(override) { + if (String(override).length === 0) { + throw new Error('Invalid WebAssembly filename extension override'); + } + wasmExt = String(override); + }; + + Engine.prototype.setUnloadAfterInit = function(enabled) { + unloadAfterInit = enabled; + }; + + Engine.prototype.setCanvas = function(canvasElem) { + canvas = canvasElem; + }; + + Engine.prototype.setCanvasResizedOnStart = function(enabled) { + resizeCanvasOnStart = enabled; + }; + + Engine.prototype.setLocale = function(locale) { + customLocale = locale; + }; + + Engine.prototype.setExecutableName = function(newName) { + executableName = newName; + }; + + Engine.prototype.setProgressFunc = function(func) { + progressFunc = func; + } + + Engine.prototype.setStdoutFunc = function(func) { + + var print = function(text) { + if (arguments.length > 1) { + text = Array.prototype.slice.call(arguments).join(" "); + } + func(text); + }; + if (rtenv) + rtenv.print = print; + stdout = print; + }; + + Engine.prototype.setStderrFunc = function(func) { + + var printErr = function(text) { + if (arguments.length > 1) + text = Array.prototype.slice.call(arguments).join(" "); + func(text); + }; + if (rtenv) + rtenv.printErr = printErr; + stderr = printErr; + }; + + // Closure compiler exported engine methods. + /** @export */ + Engine['isWebGLAvailable'] = Utils.isWebGLAvailable; + Engine['load'] = load; + Engine['unload'] = unload; + Engine.prototype['init'] = Engine.prototype.init + Engine.prototype['preloadFile'] = Engine.prototype.preloadFile + Engine.prototype['start'] = Engine.prototype.start + Engine.prototype['startGame'] = Engine.prototype.startGame + Engine.prototype['setWebAssemblyFilenameExtension'] = Engine.prototype.setWebAssemblyFilenameExtension + Engine.prototype['setUnloadAfterInit'] = Engine.prototype.setUnloadAfterInit + Engine.prototype['setCanvas'] = Engine.prototype.setCanvas + Engine.prototype['setCanvasResizedOnStart'] = Engine.prototype.setCanvasResizedOnStart + Engine.prototype['setLocale'] = Engine.prototype.setLocale + Engine.prototype['setExecutableName'] = Engine.prototype.setExecutableName + Engine.prototype['setProgressFunc'] = Engine.prototype.setProgressFunc + Engine.prototype['setStdoutFunc'] = Engine.prototype.setStdoutFunc + Engine.prototype['setStderrFunc'] = Engine.prototype.setStderrFunc + return Engine; +})(); diff --git a/platform/javascript/engine/externs.js b/platform/javascript/engine/externs.js new file mode 100644 index 00000000000..1a94dd15ec6 --- /dev/null +++ b/platform/javascript/engine/externs.js @@ -0,0 +1,3 @@ +var Godot; +var WebAssembly = {}; +WebAssembly.instantiate = function(buffer, imports) {}; diff --git a/platform/javascript/engine/loader.js b/platform/javascript/engine/loader.js new file mode 100644 index 00000000000..d27fbf612ee --- /dev/null +++ b/platform/javascript/engine/loader.js @@ -0,0 +1,33 @@ +var Loader = /** @constructor */ function() { + + this.env = null; + + this.init = function(loadPromise, basePath, config) { + var me = this; + return new Promise(function(resolve, reject) { + var cfg = config || {}; + cfg['locateFile'] = Utils.createLocateRewrite(basePath); + cfg['instantiateWasm'] = Utils.createInstantiatePromise(loadPromise); + loadPromise = null; + Godot(cfg).then(function(module) { + me.env = module; + resolve(); + }); + }); + } + + this.start = function(preloadedFiles, args) { + var me = this; + return new Promise(function(resolve, reject) { + if (!me.env) { + reject(new Error('The engine must be initialized before it can be started')); + } + preloadedFiles.forEach(function(file) { + Utils.copyToFS(me.env['FS'], file.path, file.buffer); + }); + preloadedFiles.length = 0; // Clear memory + me.env['callMain'](args); + resolve(); + }); + } +}; diff --git a/platform/javascript/engine/preloader.js b/platform/javascript/engine/preloader.js new file mode 100644 index 00000000000..17918eae382 --- /dev/null +++ b/platform/javascript/engine/preloader.js @@ -0,0 +1,139 @@ +var Preloader = /** @constructor */ function() { + + var DOWNLOAD_ATTEMPTS_MAX = 4; + var progressFunc = null; + var lastProgress = { loaded: 0, total: 0 }; + + var loadingFiles = {}; + this.preloadedFiles = []; + + function loadXHR(resolve, reject, file, tracker) { + var xhr = new XMLHttpRequest; + xhr.open('GET', file); + if (!file.endsWith('.js')) { + xhr.responseType = 'arraybuffer'; + } + ['loadstart', 'progress', 'load', 'error', 'abort'].forEach(function(ev) { + xhr.addEventListener(ev, onXHREvent.bind(xhr, resolve, reject, file, tracker)); + }); + xhr.send(); + } + + function onXHREvent(resolve, reject, file, tracker, ev) { + + if (this.status >= 400) { + + if (this.status < 500 || ++tracker[file].attempts >= DOWNLOAD_ATTEMPTS_MAX) { + reject(new Error("Failed loading file '" + file + "': " + this.statusText)); + this.abort(); + return; + } else { + setTimeout(loadXHR.bind(null, resolve, reject, file, tracker), 1000); + } + } + + switch (ev.type) { + case 'loadstart': + if (tracker[file] === undefined) { + tracker[file] = { + total: ev.total, + loaded: ev.loaded, + attempts: 0, + final: false, + }; + } + break; + + case 'progress': + tracker[file].loaded = ev.loaded; + tracker[file].total = ev.total; + break; + + case 'load': + tracker[file].final = true; + resolve(this); + break; + + case 'error': + if (++tracker[file].attempts >= DOWNLOAD_ATTEMPTS_MAX) { + tracker[file].final = true; + reject(new Error("Failed loading file '" + file + "'")); + } else { + setTimeout(loadXHR.bind(null, resolve, reject, file, tracker), 1000); + } + break; + + case 'abort': + tracker[file].final = true; + reject(new Error("Loading file '" + file + "' was aborted.")); + break; + } + } + + this.loadPromise = function(file) { + return new Promise(function(resolve, reject) { + loadXHR(resolve, reject, file, loadingFiles); + }); + } + + this.preload = function(pathOrBuffer, destPath) { + if (pathOrBuffer instanceof ArrayBuffer) { + pathOrBuffer = new Uint8Array(pathOrBuffer); + } else if (ArrayBuffer.isView(pathOrBuffer)) { + pathOrBuffer = new Uint8Array(pathOrBuffer.buffer); + } + if (pathOrBuffer instanceof Uint8Array) { + this.preloadedFiles.push({ + path: destPath, + buffer: pathOrBuffer + }); + return Promise.resolve(); + } else if (typeof pathOrBuffer === 'string') { + var me = this; + return this.loadPromise(pathOrBuffer).then(function(xhr) { + me.preloadedFiles.push({ + path: destPath || pathOrBuffer, + buffer: xhr.response + }); + return Promise.resolve(); + }); + } else { + throw Promise.reject("Invalid object for preloading"); + } + }; + + var animateProgress = function() { + + var loaded = 0; + var total = 0; + var totalIsValid = true; + var progressIsFinal = true; + + Object.keys(loadingFiles).forEach(function(file) { + const stat = loadingFiles[file]; + if (!stat.final) { + progressIsFinal = false; + } + if (!totalIsValid || stat.total === 0) { + totalIsValid = false; + total = 0; + } else { + total += stat.total; + } + loaded += stat.loaded; + }); + if (loaded !== lastProgress.loaded || total !== lastProgress.total) { + lastProgress.loaded = loaded; + lastProgress.total = total; + if (typeof progressFunc === 'function') + progressFunc(loaded, total); + } + if (!progressIsFinal) + requestAnimationFrame(animateProgress); + } + this.animateProgress = animateProgress; // Also exposed to start it. + + this.setProgressFunc = function(callback) { + progressFunc = callback; + } +}; diff --git a/platform/javascript/engine/utils.js b/platform/javascript/engine/utils.js new file mode 100644 index 00000000000..fdff90a9236 --- /dev/null +++ b/platform/javascript/engine/utils.js @@ -0,0 +1,69 @@ +var Utils = { + + createLocateRewrite: function(execName) { + function rw(path) { + if (path.endsWith('.worker.js')) { + return execName + '.worker.js'; + } else if (path.endsWith('.js')) { + return execName + '.js'; + } else if (path.endsWith('.wasm')) { + return execName + '.wasm'; + } + } + return rw; + }, + + createInstantiatePromise: function(wasmLoader) { + function instantiateWasm(imports, onSuccess) { + wasmLoader.then(function(xhr) { + WebAssembly.instantiate(xhr.response, imports).then(function(result) { + onSuccess(result['instance'], result['module']); + }); + }); + wasmLoader = null; + return {}; + }; + + return instantiateWasm; + }, + + copyToFS: function(fs, path, buffer) { + var p = path.lastIndexOf("/"); + var dir = "/"; + if (p > 0) { + dir = path.slice(0, path.lastIndexOf("/")); + } + try { + fs.stat(dir); + } catch (e) { + if (e.errno !== 44) { // 'ENOENT', see https://github.com/emscripten-core/emscripten/blob/master/system/lib/libc/musl/arch/emscripten/bits/errno.h + throw e; + } + fs['mkdirTree'](dir); + } + // With memory growth, canOwn should be false. + fs['writeFile'](path, new Uint8Array(buffer), {'flags': 'wx+'}); + }, + + findCanvas: function() { + var nodes = document.getElementsByTagName('canvas'); + if (nodes.length && nodes[0] instanceof HTMLCanvasElement) { + return nodes[0]; + } + throw new Error("No canvas found"); + }, + + isWebGLAvailable: function(majorVersion = 1) { + + var testContext = false; + try { + var testCanvas = document.createElement('canvas'); + if (majorVersion === 1) { + testContext = testCanvas.getContext('webgl') || testCanvas.getContext('experimental-webgl'); + } else if (majorVersion === 2) { + testContext = testCanvas.getContext('webgl2') || testCanvas.getContext('experimental-webgl2'); + } + } catch (e) {} + return !!testContext; + } +}; diff --git a/platform/javascript/export/export.cpp b/platform/javascript/export/export.cpp index f0326d50271..da61425747e 100644 --- a/platform/javascript/export/export.cpp +++ b/platform/javascript/export/export.cpp @@ -94,6 +94,9 @@ public: } else if (req[1] == basereq + ".js") { filepath += ".js"; ctype = "application/javascript"; + } else if (req[1] == basereq + ".worker.js") { + filepath += ".worker.js"; + ctype = "application/javascript"; } else if (req[1] == basereq + ".pck") { filepath += ".pck"; ctype = "application/octet-stream"; @@ -432,6 +435,10 @@ Error EditorExportPlatformJavaScript::export_project(const Ref &p_prese // Export generates several files, clean them up on failure. DirAccess::remove_file_or_error(basepath + ".html"); DirAccess::remove_file_or_error(basepath + ".js"); + DirAccess::remove_file_or_error(basepath + ".worker.js"); DirAccess::remove_file_or_error(basepath + ".pck"); DirAccess::remove_file_or_error(basepath + ".png"); DirAccess::remove_file_or_error(basepath + ".wasm"); diff --git a/platform/javascript/id_handler.js b/platform/javascript/id_handler.js index 3851123ed14..67d29075b8f 100644 --- a/platform/javascript/id_handler.js +++ b/platform/javascript/id_handler.js @@ -28,7 +28,7 @@ /* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ /*************************************************************************/ -var IDHandler = function() { +var IDHandler = /** @constructor */ function() { var ids = {}; var size = 0; diff --git a/platform/javascript/os_javascript.cpp b/platform/javascript/os_javascript.cpp index 9ba02233877..1d7a16db80c 100644 --- a/platform/javascript/os_javascript.cpp +++ b/platform/javascript/os_javascript.cpp @@ -935,6 +935,7 @@ Error OS_JavaScript::initialize(const VideoMode &p_desired, int p_video_driver, if (p_desired.fullscreen) { /* clang-format off */ EM_ASM({ + const canvas = Module.canvas; (canvas.requestFullscreen || canvas.msRequestFullscreen || canvas.mozRequestFullScreen || canvas.mozRequestFullscreen || canvas.webkitRequestFullscreen diff --git a/platform/javascript/pre.js b/platform/javascript/pre.js deleted file mode 100644 index a870e676ea8..00000000000 --- a/platform/javascript/pre.js +++ /dev/null @@ -1,5 +0,0 @@ -var Engine = { - RuntimeEnvironment: function(Module, exposedLibs) { - // The above is concatenated with generated code, and acts as the start of - // a wrapper for said code. See engine.js for the other part of the - // wrapper.