using System; using System.Collections; using System.IO; using System.Security.Cryptography; using System.Text; using UnityEngine; using UnityEngine.Rendering; // Compiles the FX / sprite / particle shaders used by gameplay during the preload // screen so the first note (or first VFX / UI blur) does not pay the variant compile // cost mid-gameplay. // // Progress: WarmupRoutine drives compilation across frames and reports a localized // progress string ("正在编译着色器 xx%") through the onProgress callback so the caller // (preload) can surface it on the loading overlay. When nothing needs compiling // (cache valid) it reports the idle default ("加载中..."). // // Persistence: after compiling, a validation manifest is written to a project-local // ShaderCache folder (never Application.persistentDataPath / the C: AppData path). On // the next load the manifest is compared against the current shader sources + GPU + // build. If everything matches, compilation is skipped; if any shader source changed // (or the GPU / build changed, invalidating previously compiled variants) the shaders // are recompiled and the manifest refreshed. public static class GlobalFxShaderWarmer { public const string IdleLoadingText = "加载中..."; private const string CompilingTextFormat = "正在编译着色器 {0}%"; [Serializable] private class ShaderEntry { public string name; public string sourceHash; } [Serializable] private class WarmManifest { public string unityVersion; public string appVersion; public string graphicsDevice; public string graphicsDeviceType; public ShaderEntry[] shaders; } // A shader to warm plus its source path relative to Application.dataPath (the // Assets folder). sourcePath may be null for engine/built-in shaders that have no // file under Assets; those are still gated by the Unity/GPU/build fingerprint. private readonly struct WarmTarget { public readonly string ShaderName; public readonly string SourcePath; public WarmTarget(string shaderName, string sourcePath) { ShaderName = shaderName; SourcePath = sourcePath; } } // Order is stable and used directly for manifest comparison. private static readonly WarmTarget[] Targets = { // Global full-screen FX blit shaders (noteFunction effects). new WarmTarget("Hidden/Bansonic/GlobalGlitch", "Shaders/GlobalGlitch.shader"), new WarmTarget("Hidden/Bansonic/GlobalMonochrome", "Shaders/GlobalMonochrome.shader"), new WarmTarget("Hidden/Bansonic/GlobalDistortion", "Shaders/GlobalDistortion.shader"), new WarmTarget("Hidden/Bansonic/GlobalAfterimage", "Shaders/GlobalAfterimage.shader"), // Explicitly requested particle shaders. new WarmTarget("Cartoon FX/Remaster/Particle Ubershader", "JMO Assets/Cartoon FX Remaster/CFXR Assets/Shaders/CFXR Particle Ubershader.cfxrshader"), new WarmTarget("Mobile/Particles/Additive", null), // Other CFXR particle shaders used across gameplay VFX. new WarmTarget("Cartoon FX/Remaster/Particle Procedural Glow", "JMO Assets/Cartoon FX Remaster/CFXR Assets/Shaders/CFXR Particle Glow.cfxrshader"), new WarmTarget("Cartoon FX/Remaster/Particle Screen Distortion", "JMO Assets/Cartoon FX Remaster/CFXR Assets/Shaders/CFXR Particle Distortion.cfxrshader"), new WarmTarget("Cartoon FX/Remaster/Particle Procedural Ring", "JMO Assets/Cartoon FX Remaster/CFXR Assets/Shaders/CFXR Particle Procedural Ring.cfxrshader"), // Legacy Cartoon FX particle shaders still referenced by prefabs. new WarmTarget("Cartoon FX/Legacy/Particles Additive Alpha8", "JMO Assets/Cartoon FX (legacy)/Shaders/CFXM_MobileParticleAdd_Alpha8.shader"), new WarmTarget("Cartoon FX/Legacy/Particle Multiply Colored", "JMO Assets/Cartoon FX (legacy)/Shaders/CFX3 Multiply Color.shader"), // Custom sprite/UI shaders used by gameplay materials. new WarmTarget("Bansonic/Sprite Glow", "Shaders/BansonicSpriteGlow.shader"), new WarmTarget("Bansonic/Sprite Light Curtain", "Shaders/BansonicSpriteLightCurtain.shader"), new WarmTarget("Bansonic/Sprite Vertical Glow Only", "Shaders/BansonicSpriteVerticalGlowOnly.shader"), new WarmTarget("Sprites/Outline", "SpriteGlow/Resources/SpriteGlow/Shaders/SpriteOutline.shader"), new WarmTarget("Custom/SphereGradientShader", "sphereShaders/SphereGradient.shader"), new WarmTarget("UI/Soft Shadow", "Shaders/UI/UIShadow.shader"), new WarmTarget("UI/UIGaussianBlur", "Shaders/UI/UIGaussianBlur.shader"), new WarmTarget("UI/Bansonic/Blur Behind", "Shaders/UI/BansonicUIBlurBehind.shader") }; private const string ManifestFolder = "ShaderCache"; private const string ManifestFileName = "fx_shader_warm_manifest.json"; private static bool s_warmed; // Coroutine entry point: warms shaders across frames and reports progress. Safe to // call once per process; subsequent calls are no-ops (guarded by s_warmed). public static IEnumerator WarmupRoutine(Action onProgress) { if (s_warmed) { onProgress?.Invoke(IdleLoadingText); yield break; } s_warmed = true; WarmManifest current = BuildCurrentManifest(); WarmManifest cached = TryLoadManifest(); if (cached != null && ManifestsMatch(cached, current)) { // Shaders (and GPU/build) unchanged: the driver's persistent pipeline cache // still holds valid binaries, so skip the redundant compile this process. Debug.Log("[GlobalFxShaderWarmer] Cached warm manifest valid; skipping shader recompile."); onProgress?.Invoke(IdleLoadingText); yield break; } RenderTexture temp = null; RenderTexture previousActive = RenderTexture.active; int count = Targets.Length; try { temp = RenderTexture.GetTemporary(4, 4, 0); for (int i = 0; i < count; i++) { int percent = Mathf.Clamp(Mathf.RoundToInt((float)i / count * 100f), 0, 100); onProgress?.Invoke(string.Format(CompilingTextFormat, percent)); CompileOne(Targets[i].ShaderName, temp); // Yield so the compile spikes are spread over frames and the progress // text can refresh instead of blocking on one long frame. yield return null; } } finally { if (temp != null) RenderTexture.ReleaseTemporary(temp); RenderTexture.active = previousActive; } SaveManifest(current); onProgress?.Invoke(IdleLoadingText); Debug.Log(cached == null ? "[GlobalFxShaderWarmer] No cache found; compiled shaders and wrote manifest." : "[GlobalFxShaderWarmer] Shader source or device changed; recompiled shaders and refreshed manifest."); } private static void CompileOne(string shaderName, RenderTexture temp) { Shader shader = Shader.Find(shaderName); if (shader == null) { Debug.LogWarning("[GlobalFxShaderWarmer] Shader not found: " + shaderName); return; } Material material = CoreUtils.CreateEngineMaterial(shader); if (material == null) return; try { // Blit pass 0 to force the shader's variant to compile. Even for sprite / // particle shaders this triggers program compilation; the throwaway 4x4 // render is discarded. Use a real source texture so URP's Blit does not // warn about a null source. Graphics.Blit(Texture2D.blackTexture, temp, material, 0); } catch (Exception ex) { Debug.LogWarning("[GlobalFxShaderWarmer] Warmup blit failed for " + shaderName + ": " + ex.Message); } finally { CoreUtils.Destroy(material); } } private static WarmManifest BuildCurrentManifest() { var entries = new ShaderEntry[Targets.Length]; for (int i = 0; i < Targets.Length; i++) { entries[i] = new ShaderEntry { name = Targets[i].ShaderName, sourceHash = ComputeSourceHash(Targets[i].SourcePath) }; } return new WarmManifest { unityVersion = Application.unityVersion, appVersion = Application.version, graphicsDevice = SystemInfo.graphicsDeviceName, graphicsDeviceType = SystemInfo.graphicsDeviceType.ToString(), shaders = entries }; } // Hashes the .shader source so an edit invalidates the cache. Engine/built-in // shaders (sourcePath == null) or a stripped player where the file is absent hash // to empty; validity there is gated by the build version + GPU fingerprint. private static string ComputeSourceHash(string relativePath) { if (string.IsNullOrEmpty(relativePath)) return string.Empty; try { string full = Path.Combine(Application.dataPath, relativePath); if (!File.Exists(full)) return string.Empty; byte[] bytes = File.ReadAllBytes(full); using (var sha = SHA256.Create()) { byte[] hash = sha.ComputeHash(bytes); var sb = new StringBuilder(hash.Length * 2); foreach (byte b in hash) sb.Append(b.ToString("x2")); return sb.ToString(); } } catch (Exception ex) { Debug.LogWarning("[GlobalFxShaderWarmer] Failed to hash shader source '" + relativePath + "': " + ex.Message); return string.Empty; } } private static bool ManifestsMatch(WarmManifest a, WarmManifest b) { if (a == null || b == null) return false; if (a.unityVersion != b.unityVersion || a.appVersion != b.appVersion || a.graphicsDevice != b.graphicsDevice || a.graphicsDeviceType != b.graphicsDeviceType) return false; if (a.shaders == null || b.shaders == null || a.shaders.Length != b.shaders.Length) return false; for (int i = 0; i < a.shaders.Length; i++) { ShaderEntry ea = a.shaders[i]; ShaderEntry eb = b.shaders[i]; if (ea == null || eb == null) return false; if (ea.name != eb.name || ea.sourceHash != eb.sourceHash) return false; } return true; } private static string GetManifestPath() { // Project-local folder only. Deliberately not Application.persistentDataPath // (that lives under C:/Users/.../AppData) per the requirement to stay off C:. string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); string dir = Path.Combine(projectRoot, ManifestFolder); Directory.CreateDirectory(dir); return Path.Combine(dir, ManifestFileName); } private static WarmManifest TryLoadManifest() { try { string path = GetManifestPath(); if (!File.Exists(path)) return null; string json = File.ReadAllText(path); if (string.IsNullOrEmpty(json)) return null; return JsonUtility.FromJson(json); } catch (Exception ex) { Debug.LogWarning("[GlobalFxShaderWarmer] Failed to read warm manifest: " + ex.Message); return null; } } private static void SaveManifest(WarmManifest manifest) { try { string path = GetManifestPath(); string json = JsonUtility.ToJson(manifest, true); File.WriteAllText(path, json); } catch (Exception ex) { Debug.LogWarning("[GlobalFxShaderWarmer] Failed to write warm manifest: " + ex.Message); } } }