Files

315 lines
12 KiB
C#

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<string> 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<WarmManifest>(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);
}
}
}