using System; using System.Collections; using System.Collections.Generic; using System.IO; using System.Text; using System.Threading.Tasks; using LibMMD.Material; using LibMMD.Unity3D.ImageLoader; using UnityEngine; using UnityEngine.Networking; using Object = UnityEngine.Object; namespace LibMMD.Unity3D { public class TextureLoader : IDisposable { private const string SysToonPath = "LibMmd/SysToon/"; private const long MaxPrefetchBytes = 32L * 1024 * 1024; private readonly string _relativePath; private readonly int _maxTextureSize; private readonly Func _isLogEnabled; private static readonly HashSet SysToonNames = new HashSet(); private readonly Dictionary _textureMap = new Dictionary(); // Normalized filename indexes avoid enumerating the same directory once for every // texture whose Unicode form or letter casing differs from the path stored in PMX. private readonly Dictionary> _directoryIndexes = new Dictionary>(StringComparer.OrdinalIgnoreCase); private readonly Dictionary> _childDirectoryIndexes = new Dictionary>(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _sourceFiles = new Dictionary(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _prefetchedBytes = new Dictionary(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _searchedDirectories = new Dictionary(StringComparer.OrdinalIgnoreCase); private struct FileStamp { public long Length; public long LastWriteTicks; } static TextureLoader() { SysToonNames.Add("toon0.bmp"); for (var i = 1; i < 9; i++) { SysToonNames.Add("toon0" + i + ".bmp"); } SysToonNames.Add("toon10.bmp"); } public TextureLoader(string relativePath, int maxTextureSize = 0, Func isLogEnabled = null) { _relativePath = relativePath + Path.DirectorySeparatorChar; _maxTextureSize = maxTextureSize; _isLogEnabled = isLogEnabled ?? (() => true); } public Texture LoadTexture(MmdTexture mmdTexture) { return mmdTexture == null ? null : LoadTexture(mmdTexture.TexturePath); } internal IEnumerator LoadTextureAsync(MmdTexture mmdTexture, Action completed) { if (completed == null) throw new ArgumentNullException(nameof(completed)); if (mmdTexture == null) { completed(null); yield break; } var requested = mmdTexture.TexturePath; if (string.IsNullOrEmpty(requested)) { completed(null); yield break; } requested = ReplaceFileSeperator(requested); if (_textureMap.TryGetValue(requested, out var cached)) { completed(cached); yield break; } if (_isLogEnabled()) Debug.LogFormat("load texture {0}", requested); if (SysToonNames.Contains(requested)) { var toon = Resources.Load(SysToonPath + Path.GetFileNameWithoutExtension(requested)) as Texture; if (toon != null) _textureMap[requested] = toon; completed(toon); yield break; } var resolved = FindFileWithNormalization(requested) ?? ResolveTexturePath(requested); if (!File.Exists(resolved)) { if (_isLogEnabled()) Debug.LogFormat("texture file not exists {0}", resolved); completed(null); yield break; } TrackSourceFile(resolved); var extension = Path.GetExtension(resolved).ToLowerInvariant(); if (extension == ".bmp" || extension == ".tga") { var decodeTask = Task.Run(() => extension == ".bmp" ? new BackgroundTextureData(BitmapLoader.LoadFromFile(resolved)) : new BackgroundTextureData(TgaTextureLoader.Decode(resolved, _maxTextureSize))); while (!decodeTask.IsCompleted) yield return null; Texture decoded = null; try { var data = decodeTask.GetAwaiter().GetResult(); decoded = data.CreateTexture(); if (decoded is Texture2D texture2D && extension == ".bmp") decoded = RescaleLargeTextureWithGpu(texture2D); } catch (Exception e) { Debug.LogWarningFormat("background texture decode failed for {0}, {1}", resolved, e); } // Unsupported TGA variants retain the compatibility loader. if (decoded == null && extension == ".tga") decoded = DoLoadTexture(requested); if (decoded != null) _textureMap[requested] = decoded; completed(decoded); yield break; } if (extension != ".png" && extension != ".jpg" && extension != ".jpeg") { var synchronous = DoLoadTexture(requested); if (synchronous != null) _textureMap[requested] = synchronous; completed(synchronous); yield break; } Texture loaded = null; using (var request = UnityWebRequestTexture.GetTexture(CreateFileUri(resolved), false)) { var operation = request.SendWebRequest(); while (!operation.isDone) yield return null; if (request.result == UnityWebRequest.Result.Success) { loaded = DownloadHandlerTexture.GetContent(request); if (loaded is Texture2D texture2D) loaded = RescaleLargeTextureWithGpu(texture2D); } else { Debug.LogWarningFormat("failed to load texture {0}, {1}", resolved, request.error); } } if (loaded != null) _textureMap[requested] = loaded; completed(loaded); } private static string CreateFileUri(string path) { // System.Uri intentionally leaves '+' unescaped. UnityWebRequest's local-file // handling can interpret it as a form-encoded space and return a false 404 for a // file that File.Exists already resolved successfully. return new Uri(new FileInfo(path).FullName).AbsoluteUri.Replace("+", "%2B"); } private sealed class BackgroundTextureData { private readonly TextureImage _bitmap; private readonly TgaTextureLoader.DecodedTexture _tga; internal BackgroundTextureData(TextureImage bitmap) { _bitmap = bitmap; } internal BackgroundTextureData(TgaTextureLoader.DecodedTexture tga) { _tga = tga; } internal Texture2D CreateTexture() { if (_tga != null) return TgaTextureLoader.CreateTexture(_tga); if (_bitmap == null) return null; var texture = new Texture2D(_bitmap.Width, _bitmap.Height, TextureFormat.ARGB32, false); texture.SetPixels(_bitmap.Pixels); texture.Apply(false, false); return texture; } } private Texture2D RescaleLargeTextureWithGpu(Texture2D source) { if (_maxTextureSize <= 0 || source.width <= _maxTextureSize && source.height <= _maxTextureSize) return source; var width = Math.Min(source.width, _maxTextureSize); var height = Math.Min(source.height, _maxTextureSize); var previous = RenderTexture.active; var temporary = RenderTexture.GetTemporary(width, height, 0, RenderTextureFormat.ARGB32, RenderTextureReadWrite.Default); try { Graphics.Blit(source, temporary); RenderTexture.active = temporary; var resized = new Texture2D(width, height, TextureFormat.RGBA32, false) { name = source.name, filterMode = source.filterMode, wrapMode = source.wrapMode }; resized.ReadPixels(new Rect(0, 0, width, height), 0, 0, false); resized.Apply(false, false); Object.Destroy(source); return resized; } finally { RenderTexture.active = previous; RenderTexture.ReleaseTemporary(temporary); } } internal Task PrefetchAsync(IEnumerable textures, int maxConcurrency = 2) { return Task.Run(() => { var files = new HashSet(StringComparer.OrdinalIgnoreCase); long selectedBytes = 0; foreach (var texture in textures) { if (texture == null || string.IsNullOrEmpty(texture.TexturePath)) continue; var requested = ReplaceFileSeperator(texture.TexturePath); if (SysToonNames.Contains(requested)) continue; var resolved = FindFileWithNormalization(requested) ?? ResolveTexturePath(requested); if (!File.Exists(resolved)) continue; var extension = Path.GetExtension(resolved).ToLowerInvariant(); if (extension != ".png" && extension != ".jpg" && extension != ".jpeg") continue; TrackSourceFile(resolved); var info = new FileInfo(resolved); if (selectedBytes + info.Length > MaxPrefetchBytes) continue; if (files.Add(info.FullName)) selectedBytes += info.Length; } Parallel.ForEach(files, new ParallelOptions { MaxDegreeOfParallelism = Math.Max(1, maxConcurrency) }, file => { try { var bytes = File.ReadAllBytes(file); lock (_prefetchedBytes) _prefetchedBytes[file] = bytes; } catch (IOException) { // The normal synchronous loader retains its warning/null fallback. } catch (UnauthorizedAccessException) { } }); }); } private Texture LoadTexture(string path) { if (string.IsNullOrEmpty(path)) { return null; } path = ReplaceFileSeperator(path); Texture ret; if (_textureMap.TryGetValue(path, out ret)) { return ret; } if (_isLogEnabled()) Debug.LogFormat("load texture {0}", path); ret = DoLoadTexture(path); if (ret != null) { _textureMap.Add(path, ret); } return ret; } public void Dispose() { foreach (var entry in _textureMap) { if (SysToonNames.Contains(entry.Key)) { continue; } var tex2D = entry.Value as Texture2D; if (tex2D == null) { continue; } Object.Destroy(tex2D); } } private string ResolveTexturePath(string relativePath) { string dir = _relativePath.TrimEnd(Path.DirectorySeparatorChar); for (int i = 0; i < 5; i++) { string candidate = Path.Combine(dir, relativePath); string resolved = FindFileWithNormalization(candidate); if (resolved != null) return resolved; string parent = Path.GetDirectoryName(dir); if (string.IsNullOrEmpty(parent) || parent == dir) break; dir = parent; } return _relativePath + relativePath; } // iOS/macOS APFS stores filenames in NFD; PMX files may use NFC. Try both. private string FindFileWithNormalization(string fullPath) { if (File.Exists(fullPath)) return fullPath; string directory = Path.GetDirectoryName(fullPath); string fileName = Path.GetFileName(fullPath); if (string.IsNullOrEmpty(directory) || string.IsNullOrEmpty(fileName)) return null; // On the case-sensitive file systems used by iOS, Directory.Exists(directory) // also fails when any intermediate component differs only by casing or Unicode // normalization. Resolve the directory chain before looking for the leaf file. directory = FindDirectoryWithNormalization(directory); if (directory == null) return null; string nfc = fileName.Normalize(NormalizationForm.FormC); string nfd = fileName.Normalize(NormalizationForm.FormD); foreach (var form in new[] { nfc, nfd }) { string candidate = Path.Combine(directory, form); if (File.Exists(candidate)) return candidate; } if (!Directory.Exists(directory)) return null; try { Dictionary index; if (!_directoryIndexes.TryGetValue(directory, out index)) { index = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var file in Directory.GetFiles(directory)) { var normalized = Path.GetFileName(file).Normalize(NormalizationForm.FormC); if (!index.ContainsKey(normalized)) index.Add(normalized, file); } _directoryIndexes.Add(directory, index); _searchedDirectories[directory] = Directory.GetLastWriteTimeUtc(directory).Ticks; } string indexedPath; if (index.TryGetValue(nfc, out indexedPath)) return indexedPath; } catch (Exception) { } return null; } private string FindDirectoryWithNormalization(string directory) { if (Directory.Exists(directory)) return directory; var parent = Path.GetDirectoryName(directory); var name = Path.GetFileName(directory); if (string.IsNullOrEmpty(parent) || string.IsNullOrEmpty(name) || parent == directory) return null; parent = FindDirectoryWithNormalization(parent); if (parent == null) return null; try { Dictionary index; if (!_childDirectoryIndexes.TryGetValue(parent, out index)) { index = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var child in Directory.GetDirectories(parent)) { var normalized = Path.GetFileName(child).Normalize(NormalizationForm.FormC); if (!index.ContainsKey(normalized)) index.Add(normalized, child); } _childDirectoryIndexes.Add(parent, index); _searchedDirectories[parent] = Directory.GetLastWriteTimeUtc(parent).Ticks; } string resolved; return index.TryGetValue(name.Normalize(NormalizationForm.FormC), out resolved) ? resolved : null; } catch (Exception) { return null; } } private string ReplaceFileSeperator(string path) { if (Path.DirectorySeparatorChar.Equals('/')) { path = path.Replace('\\', '/'); } else if (Path.DirectorySeparatorChar.Equals('\\')) { path = path.Replace('/', '\\'); } return path; } private Texture DoLoadTexture(string path) { if (SysToonNames.Contains(path)) { var filename = Path.GetFileNameWithoutExtension(path); return Resources.Load(SysToonPath + filename) as Texture; } string resolved = FindFileWithNormalization(path); if (resolved == null) { resolved = ResolveTexturePath(path); } path = resolved; if (!File.Exists(path)) { if (_isLogEnabled()) Debug.LogFormat("texture file not exists {0}", path); return null; } TrackSourceFile(path); try { var extension = Path.GetExtension(path); if (extension != null) { var ext = extension.ToLower(); Texture ret; if (".jpg".Equals(ext) || ".jpeg".Equals(ext)) { ret = LoadJpg(path); } else if (".png".Equals(ext)) { ret = LoadPng(path); } else if (".bmp".Equals(ext)) { ret = LoadBmp(path); } else if (".tga".Equals(ext)) { ret = TgaTextureLoader.Load(path, _maxTextureSize); } else if (".dds".Equals(ext)) { ret = LoadDds(path); } else { ret = TryLoadWithAllFormats(path); } var tex2D = ret as Texture2D; if (tex2D != null) { RescaleLargeTexture(tex2D); //tex2D.Compress(false); } return ret; } } catch (Exception e) { Debug.LogWarningFormat("failed to load texture {0}, {1}", path, e); } return null; } private void TrackSourceFile(string path) { try { var info = new FileInfo(path); _sourceFiles[info.FullName] = new FileStamp { Length = info.Length, LastWriteTicks = info.LastWriteTimeUtc.Ticks, }; } catch (Exception) { } } // A cached Unity texture is reusable only while every source file and every directory // searched during path resolution is unchanged. Directory validation also catches a // newly-added texture that should replace a previous failed lookup. internal bool AreSourcesCurrent() { try { foreach (var pair in _sourceFiles) { var info = new FileInfo(pair.Key); if (!info.Exists || info.Length != pair.Value.Length || info.LastWriteTimeUtc.Ticks != pair.Value.LastWriteTicks) return false; } foreach (var pair in _searchedDirectories) { if (!Directory.Exists(pair.Key) || Directory.GetLastWriteTimeUtc(pair.Key).Ticks != pair.Value) return false; } return true; } catch (Exception) { // If freshness cannot be proven, force a reload rather than serving stale data. return false; } } private void RescaleLargeTexture(Texture2D tex) { if (_maxTextureSize <= 0) { return; } if (tex.width <= _maxTextureSize && tex.height <= _maxTextureSize) { return; } try { TextureScale.Bilinear(tex, Math.Min(tex.width, _maxTextureSize), Math.Min(tex.height, _maxTextureSize)); } catch (Exception e) { Debug.LogWarningFormat("Resize texture failed. {0}", e); } } private Texture DoLoadCubemap(string path) { var tex2D = DoLoadTexture(path) as Texture2D; return tex2D == null ? null : Texture2DToCubeMap(tex2D); } private Cubemap Texture2DToCubeMap(Texture2D texture2D) { if (texture2D.width != texture2D.height) { if (_isLogEnabled()) Debug.LogWarning("Can't convert a Texture2D to Cubemap when width and height are different"); return null; } var ret = new Cubemap(texture2D.width, texture2D.format, false); var texPixels = texture2D.GetPixels(); ret.SetPixels(texPixels, CubemapFace.NegativeX); ret.SetPixels(texPixels, CubemapFace.NegativeY); ret.SetPixels(texPixels, CubemapFace.NegativeZ); ret.SetPixels(texPixels, CubemapFace.PositiveX); ret.SetPixels(texPixels, CubemapFace.PositiveY); ret.SetPixels(texPixels, CubemapFace.PositiveZ); ret.Apply(); return ret; } private Texture TryLoadWithAllFormats(string path) { var ret = LoadBmp(path); if (ret != null) { return ret; } ret = LoadPng(path); if (ret != null) { return ret; } ret = LoadJpg(path); return ret; } private Texture LoadJpg(string path) { return LoadWithUnity(path); } private Texture LoadPng(string path) { return LoadWithUnity(path); } private static Texture LoadBmp(string path) { var img = BitmapLoader.LoadFromFile(path); if (img == null) { return null; } var ret = new Texture2D(img.Width, img.Height, TextureFormat.ARGB32, false); ret.SetPixels(img.Pixels); ret.Apply(); return ret; } private static Texture LoadDds(string path) { var bytes = File.ReadAllBytes(path); var width = DdsLoader.DdsGetWidth(bytes); var height = DdsLoader.DdsGetHeight(bytes); var nMipmap = DdsLoader.DdsGetMipmap(bytes); var hasMipmap = nMipmap > 1; var ret = new Texture2D(width, height, TextureFormat.ARGB32, hasMipmap); if (hasMipmap) { for (var i = 0; i < nMipmap; i++) { var intColors = DdsLoader.DdsRead(bytes, DdsLoader.DdsReaderArgb, i); ret.SetPixels(IntsArgbToColorUpsideDown(intColors, width / (1 << i), height / (1 << i)), i); } } else { var intColors = DdsLoader.DdsRead(bytes, DdsLoader.DdsReaderArgb, 0); ret.SetPixels(IntsArgbToColorUpsideDown(intColors, width, height)); } ret.Apply(); return ret; } public static Color[] IntsArgbToColorUpsideDown(int[] ints, int width, int height) { var ret = new Color[ints.Length]; for (var i = 0; i < height; i++) { for (var j = 0; j < width; j++) { var intColor = ints[i * width + j]; var dstIndex = (height - 1 - i) * width + j; ret[dstIndex].b = (intColor & 0xFF) / 255.0f; ret[dstIndex].g = ((intColor >> 8) & 0xFF) / 255.0f; ret[dstIndex].r = ((intColor >> 16) & 0xFF) / 255.0f; ret[dstIndex].a = ((intColor >> 24) & 0xFF) / 255.0f; } } return ret; } private Texture LoadWithUnity(string path) { if (!File.Exists(path)) return null; var fullPath = new FileInfo(path).FullName; byte[] fileData; lock (_prefetchedBytes) { if (!_prefetchedBytes.TryGetValue(fullPath, out fileData)) fileData = null; else _prefetchedBytes.Remove(fullPath); } if (fileData == null) fileData = File.ReadAllBytes(path); var tex = new Texture2D(2, 2); tex.LoadImage(fileData); return tex; } } }