// -------------------------------------------------------------------------------------------------------------------- // // Copyright (c) 2023 Johannes Deml. All rights reserved. // // // Johannes Deml // public@deml.io // // -------------------------------------------------------------------------------------------------------------------- using System; using System.Collections.Generic; using System.IO; using System.Linq; using JetBrains.Annotations; using UnityEditor; using UnityEditor.Build; using UnityEditor.Build.Reporting; using UnityEditor.Compilation; using UnityEngine; using UnityEngine.Rendering; namespace UnityBuilderAction { /// /// Used for building the project through continuous integration (CI) or semi-automated through menu items /// Supports logging in the editor and highly configurable WebGL /// Modified version of /// Tailored to the needs for /// public static class BuildScript { private static readonly string Eol = Environment.NewLine; private static bool LogVerboseBatchMode = true; private static bool LogVerboseInEditor = false; private static readonly string CodeOptimizationSpeed = #if UNITY_2022_3_OR_NEWER CodeOptimizationWebGL.RuntimeSpeedLTO.ToString(); #else "speed"; #endif private static readonly string CodeOptimizationSize = #if UNITY_2022_3_OR_NEWER CodeOptimizationWebGL.DiskSizeLTO.ToString(); #else "size"; #endif private static readonly string CodeOptimizationBuildTimes = #if UNITY_2022_3_OR_NEWER CodeOptimizationWebGL.BuildTimes.ToString(); #else "size"; #endif private static readonly string[] Secrets = { "androidKeystorePass", "androidKeyaliasName", "androidKeyaliasPass" }; private static BuildPlayerOptions buildPlayerOptions; private static List errorLogMessages = new List(); [UsedImplicitly] public static void BuildWithCommandlineArgs() { string[] args = Environment.GetCommandLineArgs(); Build(args); } public static void Build(string[] args) { buildPlayerOptions = new BuildPlayerOptions(); // Gather values from args Dictionary options = GetValidatedOptions(args); // Set version for this build if provided if(options.TryGetValue("buildVersion", out string buildVersion) && buildVersion != "none") { PlayerSettings.bundleVersion = buildVersion; PlayerSettings.macOS.buildNumber = buildVersion; } if(options.TryGetValue("androidVersionCode", out string versionCode) && versionCode != "0") { PlayerSettings.Android.bundleVersionCode = int.Parse(options["androidVersionCode"]); } // Apply build target var buildTarget = (BuildTarget)Enum.Parse(typeof(BuildTarget), options["buildTarget"]); switch (buildTarget) { case BuildTarget.Android: { EditorUserBuildSettings.buildAppBundle = options["customBuildPath"].EndsWith(".aab"); if (options.TryGetValue("androidKeystoreName", out string keystoreName) && !string.IsNullOrEmpty(keystoreName)) PlayerSettings.Android.keystoreName = keystoreName; if (options.TryGetValue("androidKeystorePass", out string keystorePass) && !string.IsNullOrEmpty(keystorePass)) PlayerSettings.Android.keystorePass = keystorePass; if (options.TryGetValue("androidKeyaliasName", out string keyaliasName) && !string.IsNullOrEmpty(keyaliasName)) PlayerSettings.Android.keyaliasName = keyaliasName; if (options.TryGetValue("androidKeyaliasPass", out string keyaliasPass) && !string.IsNullOrEmpty(keyaliasPass)) PlayerSettings.Android.keyaliasPass = keyaliasPass; break; } case BuildTarget.StandaloneOSX: #if UNITY_2021_3_OR_NEWER PlayerSettings.SetScriptingBackend(NamedBuildTarget.Standalone, ScriptingImplementation.Mono2x); #else PlayerSettings.SetScriptingBackend(BuildTargetGroup.Standalone, ScriptingImplementation.Mono2x); #endif break; case BuildTarget.WebGL: #if UNITY_2021_2_OR_NEWER // Use ASTC texture compression, since we are also targeting mobile versions - Don't use this for desktop only targets buildPlayerOptions.subtarget = (int)WebGLTextureSubtarget.ASTC; var namedBuildTarget = NamedBuildTarget.WebGL; #endif if (options.TryGetValue("tag", out string tagVersion) && !string.IsNullOrEmpty(tagVersion)) { string[] tagParameters = tagVersion.Split('-'); if (tagParameters.Contains("minsize")) { PlayerSettings.WebGL.template = "PROJECT:Release"; buildPlayerOptions.options |= BuildOptions.CompressWithLz4HC; PlayerSettings.WebGL.exceptionSupport = WebGLExceptionSupport.None; SetWebGlOptimization(CodeOptimizationSize); // Off by default, but just to be sure, see https://docs.unity3d.com/Manual/webgl-embeddedresources.html PlayerSettings.WebGL.useEmbeddedResources = false; PlayerSettings.WebGL.threadsSupport = false; // ASTC has the smallest texture sizes EditorUserBuildSettings.webGLBuildSubtarget = WebGLTextureSubtarget.ASTC; #if UNITY_2022_1_OR_NEWER PlayerSettings.SetIl2CppCodeGeneration(namedBuildTarget, Il2CppCodeGeneration.OptimizeSize); #endif #if UNITY_6000_0_OR_NEWER // Reduces build size and is widely supported PlayerSettings.WebGL.wasm2023 = true; #endif #if UNITY_2021_2_OR_NEWER PlayerSettings.SetIl2CppCompilerConfiguration(namedBuildTarget, Il2CppCompilerConfiguration.Master); #else PlayerSettings.SetIl2CppCompilerConfiguration(BuildTargetGroup.WebGL, Il2CppCompilerConfiguration.Master); #endif } else if (tagParameters.Contains("debug")) { PlayerSettings.WebGL.template = "PROJECT:Develop"; PlayerSettings.WebGL.exceptionSupport = WebGLExceptionSupport.FullWithStacktrace; // For debug builds this setting will always be build times no matter what is set, setting this more as a documentation of the behavior SetWebGlOptimization(CodeOptimizationBuildTimes); #if UNITY_2022_1_OR_NEWER PlayerSettings.SetIl2CppCodeGeneration(namedBuildTarget, Il2CppCodeGeneration.OptimizeSize); #endif #if UNITY_2021_2_OR_NEWER PlayerSettings.SetIl2CppCompilerConfiguration(namedBuildTarget, Il2CppCompilerConfiguration.Debug); PlayerSettings.WebGL.debugSymbolMode = WebGLDebugSymbolMode.Embedded; #else PlayerSettings.SetIl2CppCompilerConfiguration(BuildTargetGroup.WebGL, Il2CppCompilerConfiguration.Debug); PlayerSettings.WebGL.debugSymbols = true; #endif #if UNITY_2022_2_OR_NEWER PlayerSettings.WebGL.showDiagnostics = true; #endif buildPlayerOptions.options |= BuildOptions.Development; } else { PlayerSettings.WebGL.template = "PROJECT:Develop"; // By default use the speed setting SetWebGlOptimization(CodeOptimizationSpeed); #if UNITY_2022_1_OR_NEWER PlayerSettings.SetIl2CppCodeGeneration(namedBuildTarget, Il2CppCodeGeneration.OptimizeSpeed); #endif #if UNITY_2021_2_OR_NEWER PlayerSettings.SetIl2CppCompilerConfiguration(namedBuildTarget, Il2CppCompilerConfiguration.Master); #else PlayerSettings.SetIl2CppCompilerConfiguration(BuildTargetGroup.WebGL, Il2CppCompilerConfiguration.Master); #endif } List graphicsAPIs = new List(); if (tagParameters.Contains("webgl1")) { #if !UNITY_2023_1_OR_NEWER graphicsAPIs.Add(GraphicsDeviceType.OpenGLES2); #else LogWarning("WebGL1 not supported anymore, choosing WebGL2 instead"); graphicsAPIs.Add(GraphicsDeviceType.OpenGLES3); #endif } if(tagParameters.Contains("webgl2")) { graphicsAPIs.Add(GraphicsDeviceType.OpenGLES3); } if(tagParameters.Contains("webgpu")) { #if UNITY_2023_2_OR_NEWER graphicsAPIs.Add(GraphicsDeviceType.WebGPU); #if UNITY_6000_0_OR_NEWER // Enable wasm2023 for WebGPU, since if webGPU is supported everything from 2023 is supported as well PlayerSettings.WebGL.wasm2023 = true; #endif #else LogError("WebGPU not supported yet"); #endif } PlayerSettings.SetGraphicsAPIs(BuildTarget.WebGL, graphicsAPIs.ToArray()); } break; } // Additional options for local builds if (!Application.isBatchMode) { if (options.TryGetValue("autorunplayer", out string _)) { buildPlayerOptions.options |= BuildOptions.AutoRunPlayer; } var projectPath = Application.dataPath.Substring(0, Application.dataPath.Length - "/Assets".Length); BackupLastBuild($"{projectPath}/{options["customBuildPath"]}"); } errorLogMessages = new List(); Application.logMessageReceived += OnLogMessageReceived; // Custom build Build(buildTarget, options["customBuildPath"]); } private static void OnLogMessageReceived(string logString, string stackTrace, LogType type) { if(type == LogType.Error || type == LogType.Exception) { errorLogMessages.Add($"{logString}{Eol}{stackTrace}"); } } private static void BackupLastBuild(string buildPath) { if (Directory.Exists(buildPath)) { string backupFolderPath = $"{buildPath}-Previous"; if (Directory.Exists(backupFolderPath)) { Directory.Delete(backupFolderPath, true); } Log($"Moving current build folder to backup location: {backupFolderPath}"); Directory.Move(buildPath, backupFolderPath); } else if (File.Exists(buildPath)) { string extension = Path.GetExtension(buildPath); string pathWithoutExtension = buildPath.Substring(0, buildPath.Length - extension.Length); string backupFilePath = $"{pathWithoutExtension}-Previous{extension}"; if (File.Exists(backupFilePath)) { File.Delete(backupFilePath); } Log($"Moving current build file to backup location: {backupFilePath}"); File.Move(buildPath, backupFilePath); } } private static void SetWebGlOptimization(string value) { #if UNITY_2019_4_OR_NEWER EditorUserBuildSettings.SetPlatformSettings(BuildPipeline.GetBuildTargetName(BuildTarget.WebGL), "CodeOptimization", value); #else Log($"Setting {nameof(SetWebGlOptimization)} not supported by this unity version"); #endif } private static Dictionary GetValidatedOptions(string[] args) { ParseCommandLineArguments(args, out Dictionary validatedOptions); if (!validatedOptions.TryGetValue("projectPath", out string _)) { LogError("Missing argument -projectPath"); EndBuild(110); } if (!validatedOptions.TryGetValue("buildTarget", out string buildTarget)) { LogError("Missing argument -buildTarget"); EndBuild(120); } if (!Enum.IsDefined(typeof(BuildTarget), buildTarget ?? string.Empty)) { EndBuild(121); } if (!validatedOptions.TryGetValue("customBuildPath", out string _)) { LogError("Missing argument -customBuildPath"); EndBuild(130); } const string defaultCustomBuildName = "TestBuild"; if (!validatedOptions.TryGetValue("customBuildName", out string customBuildName)) { LogError($"Missing argument -customBuildName, defaulting to {defaultCustomBuildName}."); validatedOptions.Add("customBuildName", defaultCustomBuildName); } else if (customBuildName == "") { LogError($"Invalid argument -customBuildName, defaulting to {defaultCustomBuildName}."); validatedOptions.Add("customBuildName", defaultCustomBuildName); } return validatedOptions; } private static void ParseCommandLineArguments(string[] args, out Dictionary providedArguments) { providedArguments = new Dictionary(); LogVerbose( $"{Eol}" + $"###########################{Eol}" + $"# Parsing settings #{Eol}" + $"###########################{Eol}" + $"{Eol}" ); // Extract flags with optional values for (int current = 0, next = 1; current < args.Length; current++, next++) { // Parse flag bool isFlag = args[current].StartsWith("-"); if (!isFlag) continue; string flag = args[current].TrimStart('-'); // Parse optional value bool flagHasValue = next < args.Length && !args[next].StartsWith("-"); string value = flagHasValue ? args[next].TrimStart('-') : ""; bool secret = Secrets.Contains(flag); string displayValue = secret ? "*HIDDEN*" : "\"" + value + "\""; // Assign LogVerbose($"Found flag \"{flag}\" with value {displayValue}."); providedArguments.Add(flag, value); } } private static void Build(BuildTarget buildTarget, string filePath) { string[] scenes = EditorBuildSettings.scenes.Where(scene => scene.enabled).Select(s => s.path).ToArray(); buildPlayerOptions.scenes = scenes; buildPlayerOptions.target = buildTarget; buildPlayerOptions.locationPathName = filePath; BuildSummary buildSummary = BuildPipeline.BuildPlayer(buildPlayerOptions).summary; ReportSummary(buildSummary); ExitWithResult(buildSummary.result); } private static void ReportSummary(BuildSummary summary) { string summaryText = $"{Eol}" + $"###########################{Eol}" + $"# Build results #{Eol}" + $"###########################{Eol}" + $"{Eol}" + $"Duration: {summary.totalTime.ToString()}{Eol}" + $"Warnings: {summary.totalWarnings.ToString()}{Eol}" + $"Errors: {summary.totalErrors.ToString()}{Eol}" + $"Size: {summary.totalSize.ToString()} bytes{Eol}" + $"{Eol}"; if(errorLogMessages.Count > 0) { summaryText += $"### Error log messages: ###{Eol}"; summaryText += string.Join(Eol, errorLogMessages); } if (summary.totalErrors == 0) { Log(summaryText); } else { LogError(summaryText); } // Archive the build report to metadata folder ArchiveBuildReport(summary.outputPath); } private static void ArchiveBuildReport(string buildOutputPath) { try { string buildReportPath = "Library/LastBuild.buildreport"; if (!File.Exists(buildReportPath)) { LogWarning($"Build report not found at {buildReportPath}"); return; } string destinationFolder = Path.Combine(buildOutputPath, "metadata"); Directory.CreateDirectory(destinationFolder); string destinationPath = Path.Combine(destinationFolder, "LastBuild.buildreport"); File.Copy(buildReportPath, destinationPath, true); Log($"Build report archived to: {destinationPath}"); } catch (Exception ex) { LogWarning($"Failed to archive build report: {ex.Message}"); } } private static void ExitWithResult(BuildResult result) { switch (result) { case BuildResult.Succeeded: Log("Build succeeded!"); EndBuild(0); break; case BuildResult.Failed: LogError("Build failed!"); EndBuild(101); break; case BuildResult.Cancelled: LogError("Build cancelled!"); EndBuild(102); break; case BuildResult.Unknown: default: LogError("Build result is unknown!"); EndBuild(103); break; } } private static void EndBuild(int returnValue) { if (Application.isBatchMode) { EditorApplication.Exit(returnValue); } else { if (returnValue != 0) { throw new Exception($"BuildScript ended with non-zero exitCode: {returnValue}"); } } } private static void LogVerbose(string message) { if(Application.isBatchMode) { if (LogVerboseBatchMode) { Console.WriteLine(message); } } else { if (LogVerboseInEditor) { Debug.Log(message); } } } private static void Log(string message) { if(Application.isBatchMode) { Console.WriteLine(message); } else { Debug.Log(message); } } private static void LogWarning(string message) { if(Application.isBatchMode) { Console.WriteLine(message); } else { Debug.LogWarning(message); } } private static void LogError(string message) { if(Application.isBatchMode) { Console.WriteLine(message); } else { Debug.LogError(message); } } } }