using System; using System.Collections.Generic; using ConformalDecals.Util; using TMPro; using UnityEngine; using UnityEngine.Events; using UnityEngine.Rendering; namespace ConformalDecals.Text { // TODO: Testing shows the job system is unnecessary, so remove job system code. /// Class handing text rendering. /// Is a singleton referencing a single gameobject in the scene which contains the TextMeshPro component [KSPAddon(KSPAddon.Startup.Instantly, true)] public class TextRenderer : MonoBehaviour { /// Texture format used for returned textures. /// Unfortunately due to how Unity textures work, this cannot be R8 or Alpha8, /// so theres always a superfluous green channel using memory public static TextureFormat textTextureFormat = TextureFormat.RG16; /// Render Texture format used when rendering /// Overriden below to be ARGB32 on DirectX because DirectX is dumb public static RenderTextureFormat textRenderTextureFormat = RenderTextureFormat.R8; /// The text renderer object within the scene which contains the TextMeshPro component used for rendering. public static TextRenderer Instance { get { if (!_instance._isSetup) { _instance.Setup(); } return _instance; } } /// Text Render unityevent, used with the job system to signal render completion [Serializable] public class TextRenderEvent : UnityEvent { } private const string ShaderName = "ConformalDecals/Text Blit"; private const int MaxTextureSize = 4096; private const float FontSize = 100; private const float PixelDensity = 5; private static TextRenderer _instance; private bool _isSetup; private TextMeshPro _tmp; private Shader _blitShader; private static readonly Dictionary RenderCache = new Dictionary(); private static readonly Queue RenderJobs = new Queue(); /// Update text using the job queue public static TextRenderJob UpdateText(DecalText oldText, DecalText newText, UnityAction renderFinishedCallback) { if (newText == null) throw new ArgumentNullException(nameof(newText)); var job = new TextRenderJob(oldText, newText, renderFinishedCallback); RenderJobs.Enqueue(job); return job; } /// Update text immediately without using job queue public static TextRenderOutput UpdateTextNow(DecalText oldText, DecalText newText) { if (newText == null) throw new ArgumentNullException(nameof(newText)); return Instance.RunJob(new TextRenderJob(oldText, newText, null), out _); } /// Unregister a user of a piece of text public static void UnregisterText(DecalText text) { if (RenderCache.TryGetValue(text, out var renderedText)) { renderedText.UserCount--; if (renderedText.UserCount <= 0) { RenderCache.Remove(text); Destroy(renderedText.Texture); } } } private void Start() { if (_instance != null) { Logging.LogError("Duplicate TextRenderer created???"); } Logging.Log("Creating TextRenderer Object"); _instance = this; DontDestroyOnLoad(gameObject); if (SystemInfo.graphicsDeviceType == GraphicsDeviceType.Direct3D11 || SystemInfo.graphicsDeviceType == GraphicsDeviceType.Direct3D12) { textRenderTextureFormat = RenderTextureFormat.ARGB32; // DirectX is dumb } if (!SystemInfo.SupportsTextureFormat(textTextureFormat)) { Logging.LogError($"Text texture format {textTextureFormat} not supported on this platform."); } if (!SystemInfo.SupportsRenderTextureFormat(textRenderTextureFormat)) { Logging.LogError($"Text texture format {textRenderTextureFormat} not supported on this platform."); } } /// Setup this text renderer instance for rendering private void Setup() { if (_isSetup) return; Logging.Log("Setting Up TextRenderer Object"); _tmp = gameObject.AddComponent(); _tmp.renderer.enabled = false; // dont automatically render _blitShader = Shabby.Shabby.FindShader(ShaderName); if (_blitShader == null) Logging.LogError($"Could not find text blit shader named '{ShaderName}'"); _isSetup = true; } /// Run a text render job private TextRenderOutput RunJob(TextRenderJob job, out bool renderNeeded) { if (!job.Needed) { renderNeeded = false; return null; } job.Start(); Texture2D texture = null; if (job.OldText != null && RenderCache.TryGetValue(job.OldText, out var oldRender)) { // old output still exists oldRender.UserCount--; if (oldRender.UserCount <= 0) { // this is the only usage of this output, so we are free to re-render into the texture texture = oldRender.Texture; RenderCache.Remove(job.OldText); } } // now that all old references are handled, begin rendering the new output if (RenderCache.TryGetValue(job.NewText, out var renderOutput)) { renderNeeded = false; } else { renderNeeded = true; renderOutput = RenderText(job.NewText, texture); RenderCache.Add(job.NewText, renderOutput); } renderOutput.UserCount++; job.Finish(renderOutput); return renderOutput; } /// Render a piece of text to a given texture public TextRenderOutput RenderText(DecalText text, Texture2D texture) { if (text == null) throw new ArgumentNullException(nameof(text)); if (_tmp == null) throw new InvalidOperationException("TextMeshPro object not yet created."); // SETUP TMP OBJECT FOR RENDERING _tmp.text = text.FormattedText; _tmp.font = text.Font.FontAsset; _tmp.fontStyle = text.Style | text.Font.FontStyle; _tmp.lineSpacing = text.LineSpacing; _tmp.characterSpacing = text.CharSpacing; _tmp.extraPadding = true; _tmp.enableKerning = true; _tmp.enableWordWrapping = false; _tmp.overflowMode = TextOverflowModes.Overflow; _tmp.alignment = TextAlignmentOptions.Center; _tmp.fontSize = FontSize; // GENERATE MESH _tmp.ClearMesh(false); _tmp.ForceMeshUpdate(); var meshFilters = gameObject.GetComponentsInChildren(); var meshes = new Mesh[meshFilters.Length]; var materials = new Material[meshFilters.Length]; var bounds = new Bounds(); // SETUP MATERIALS AND BOUNDS for (int i = 0; i < meshFilters.Length; i++) { var renderer = meshFilters[i].gameObject.GetComponent(); meshes[i] = meshFilters[i].mesh; if (i == 0) meshes[i] = _tmp.mesh; materials[i] = Instantiate(renderer.material); materials[i].shader = _blitShader; if (renderer == null) throw new FormatException($"Object {meshFilters[i].gameObject.name} has filter but no renderer"); if (meshes[i] == null) throw new FormatException($"Object {meshFilters[i].gameObject.name} has a null mesh"); if (i == 0) { bounds = meshes[i].bounds; } else { bounds.Encapsulate(meshes[i].bounds); } } // CALCULATE SIZES var size = bounds.size * PixelDensity; size.x = Mathf.Max(size.x, 0.1f); size.y = Mathf.Max(size.y, 0.1f); var textureSize = new Vector2Int { x = Mathf.NextPowerOfTwo((int) size.x), y = Mathf.NextPowerOfTwo((int) size.y) }; if (textureSize.x == 0 || textureSize.y == 0) { Logging.LogWarning("No text present or error in texture size calculation. Aborting."); return new TextRenderOutput(Texture2D.blackTexture, Rect.zero); } // make sure texture isnt too big, scale it down if it is // this is just so you dont crash the game by pasting in the entire script of The Bee Movie if (textureSize.x > MaxTextureSize) { textureSize.y /= textureSize.x / MaxTextureSize; textureSize.x = MaxTextureSize; } if (textureSize.y > MaxTextureSize) { textureSize.x /= textureSize.y / MaxTextureSize; textureSize.y = MaxTextureSize; } // scale up everything to fit the texture for maximum usage float sizeRatio = Mathf.Min(textureSize.x / size.x, textureSize.y / size.y); // calculate where in the texture the used area actually is var window = new Rect { size = size * sizeRatio, center = (Vector2) textureSize / 2 }; // SETUP TEXTURE if (texture == null) { texture = new Texture2D(textureSize.x, textureSize.y, textTextureFormat, true); } else if (texture.width != textureSize.x || texture.height != textureSize.y || texture.format != textTextureFormat) { texture.Resize(textureSize.x, textureSize.y, textTextureFormat, true); } // GENERATE PROJECTION MATRIX var halfSize = (Vector2) textureSize / PixelDensity / 2 / sizeRatio; var matrix = Matrix4x4.Ortho(bounds.center.x - halfSize.x, bounds.center.x + halfSize.x, bounds.center.y - halfSize.y, bounds.center.y + halfSize.y, -1, 1); // GET RENDERTEX var renderTex = RenderTexture.GetTemporary(textureSize.x, textureSize.y, 0, textRenderTextureFormat, RenderTextureReadWrite.Linear, 1); renderTex.autoGenerateMips = false; // RENDER Graphics.SetRenderTarget(renderTex); GL.PushMatrix(); GL.LoadProjectionMatrix(matrix); GL.LoadIdentity(); GL.Clear(false, true, Color.black); for (var i = 0; i < meshes.Length; i++) { if (meshes[i].vertexCount >= 3) { materials[i].SetPass(0); Graphics.DrawMeshNow(meshes[i], Matrix4x4.identity); } } GL.PopMatrix(); // COPY TEXTURE BACK INTO RAM RenderTexture.active = renderTex; texture.ReadPixels(new Rect(0, 0, textureSize.x, textureSize.y), 0, 0, true); texture.Apply(); // RELEASE RENDERTEX RenderTexture.ReleaseTemporary(renderTex); // CLEAR SUBMESHES _tmp.text = ""; for (int i = 0; i < transform.childCount; i++) { var child = transform.GetChild(i); Destroy(child.gameObject); } return new TextRenderOutput(texture, window); } } }