using System; using System.Collections; using System.Collections.Generic; using System.Threading.Tasks; using LibMMD.Material; using LibMMD.Model; using UnityEngine; namespace LibMMD.Unity3D { public class MaterialLoader : IDisposable { private readonly TextureLoader _textureLoader; private readonly Func _isLogEnabled; private readonly IMmdMaterialAdapterRegistry _adapterRegistry; private readonly Dictionary _alphaStats = new Dictionary(); private struct AlphaStats { internal int PixelCount; internal int TransparentPixelCount; } public MaterialLoader( TextureLoader textureLoader, Func isLogEnabled = null, IMmdMaterialAdapterRegistry adapterRegistry = null) { _textureLoader = textureLoader; _isLogEnabled = isLogEnabled ?? (() => true); _adapterRegistry = adapterRegistry ?? MmdMaterialAdapterRegistry.Default; } public UnityEngine.Material LoadMaterial(MmdMaterial source, MmdUnityConfig config) { var context = BuildContext(source, config); IMmdMaterialAdapter adapter; var shader = ResolveShader(source, context, out adapter); if (shader == null) throw new InvalidOperationException("The built-in MMD shader could not be found."); var material = new UnityEngine.Material(shader); adapter.Apply(material, source, context); return material; } internal IEnumerator LoadMaterialAsync(MmdMaterial source, MmdUnityConfig config, Action completed) { Texture main = null, sphere = null, toon = null; var loader = _textureLoader.LoadTextureAsync(source.Texture, value => main = value); while (loader.MoveNext()) yield return loader.Current; loader = _textureLoader.LoadTextureAsync(source.SubTexture, value => sphere = value); while (loader.MoveNext()) yield return loader.Current; loader = _textureLoader.LoadTextureAsync(source.Toon, value => toon = value); while (loader.MoveNext()) yield return loader.Current; bool transparent = false; var transparency = ResolveTransparencyAsync(source, config, main, value => transparent = value); while (transparency.MoveNext()) yield return transparency.Current; var context = BuildContext(source, config, main, sphere, toon, transparent); IMmdMaterialAdapter adapter; var shader = ResolveShader(source, context, out adapter); if (shader == null) throw new InvalidOperationException("The built-in MMD shader could not be found."); var material = new UnityEngine.Material(shader); adapter.Apply(material, source, context); completed(material); } public void RefreshMaterialConfig( MmdMaterial source, MmdUnityConfig config, UnityEngine.Material material) { var context = BuildContext(source, config); IMmdMaterialAdapter adapter; var shader = ResolveShader(source, context, out adapter); if (shader != null && material.shader != shader) material.shader = shader; adapter.Apply(material, source, context); } private Shader ResolveShader( MmdMaterial source, MmdMaterialContext context, out IMmdMaterialAdapter selectedAdapter) { selectedAdapter = _adapterRegistry.Get(context.Config.ShaderAdapterId); if (selectedAdapter == null) { if (_isLogEnabled()) Debug.LogWarning("Unknown MMD material adapter '" + context.Config.ShaderAdapterId + "'. Falling back to MMD."); selectedAdapter = _adapterRegistry.Get(MmdMaterialAdapterIds.Mmd); } var shader = selectedAdapter.ResolveShader(source, context); if (shader != null) return shader; // A missing optional shader changes the rendered result and must remain visible in // player logs even when verbose LibMMD logging is disabled. Debug.LogWarning("Shader for adapter '" + selectedAdapter.Id + "' was not found. Falling back to MMD."); selectedAdapter = _adapterRegistry.Get(MmdMaterialAdapterIds.Mmd); return selectedAdapter.ResolveShader(source, context); } private MmdMaterialContext BuildContext(MmdMaterial source, MmdUnityConfig config) { var mainTexture = _textureLoader.LoadTexture(source.Texture); var sphereTexture = source.SubTexture == null ? null : _textureLoader.LoadTexture(source.SubTexture); var toonTexture = _textureLoader.LoadTexture(source.Toon); return BuildContext(source, config, mainTexture, sphereTexture, toonTexture); } private MmdMaterialContext BuildContext(MmdMaterial source, MmdUnityConfig config, Texture mainTexture, Texture sphereTexture, Texture toonTexture, bool? transparentOverride = null) { if (sphereTexture != null) sphereTexture.wrapMode = TextureWrapMode.Clamp; if (toonTexture != null) toonTexture.wrapMode = TextureWrapMode.Clamp; var isTransparent = transparentOverride ?? IsTransparent(source, config, mainTexture); var receiveShadows = MmdUnityConfig.DealSwitch(config.EnableDrawSelfShadow, source.DrawSelfShadow); return new MmdMaterialContext { Config = config, MainTexture = mainTexture, ToonTexture = toonTexture, SphereTexture = sphereTexture, SphereTextureType = source.SubTextureType, IsTransparent = isTransparent, // PMX materials with every render flag disabled are commonly // translucent decals layered over another material (hair // shadows, eye highlights, etc.). Keep this distinction so a // renderer can avoid letting only those overlays own depth. IsTransparentOverlay = isTransparent && AreAllRenderFlagsOff(source), EnableOutline = MmdUnityConfig.DealSwitch(config.EnableEdge, source.DrawEdge), // Transparency and shadow casting are independent. MMD's // ShadowCaster samples _MainTex and alpha-clips it, while // external adapters delegate the pass implementation to the // selected shader. The PMX flag/UI switch remains authoritative. CastShadows = MmdUnityConfig.DealSwitch(config.EnableCastShadow, source.CastSelfShadow), ReceiveShadows = receiveShadows, // Preserve the original MMD keyword condition. Receiving a // self shadow must not depend on whether this particular // material also owns a ShadowCaster pass. EnableSelfShadow = config.EnableCastShadow != MmdConfigSwitch.ForceFalse && receiveShadows, }; } public void Dispose() { if (_textureLoader != null) _textureLoader.Dispose(); } internal bool AreSourcesCurrent() { return _textureLoader != null && _textureLoader.AreSourcesCurrent(); } internal Task PrefetchTexturesAsync(MmdModel model, int maxConcurrency = 2) { // PNG/JPEG now use UnityWebRequestTexture, which performs its own asynchronous // file read. Prefetching those bytes would read every file twice and retain an // unused copy until the loader is disposed. return Task.CompletedTask; } private bool IsTransparent(MmdMaterial material, MmdUnityConfig config, Texture mainTexture) { if (material.DiffuseColor.a < 0.9999f) return true; if (material.DrawEdge && !material.DrawGroundShadow && !material.CastSelfShadow && !material.DrawSelfShadow) return true; if (!config.EnableTextureAlphaScan) return false; return AreAllRenderFlagsOff(material) ? IsTextureTransparentByRatio(mainTexture, 0.05f) : IsTextureTransparent(mainTexture); } private IEnumerator ResolveTransparencyAsync(MmdMaterial material, MmdUnityConfig config, Texture mainTexture, Action completed) { if (material.DiffuseColor.a < 0.9999f || material.DrawEdge && !material.DrawGroundShadow && !material.CastSelfShadow && !material.DrawSelfShadow) { completed(true); yield break; } if (!config.EnableTextureAlphaScan || !(mainTexture is Texture2D texture)) { completed(false); yield break; } AlphaStats stats = default; if (!_alphaStats.TryGetValue(texture, out stats)) { Color32[] pixels; try { pixels = texture.GetPixels32(); } catch { completed(false); yield break; } // Keep alpha analysis frame-friendly without stretching large, texture-heavy // models across thousands of frames. Color32 scanning is cheap enough for this // chunk size while still yielding several times for large textures. const int pixelsPerSlice = 131072; for (var start = 0; start < pixels.Length; start += pixelsPerSlice) { var end = Math.Min(start + pixelsPerSlice, pixels.Length); for (var i = start; i < end; i++) if (pixels[i].a < 230) stats.TransparentPixelCount++; if (end < pixels.Length) yield return null; } stats.PixelCount = pixels.Length; _alphaStats[texture] = stats; } completed(AreAllRenderFlagsOff(material) ? stats.PixelCount > 0 && (float)stats.TransparentPixelCount / stats.PixelCount >= 0.05f : stats.TransparentPixelCount >= 5); } private static bool AreAllRenderFlagsOff(MmdMaterial material) => !material.DrawEdge && !material.DrawGroundShadow && !material.CastSelfShadow && !material.DrawSelfShadow; private bool IsTextureTransparent(Texture texture) { try { var texture2D = texture as Texture2D; if (texture2D == null) return false; if (_alphaStats.TryGetValue(texture2D, out var stats)) return stats.TransparentPixelCount >= 5; var count = 0; foreach (var color in texture2D.GetPixels32()) if (color.a < 230 && ++count >= 5) return true; } catch { // A non-readable texture cannot be alpha-scanned. } return false; } private bool IsTextureTransparentByRatio(Texture texture, float minRatio) { try { var texture2D = texture as Texture2D; if (texture2D == null) return false; if (_alphaStats.TryGetValue(texture2D, out var stats)) return stats.PixelCount > 0 && (float)stats.TransparentPixelCount / stats.PixelCount >= minRatio; var pixels = texture2D.GetPixels32(); var count = 0; foreach (var color in pixels) if (color.a < 230) count++; return (float)count / pixels.Length >= minRatio; } catch { return false; } } } }