1.4.1: утечка видеопамяти от трупов «Жертвы», труп лежит 5 минут

Было: CorpseLootPatch перекрашивал труп через renderer.materials и Tint(),
и ничего за собой не убирал. Копии материалов Unity с объектом не удаляет,
перекрашенная текстура была несжатой RGBA32 (16-64 МБ) и читаемой (копия
ещё и в RAM), кеш только рос. Лог игры: Texture.currentTextureMemory
1,2 -> 7,4 ГБ за два часа при desired ~3,5 ГБ, FPS 60 -> 11. Те же жалобы
на Nexus (sanovskiy, Kriloko, Megaladon43).

Стало: каждый перекрашенный труп записан (рендереры, материалы, текстуры),
тик раз в секунду удаляет всё за трупом, которого нет в мире, текстура
удаляется со счётчиком пользователей. Текстура не больше 1024, DXT,
makeNoLongerReadable. Проверено в игре: обыск и истечение срока доводят
счёт до «трупов 0, текстур 0».

Необысканный труп лежит 300 с вместо 3600 (указание пользователя).
buffNecroZombieKillTrackerDisplay: update_rate .1 -> 1 (отзыв zomb1);
урон ножа растёт после убийства, проверено.

Документы: версия 1.4.1, README (статус + несовместимость с 3.3
experimental), описания Nexus и сайта (пять минут вместо часа),
NEXUS_CHANGELOG/FILE_SUMMARY_1.4.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AlexCube
2026-09-24 23:36:51 +03:00
co-authored by Claude Opus 5.5
parent 67dd410358
commit 77b56be9ae
10 changed files with 186 additions and 16 deletions
+159 -7
View File
@@ -54,9 +54,11 @@ namespace NecromancerTome
/// </summary>
public static class CorpseLoot
{
/// <summary>Сколько секунд лежит помеченный труп. У ванильного мешка с лутом 3600, и
/// причин быть скромнее нет: труп и есть мешок.</summary>
public const float CorpseSecondsAfterDeath = 3600f;
/// <summary>Сколько секунд лежит помеченный труп. В 1.4.0 было 3600, как у ванильного мешка с
/// лутом; 2026-09-24 уменьшено до 5 минут по указанию пользователя: «если игрок не будет
/// убирать, у него FPS начнёт падать» - каждый лежащий труп это отрисовываемая модель со
/// своими копиями материалов, а необобранных за час набирается много.</summary>
public const float CorpseSecondsAfterDeath = 300f;
/// <summary>Во что красится труп. Тёмно-багровый: издалека видно, что этот не как все, и
/// при этом не выглядит подсветкой из другой игры.</summary>
@@ -81,9 +83,42 @@ namespace NecromancerTome
/// кадре, когда сущность умирает. Добивается односекундным тиком PetFollowPatch.</summary>
public static readonly HashSet<int> PendingTint = new HashSet<int>();
/// <summary>УТЕЧКА ПАМЯТИ, НАЙДЕНА 2026-09-24 (жалоба: «память течёт, лечится только
/// перезапуском»). Версия 1.4.0 перекрашивала труп и НИЧЕГО за собой не убирала:
///
/// - `renderer.materials` создаёт копии материалов, и Unity НЕ удаляет их вместе с
/// объектом - они живут до перезапуска;
/// - перекрашенная текстура создавалась несжатой (RGBA32, 16-64 МБ на штуку) и оставалась
/// читаемой, то есть лежала дважды - в видеопамяти и копией в оперативной;
/// - кеш текстур только рос.
///
/// Лог игры (`60sec delta: ... current: ..., desired: ...` - это Texture.currentTextureMemory)
/// показал рост видеопамяти текстур с 1,2 до 7,4 ГБ за два часа при нужных игре ~3,5 ГБ, и
/// FPS упал с 60 до 11. За сессию было перекрашено 53 трупа по 4-10 материалов.
///
/// Теперь каждый перекрашенный труп записан здесь вместе с копиями материалов и текстурами,
/// а тик раз в секунду (ReleaseGoneCorpses) убирает за теми, кого уже нет. Текстура
/// удаляется, когда её не использует ни один труп.</summary>
public class TintedCorpse
{
public readonly List<Renderer> Renderers = new List<Renderer>();
public readonly List<Material> Materials = new List<Material>();
public readonly List<Texture2D> Textures = new List<Texture2D>();
}
public static readonly Dictionary<int, TintedCorpse> Tinted = new Dictionary<int, TintedCorpse>();
/// <summary>Сколько трупов сейчас носит каждую перекрашенную текстуру.</summary>
public static readonly Dictionary<Texture2D, int> TextureUsers = new Dictionary<Texture2D, int>();
/// <summary>Предел стороны перекрашенной текстуры. Исходные 2048-4096; труп рассматривают
/// вблизи, но 1024 со сжатием это ~1 МБ вместо 16-64 МБ, и разница на глаз незаметна.</summary>
public const int MaxTintSize = 1024;
/// <summary>Повторная попытка перекраски. Зовётся из общего тика раз в секунду.</summary>
public static void RetryPendingTints(World _world)
{
ReleaseGoneCorpses(_world);
if (PendingTint.Count == 0 || _world == null)
{
return;
@@ -103,6 +138,92 @@ namespace NecromancerTome
}
}
/// <summary>Уборка за исчезнувшими трупами. Трупом считается исчезнувшим, когда его нет в
/// мире И уничтожены все его рендереры: удалять материал, который ещё рисуется, нельзя.</summary>
public static void ReleaseGoneCorpses(World _world)
{
if (Tinted.Count == 0 || _world == null)
{
return;
}
List<int> gone = null;
foreach (KeyValuePair<int, TintedCorpse> pair in Tinted)
{
if (_world.GetEntity(pair.Key) != null)
{
continue;
}
bool rendered = false;
foreach (Renderer renderer in pair.Value.Renderers)
{
if (renderer != null)
{
rendered = true;
break;
}
}
if (rendered)
{
continue;
}
if (gone == null)
{
gone = new List<int>();
}
gone.Add(pair.Key);
}
if (gone == null)
{
return;
}
foreach (int id in gone)
{
TintedCorpse corpse = Tinted[id];
Tinted.Remove(id);
SearchableCorpses.Remove(id);
foreach (Material material in corpse.Materials)
{
if (material != null)
{
UnityEngine.Object.Destroy(material);
}
}
foreach (Texture2D texture in corpse.Textures)
{
ReleaseTexture(texture);
}
}
Debug.Log("[NecromancerTome] CorpseLoot: убрано за исчезнувшими трупами: " + gone.Count +
", осталось трупов " + Tinted.Count + ", текстур " + TextureUsers.Count);
}
public static void ReleaseTexture(Texture2D _texture)
{
if (_texture == null || !TextureUsers.TryGetValue(_texture, out int users))
{
return;
}
if (users > 1)
{
TextureUsers[_texture] = users - 1;
return;
}
TextureUsers.Remove(_texture);
List<Texture> keys = new List<Texture>();
foreach (KeyValuePair<Texture, Texture2D> pair in TintedTextures)
{
if (pair.Value == _texture)
{
keys.Add(pair.Key);
}
}
foreach (Texture key in keys)
{
TintedTextures.Remove(key);
}
UnityEngine.Object.Destroy(_texture);
}
public static bool IsSearchable(Entity _entity)
{
return _entity != null && SearchableCorpses.Contains(_entity.entityId);
@@ -165,6 +286,11 @@ namespace NecromancerTome
{
return false;
}
if (!Tinted.TryGetValue(_corpse.entityId, out TintedCorpse record))
{
record = new TintedCorpse();
Tinted[_corpse.entityId] = record;
}
int tinted = 0;
foreach (Renderer renderer in renderers)
{
@@ -177,7 +303,13 @@ namespace NecromancerTome
{
continue;
}
// Копии материалов создаются этой строкой, и все они теперь наши - записываем
// каждую, даже ту, что перекрасить не удалось: удалять её тоже нам.
Material[] instances = renderer.materials;
if (!record.Renderers.Contains(renderer))
{
record.Renderers.Add(renderer);
}
for (int i = 0; i < instances.Length && i < shared.Length; i++)
{
Material material = instances[i];
@@ -185,6 +317,10 @@ namespace NecromancerTome
{
continue;
}
if (!record.Materials.Contains(material))
{
record.Materials.Add(material);
}
string albedo = GhostTraderPatch.FindAlbedoProperty(material);
if (albedo == null)
{
@@ -196,6 +332,9 @@ namespace NecromancerTome
continue;
}
material.SetTexture(albedo, repainted);
record.Textures.Add(repainted);
TextureUsers.TryGetValue(repainted, out int users);
TextureUsers[repainted] = users + 1;
tinted++;
}
}
@@ -222,13 +361,19 @@ namespace NecromancerTome
RenderTexture previous = RenderTexture.active;
try
{
rt = RenderTexture.GetTemporary(_source.width, _source.height, 0,
// Уменьшение идёт бесплатно, самим Blit в RenderTexture меньшего размера. Стороны
// кратны четырём - иначе Compress ниже откажется сжимать.
float scale = Mathf.Min(1f, (float)MaxTintSize / Mathf.Max(_source.width, _source.height));
int width = Mathf.Max(4, (int)(_source.width * scale) / 4 * 4);
int height = Mathf.Max(4, (int)(_source.height * scale) / 4 * 4);
rt = RenderTexture.GetTemporary(width, height, 0,
RenderTextureFormat.ARGB32, RenderTextureReadWrite.sRGB);
Graphics.Blit(_source, rt);
RenderTexture.active = rt;
painted = new Texture2D(_source.width, _source.height, TextureFormat.RGBA32, false);
painted.ReadPixels(new Rect(0f, 0f, _source.width, _source.height), 0, 0);
painted = new Texture2D(width, height, TextureFormat.RGBA32, false);
painted.ReadPixels(new Rect(0f, 0f, width, height), 0, 0);
Color32[] pixels = painted.GetPixels32();
for (int i = 0; i < pixels.Length; i++)
@@ -240,12 +385,19 @@ namespace NecromancerTome
pixels[i] = p;
}
painted.SetPixels32(pixels);
painted.Apply(false, false);
// Сжатие в DXT (вчетверо-восьмеро меньше RGBA32) и makeNoLongerReadable=true: без
// него копия пикселей оставалась в оперативной памяти рядом с видеопамятью.
painted.Compress(false);
painted.Apply(false, true);
}
catch (Exception e)
{
Debug.LogError("[NecromancerTome] CorpseLoot: не удалось перекрасить текстуру '" +
_source.name + "': " + e.Message);
if (painted != null)
{
UnityEngine.Object.Destroy(painted);
}
painted = null;
}
finally