FPS сползал 60 -> 15-30 за 10-15 минут в любом мире; перезаход сбрасывал, в режиме
змеи (пояс скрыт) тормозов не было. Замер показал: группа окон toolbelt 2 -> 23 мс/кадр
при неизменном числе элементов. Причина - ошибка игры: BindingNcalcFunctions.cvar при
каждом вычислении регистрирует новый VariableStateCVar, RegisterVariable добавляет без
проверки, новая переменная стартует с 0 и в следующем кадре снова вызывает пересчёт.
Шкала fill="{% cvar('necroNecromancyProgressCVar') / 20 }" (с 1.3.0) набирала по одной
переменной за кадр. CVarBindingLeakPatch: одна CVar регистрируется в привязке один раз.
Проверено: 15 мин, 25 убийств - FPS 55-58 ровно, toolbelt ~1 мс/кадр.
Пирамида: на Texture Quality ниже Full атлас блоков игры меньше нашей текстуры 512, и
CustomBlockPaint её пропускал. Теперь берётся мип под размер атласа; бандл necroatlas
пересобран с ignoreMipmapLimit (иначе CopyTexture отказывает и пирамида чёрная).
Проверено на Half.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
333 lines
12 KiB
C#
333 lines
12 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.IO;
|
||
using System.Reflection;
|
||
using HarmonyLib;
|
||
using UnityEngine;
|
||
|
||
namespace NecromancerTome
|
||
{
|
||
/// <summary>
|
||
/// Adds the mod's own paint to the game's opaque block texture atlas, so the Pyramid of
|
||
/// Spirits can ship with a surface that does not exist in vanilla.
|
||
///
|
||
/// WHY A PATCH IS THE ONLY WAY. A block's Texture property is an INDEX into a prebuilt
|
||
/// atlas; a paint entry in painting.xml carries TextureId/PaintCost/Group/SortIndex and
|
||
/// never a path to an image, and the atlas itself is compiled into
|
||
/// blocktextureatlases_assets_all.bundle. Nothing in the XML layer can introduce new image
|
||
/// data, so the array has to be extended at runtime.
|
||
///
|
||
/// WHY THIS HOOK. CreateBlockTextures is the coroutine that reads painting.xml. Hooking its
|
||
/// completion is deliberate and was learned the hard way: an earlier probe ran from
|
||
/// MeshDescription.ReloadTextureArrays and found BlockTextureData.list still null, because
|
||
/// the texture arrays load BEFORE painting.xml. By the time this coroutine finishes, both the
|
||
/// arrays and the paint table exist.
|
||
///
|
||
/// THE NUMBERS THIS RELIES ON were measured in-game rather than assumed (BlockAtlasProbe,
|
||
/// 2026-09-10): the opaque atlas holds 407 slices of 512x512 with a full 10-level mip chain,
|
||
/// diffuse as DXT1 and normal/specular as DXT5, and all three arrays are non-readable. Two
|
||
/// consequences drive the code below - our textures must match those numbers exactly, and
|
||
/// every copy must go through the GPU, since a non-readable array cannot be read back.
|
||
///
|
||
/// SAFETY. Everything is wrapped: this runs inside the game's own XML loading, and an
|
||
/// escaping exception aborts that step - which is exactly how a careless earlier version
|
||
/// produced "XML loader: Executing post load step on 'materials.xml' failed". If anything
|
||
/// here fails, the mod logs it and leaves the game exactly as it was.
|
||
/// </summary>
|
||
public static class CustomBlockPaintPatch
|
||
{
|
||
/// <summary>Bundle we ship the paint textures in, relative to the mod folder.</summary>
|
||
const string BundlePath = "Resources/necroatlas";
|
||
|
||
const string DiffuseAsset = "Assets/NecroAtlas/atlas_necroPyramid_d.png";
|
||
const string NormalAsset = "Assets/NecroAtlas/atlas_necroPyramid_n.png";
|
||
const string SpecularAsset = "Assets/NecroAtlas/atlas_necroPyramid_m.png";
|
||
|
||
/// <summary>Name the paint is registered under; blocks.xml refers to the resulting id.</summary>
|
||
public const string PaintName = "txName_NecroAsh";
|
||
|
||
/// <summary>Paint id handed out by the game once registration succeeds, -1 while unset.
|
||
/// Logged on success so it can be written into blocks.xml.</summary>
|
||
public static int AssignedPaintId = -1;
|
||
|
||
static bool alreadyRan;
|
||
|
||
/// <summary>
|
||
/// Patch the coroutine's MoveNext. A coroutine compiles into a hidden state-machine
|
||
/// class, so the method that actually runs is MoveNext, not CreateBlockTextures itself -
|
||
/// AccessTools.EnumeratorMoveNext resolves it for us.
|
||
/// </summary>
|
||
[HarmonyPatch]
|
||
static class CreateBlockTexturesHook
|
||
{
|
||
static IEnumerable<MethodBase> TargetMethods()
|
||
{
|
||
MethodBase coroutine = AccessTools.Method(
|
||
typeof(BlockTexturesFromXML), "CreateBlockTextures");
|
||
if (coroutine == null)
|
||
{
|
||
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: " +
|
||
"BlockTexturesFromXML.CreateBlockTextures not found - paint not added");
|
||
yield break;
|
||
}
|
||
MethodBase moveNext = AccessTools.EnumeratorMoveNext(coroutine);
|
||
if (moveNext == null)
|
||
{
|
||
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: " +
|
||
"could not resolve the coroutine's MoveNext - paint not added");
|
||
yield break;
|
||
}
|
||
yield return moveNext;
|
||
}
|
||
|
||
// __result == false means the enumerator is done: the XML has been read in full.
|
||
static void Postfix(bool __result)
|
||
{
|
||
if (__result || alreadyRan) return;
|
||
alreadyRan = true;
|
||
try
|
||
{
|
||
AddPaint();
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: failed, game left " +
|
||
"untouched: " + e);
|
||
}
|
||
}
|
||
}
|
||
|
||
static void AddPaint()
|
||
{
|
||
if (GameManager.IsDedicatedServer)
|
||
{
|
||
Debug.Log("[NecromancerTome] CustomBlockPaint: dedicated server, textures skipped");
|
||
return;
|
||
}
|
||
|
||
MeshDescription mesh = MeshDescription.meshes[MeshDescription.MESH_OPAQUE];
|
||
var atlas = mesh == null ? null : mesh.textureAtlas as TextureAtlasBlocks;
|
||
if (atlas == null)
|
||
{
|
||
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: opaque atlas unavailable");
|
||
return;
|
||
}
|
||
|
||
AssetBundle bundle = LoadBundle();
|
||
if (bundle == null) return;
|
||
|
||
try
|
||
{
|
||
var diffuse = bundle.LoadAsset<Texture2D>(DiffuseAsset);
|
||
var normal = bundle.LoadAsset<Texture2D>(NormalAsset);
|
||
var specular = bundle.LoadAsset<Texture2D>(SpecularAsset);
|
||
if (diffuse == null || normal == null || specular == null)
|
||
{
|
||
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: bundle is missing one " +
|
||
"of the three textures - nothing added");
|
||
return;
|
||
}
|
||
|
||
Describe("our diffuse ", diffuse);
|
||
Describe("our normal ", normal);
|
||
Describe("our specular", specular);
|
||
|
||
int slice = Append(ref atlas.diffuseTexture, diffuse, "diffuse");
|
||
Append(ref atlas.normalTexture, normal, "normal");
|
||
Append(ref atlas.specularTexture, specular, "specular");
|
||
if (slice < 0) return;
|
||
|
||
mesh.TexDiffuse = atlas.diffuseTexture;
|
||
mesh.TexNormal = atlas.normalTexture;
|
||
mesh.TexSpecular = atlas.specularTexture;
|
||
mesh.ReloadTextureArrays(false);
|
||
|
||
int textureId = RegisterUvMapping(atlas, slice);
|
||
RegisterPaint(textureId);
|
||
}
|
||
finally
|
||
{
|
||
// Keep the loaded textures alive: only the bundle wrapper is released.
|
||
bundle.Unload(false);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Give the new slice an entry in uvMapping and return its index - that index is what a
|
||
/// block's Texture property in blocks.xml actually refers to.
|
||
///
|
||
/// The entry is CLONED from an existing plain opaque paint rather than built field by
|
||
/// field. UVRectTiling carries more than a slice number (tiling, block size, material
|
||
/// flags), and copying a known-good neighbour keeps every one of those correct without
|
||
/// guessing at fields we have never inspected. Only the slice index is changed.
|
||
/// </summary>
|
||
static int RegisterUvMapping(TextureAtlasBlocks atlas, int slice)
|
||
{
|
||
// 356 is txName_Steel_wall - an ordinary full-block opaque paint, which is exactly
|
||
// the shape of entry we want.
|
||
const int TemplateTextureId = 356;
|
||
if (atlas.uvMapping == null || atlas.uvMapping.Length <= TemplateTextureId)
|
||
{
|
||
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: uvMapping too small to " +
|
||
"clone a template from - paint not registered");
|
||
return -1;
|
||
}
|
||
|
||
int textureId = atlas.uvMapping.Length;
|
||
Array.Resize(ref atlas.uvMapping, textureId + 1);
|
||
UVRectTiling tile = atlas.uvMapping[TemplateTextureId];
|
||
// Только индекс слоя: имени у UVRectTiling нет, оно живёт в BlockTextureData.
|
||
tile.index = slice;
|
||
atlas.uvMapping[textureId] = tile;
|
||
|
||
Debug.Log("[NecromancerTome] CustomBlockPaint: uvMapping entry " + textureId +
|
||
" points at slice " + slice + " (cloned from " + TemplateTextureId + ")");
|
||
return textureId;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Register the paint itself, so it has a name, shows up in the paint brush, and can be
|
||
/// referred to by name. The block only needs the texture id, but a nameless texture with
|
||
/// no paint entry would be invisible to the rest of the game.
|
||
/// </summary>
|
||
static void RegisterPaint(int textureId)
|
||
{
|
||
if (textureId < 0) return;
|
||
if (BlockTextureData.list == null)
|
||
{
|
||
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: paint table missing - " +
|
||
"texture added but not named");
|
||
return;
|
||
}
|
||
|
||
int free = -1;
|
||
for (int i = 0; i < BlockTextureData.list.Length; i++)
|
||
{
|
||
if (BlockTextureData.list[i] == null) { free = i; break; }
|
||
}
|
||
if (free < 0)
|
||
{
|
||
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: no free paint slot - " +
|
||
"texture added but not named");
|
||
return;
|
||
}
|
||
|
||
var data = new BlockTextureData
|
||
{
|
||
ID = free,
|
||
Name = PaintName,
|
||
LocalizedName = Localization.Get(PaintName),
|
||
TextureID = (ushort)textureId,
|
||
Group = "txGroupMasonry",
|
||
PaintCost = 1,
|
||
SortIndex = 0,
|
||
Hidden = false,
|
||
};
|
||
data.Init();
|
||
|
||
AssignedPaintId = free;
|
||
Debug.Log("[NecromancerTome] CustomBlockPaint: paint registered, slot " + free +
|
||
", texture id " + textureId + " -> put Texture=\"" + textureId +
|
||
"\" on the block in blocks.xml");
|
||
}
|
||
|
||
static AssetBundle LoadBundle()
|
||
{
|
||
if (ModEntry.Instance == null)
|
||
{
|
||
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: mod path unknown");
|
||
return null;
|
||
}
|
||
|
||
string path = Path.Combine(ModEntry.Instance.Path, BundlePath);
|
||
if (!File.Exists(path))
|
||
{
|
||
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: bundle not found at " + path);
|
||
return null;
|
||
}
|
||
|
||
AssetBundle bundle = AssetBundle.LoadFromFile(path);
|
||
if (bundle == null)
|
||
{
|
||
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: bundle failed to load: " + path);
|
||
}
|
||
return bundle;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Rebuild a texture array one slice larger and put our texture in the new last slot.
|
||
/// Returns the new slice index, or -1 if the arrays disagree on anything that makes a
|
||
/// copy impossible.
|
||
/// </summary>
|
||
static int Append(ref Texture target, Texture2D ours, string label)
|
||
{
|
||
var src = target as Texture2DArray;
|
||
if (src == null)
|
||
{
|
||
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: " + label +
|
||
" is not a Texture2DArray - skipped");
|
||
return -1;
|
||
}
|
||
|
||
// Качество текстур ниже Full уменьшает атлас игры вдвое/вчетверо (Half: 256 вместо 512).
|
||
// Раньше тут был пропуск - и пирамида оставалась без текстуры (28.09, игра на Half).
|
||
// Наши уменьшенные копии уже лежат в мипах: при Half берём с мипа 1, при Quarter - с 2.
|
||
int mipOffset = 0;
|
||
while (mipOffset < ours.mipmapCount && (ours.width >> mipOffset) > src.width)
|
||
{
|
||
mipOffset++;
|
||
}
|
||
if ((ours.width >> mipOffset) != src.width || (ours.height >> mipOffset) != src.height)
|
||
{
|
||
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: " + label + " size mismatch, " +
|
||
"atlas is " + src.width + "x" + src.height + " but ours is " +
|
||
ours.width + "x" + ours.height + " - skipped");
|
||
return -1;
|
||
}
|
||
if (ours.graphicsFormat != src.graphicsFormat)
|
||
{
|
||
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: " + label + " format mismatch, " +
|
||
"atlas is " + src.graphicsFormat + " but ours is " + ours.graphicsFormat +
|
||
" - skipped");
|
||
return -1;
|
||
}
|
||
if (ours.mipmapCount - mipOffset < src.mipmapCount)
|
||
{
|
||
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: " + label + " mip mismatch, " +
|
||
"atlas has " + src.mipmapCount + " but ours has " + ours.mipmapCount +
|
||
" (from mip " + mipOffset + ") - skipped");
|
||
return -1;
|
||
}
|
||
|
||
int slice = src.depth;
|
||
var grown = new Texture2DArray(src.width, src.height, slice + 1,
|
||
src.graphicsFormat, UnityEngine.Experimental.Rendering.TextureCreationFlags.MipChain,
|
||
src.mipmapCount);
|
||
grown.name = src.name + "+necro";
|
||
grown.wrapMode = src.wrapMode;
|
||
grown.filterMode = src.filterMode;
|
||
grown.anisoLevel = src.anisoLevel;
|
||
|
||
// GPU-side copy: the game's arrays are non-readable, so nothing can be pulled back
|
||
// to the CPU. CopyTexture moves whole slices with their mip chains.
|
||
for (int i = 0; i < slice; i++) Graphics.CopyTexture(src, i, grown, i);
|
||
for (int mip = 0; mip < src.mipmapCount; mip++)
|
||
{
|
||
Graphics.CopyTexture(ours, 0, mip + mipOffset, grown, slice, mip);
|
||
}
|
||
|
||
target = grown;
|
||
Debug.Log("[NecromancerTome] CustomBlockPaint: " + label + " grown from " + slice +
|
||
" to " + (slice + 1) + " slices" + (mipOffset > 0 ? ", ours taken from mip " + mipOffset : ""));
|
||
return slice;
|
||
}
|
||
|
||
static void Describe(string label, Texture2D tex)
|
||
{
|
||
Debug.Log("[NecromancerTome] CustomBlockPaint: " + label + " " +
|
||
tex.width + "x" + tex.height + " format=" + tex.format +
|
||
" graphicsFormat=" + tex.graphicsFormat + " mips=" + tex.mipmapCount);
|
||
}
|
||
}
|
||
}
|