upd: sync local changes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
iisaevandClaude Opus 5.5 committed 2026-10-16 16:18:18 +00:00
1 parent b8ddf1f0df
commit a9d4417fce
27 files changed
+3333 -1035

No files matched your search

+257 -46
View File
@@ -33,117 +33,258 @@
}
}
},
"Microsoft.CodeAnalysis.Common/4.11.0": {
"Humanizer.Core/2.2.0": {
"runtime": {
"lib/net8.0/Microsoft.CodeAnalysis.dll": {
"assemblyVersion": "4.11.0.0",
"fileVersion": "4.1100.24.37604"
"lib/netstandard1.0/Humanizer.dll": {
"assemblyVersion": "2.2.0.0",
"fileVersion": "2.2.0.0"
}
}
},
"Microsoft.Bcl.AsyncInterfaces/5.0.0": {
"runtime": {
"lib/netstandard2.1/Microsoft.Bcl.AsyncInterfaces.dll": {
"assemblyVersion": "5.0.0.0",
"fileVersion": "5.0.20.51904"
}
}
},
"Microsoft.CodeAnalysis.Common/3.11.0": {
"runtime": {
"lib/netcoreapp3.1/Microsoft.CodeAnalysis.dll": {
"assemblyVersion": "3.11.0.0",
"fileVersion": "3.1100.21.40306"
}
},
"resources": {
"lib/net8.0/cs/Microsoft.CodeAnalysis.resources.dll": {
"lib/netcoreapp3.1/cs/Microsoft.CodeAnalysis.resources.dll": {
"locale": "cs"
},
"lib/net8.0/de/Microsoft.CodeAnalysis.resources.dll": {
"lib/netcoreapp3.1/de/Microsoft.CodeAnalysis.resources.dll": {
"locale": "de"
},
"lib/net8.0/es/Microsoft.CodeAnalysis.resources.dll": {
"lib/netcoreapp3.1/es/Microsoft.CodeAnalysis.resources.dll": {
"locale": "es"
},
"lib/net8.0/fr/Microsoft.CodeAnalysis.resources.dll": {
"lib/netcoreapp3.1/fr/Microsoft.CodeAnalysis.resources.dll": {
"locale": "fr"
},
"lib/net8.0/it/Microsoft.CodeAnalysis.resources.dll": {
"lib/netcoreapp3.1/it/Microsoft.CodeAnalysis.resources.dll": {
"locale": "it"
},
"lib/net8.0/ja/Microsoft.CodeAnalysis.resources.dll": {
"lib/netcoreapp3.1/ja/Microsoft.CodeAnalysis.resources.dll": {
"locale": "ja"
},
"lib/net8.0/ko/Microsoft.CodeAnalysis.resources.dll": {
"lib/netcoreapp3.1/ko/Microsoft.CodeAnalysis.resources.dll": {
"locale": "ko"
},
"lib/net8.0/pl/Microsoft.CodeAnalysis.resources.dll": {
"lib/netcoreapp3.1/pl/Microsoft.CodeAnalysis.resources.dll": {
"locale": "pl"
},
"lib/net8.0/pt-BR/Microsoft.CodeAnalysis.resources.dll": {
"lib/netcoreapp3.1/pt-BR/Microsoft.CodeAnalysis.resources.dll": {
"locale": "pt-BR"
},
"lib/net8.0/ru/Microsoft.CodeAnalysis.resources.dll": {
"lib/netcoreapp3.1/ru/Microsoft.CodeAnalysis.resources.dll": {
"locale": "ru"
},
"lib/net8.0/tr/Microsoft.CodeAnalysis.resources.dll": {
"lib/netcoreapp3.1/tr/Microsoft.CodeAnalysis.resources.dll": {
"locale": "tr"
},
"lib/net8.0/zh-Hans/Microsoft.CodeAnalysis.resources.dll": {
"lib/netcoreapp3.1/zh-Hans/Microsoft.CodeAnalysis.resources.dll": {
"locale": "zh-Hans"
},
"lib/net8.0/zh-Hant/Microsoft.CodeAnalysis.resources.dll": {
"lib/netcoreapp3.1/zh-Hant/Microsoft.CodeAnalysis.resources.dll": {
"locale": "zh-Hant"
}
}
},
"Microsoft.CodeAnalysis.CSharp/4.11.0": {
"Microsoft.CodeAnalysis.CSharp/3.11.0": {
"dependencies": {
"Microsoft.CodeAnalysis.Common": "4.11.0"
"Microsoft.CodeAnalysis.Common": "3.11.0"
},
"runtime": {
"lib/net8.0/Microsoft.CodeAnalysis.CSharp.dll": {
"assemblyVersion": "4.11.0.0",
"fileVersion": "4.1100.24.37604"
"lib/netcoreapp3.1/Microsoft.CodeAnalysis.CSharp.dll": {
"assemblyVersion": "3.11.0.0",
"fileVersion": "3.1100.21.40306"
}
},
"resources": {
"lib/net8.0/cs/Microsoft.CodeAnalysis.CSharp.resources.dll": {
"lib/netcoreapp3.1/cs/Microsoft.CodeAnalysis.CSharp.resources.dll": {
"locale": "cs"
},
"lib/net8.0/de/Microsoft.CodeAnalysis.CSharp.resources.dll": {
"lib/netcoreapp3.1/de/Microsoft.CodeAnalysis.CSharp.resources.dll": {
"locale": "de"
},
"lib/net8.0/es/Microsoft.CodeAnalysis.CSharp.resources.dll": {
"lib/netcoreapp3.1/es/Microsoft.CodeAnalysis.CSharp.resources.dll": {
"locale": "es"
},
"lib/net8.0/fr/Microsoft.CodeAnalysis.CSharp.resources.dll": {
"lib/netcoreapp3.1/fr/Microsoft.CodeAnalysis.CSharp.resources.dll": {
"locale": "fr"
},
"lib/net8.0/it/Microsoft.CodeAnalysis.CSharp.resources.dll": {
"lib/netcoreapp3.1/it/Microsoft.CodeAnalysis.CSharp.resources.dll": {
"locale": "it"
},
"lib/net8.0/ja/Microsoft.CodeAnalysis.CSharp.resources.dll": {
"lib/netcoreapp3.1/ja/Microsoft.CodeAnalysis.CSharp.resources.dll": {
"locale": "ja"
},
"lib/net8.0/ko/Microsoft.CodeAnalysis.CSharp.resources.dll": {
"lib/netcoreapp3.1/ko/Microsoft.CodeAnalysis.CSharp.resources.dll": {
"locale": "ko"
},
"lib/net8.0/pl/Microsoft.CodeAnalysis.CSharp.resources.dll": {
"lib/netcoreapp3.1/pl/Microsoft.CodeAnalysis.CSharp.resources.dll": {
"locale": "pl"
},
"lib/net8.0/pt-BR/Microsoft.CodeAnalysis.CSharp.resources.dll": {
"lib/netcoreapp3.1/pt-BR/Microsoft.CodeAnalysis.CSharp.resources.dll": {
"locale": "pt-BR"
},
"lib/net8.0/ru/Microsoft.CodeAnalysis.CSharp.resources.dll": {
"lib/netcoreapp3.1/ru/Microsoft.CodeAnalysis.CSharp.resources.dll": {
"locale": "ru"
},
"lib/net8.0/tr/Microsoft.CodeAnalysis.CSharp.resources.dll": {
"lib/netcoreapp3.1/tr/Microsoft.CodeAnalysis.CSharp.resources.dll": {
"locale": "tr"
},
"lib/net8.0/zh-Hans/Microsoft.CodeAnalysis.CSharp.resources.dll": {
"lib/netcoreapp3.1/zh-Hans/Microsoft.CodeAnalysis.CSharp.resources.dll": {
"locale": "zh-Hans"
},
"lib/net8.0/zh-Hant/Microsoft.CodeAnalysis.CSharp.resources.dll": {
"lib/netcoreapp3.1/zh-Hant/Microsoft.CodeAnalysis.CSharp.resources.dll": {
"locale": "zh-Hant"
}
}
},
"Microsoft.CodeAnalysis.Workspaces.Common/3.11.0": {
"dependencies": {
"Humanizer.Core": "2.2.0",
"Microsoft.Bcl.AsyncInterfaces": "5.0.0",
"Microsoft.CodeAnalysis.Common": "3.11.0",
"System.Composition": "1.0.31",
"System.IO.Pipelines": "5.0.1"
},
"runtime": {
"lib/netcoreapp3.1/Microsoft.CodeAnalysis.Workspaces.dll": {
"assemblyVersion": "3.11.0.0",
"fileVersion": "3.1100.21.40306"
}
},
"resources": {
"lib/netcoreapp3.1/cs/Microsoft.CodeAnalysis.Workspaces.resources.dll": {
"locale": "cs"
},
"lib/netcoreapp3.1/de/Microsoft.CodeAnalysis.Workspaces.resources.dll": {
"locale": "de"
},
"lib/netcoreapp3.1/es/Microsoft.CodeAnalysis.Workspaces.resources.dll": {
"locale": "es"
},
"lib/netcoreapp3.1/fr/Microsoft.CodeAnalysis.Workspaces.resources.dll": {
"locale": "fr"
},
"lib/netcoreapp3.1/it/Microsoft.CodeAnalysis.Workspaces.resources.dll": {
"locale": "it"
},
"lib/netcoreapp3.1/ja/Microsoft.CodeAnalysis.Workspaces.resources.dll": {
"locale": "ja"
},
"lib/netcoreapp3.1/ko/Microsoft.CodeAnalysis.Workspaces.resources.dll": {
"locale": "ko"
},
"lib/netcoreapp3.1/pl/Microsoft.CodeAnalysis.Workspaces.resources.dll": {
"locale": "pl"
},
"lib/netcoreapp3.1/pt-BR/Microsoft.CodeAnalysis.Workspaces.resources.dll": {
"locale": "pt-BR"
},
"lib/netcoreapp3.1/ru/Microsoft.CodeAnalysis.Workspaces.resources.dll": {
"locale": "ru"
},
"lib/netcoreapp3.1/tr/Microsoft.CodeAnalysis.Workspaces.resources.dll": {
"locale": "tr"
},
"lib/netcoreapp3.1/zh-Hans/Microsoft.CodeAnalysis.Workspaces.resources.dll": {
"locale": "zh-Hans"
},
"lib/netcoreapp3.1/zh-Hant/Microsoft.CodeAnalysis.Workspaces.resources.dll": {
"locale": "zh-Hant"
}
}
},
"System.Composition/1.0.31": {
"dependencies": {
"System.Composition.AttributedModel": "1.0.31",
"System.Composition.Convention": "1.0.31",
"System.Composition.Hosting": "1.0.31",
"System.Composition.Runtime": "1.0.31",
"System.Composition.TypedParts": "1.0.31"
}
},
"System.Composition.AttributedModel/1.0.31": {
"runtime": {
"lib/netstandard1.0/System.Composition.AttributedModel.dll": {
"assemblyVersion": "1.0.31.0",
"fileVersion": "4.6.24705.1"
}
}
},
"System.Composition.Convention/1.0.31": {
"dependencies": {
"System.Composition.AttributedModel": "1.0.31"
},
"runtime": {
"lib/netstandard1.0/System.Composition.Convention.dll": {
"assemblyVersion": "1.0.31.0",
"fileVersion": "4.6.24705.1"
}
}
},
"System.Composition.Hosting/1.0.31": {
"dependencies": {
"System.Composition.Runtime": "1.0.31"
},
"runtime": {
"lib/netstandard1.0/System.Composition.Hosting.dll": {
"assemblyVersion": "1.0.31.0",
"fileVersion": "4.6.24705.1"
}
}
},
"System.Composition.Runtime/1.0.31": {
"runtime": {
"lib/netstandard1.0/System.Composition.Runtime.dll": {
"assemblyVersion": "1.0.31.0",
"fileVersion": "4.6.24705.1"
}
}
},
"System.Composition.TypedParts/1.0.31": {
"dependencies": {
"System.Composition.AttributedModel": "1.0.31",
"System.Composition.Hosting": "1.0.31",
"System.Composition.Runtime": "1.0.31"
},
"runtime": {
"lib/netstandard1.0/System.Composition.TypedParts.dll": {
"assemblyVersion": "1.0.31.0",
"fileVersion": "4.6.24705.1"
}
}
},
"System.IO.Pipelines/5.0.1": {
"runtime": {
"lib/netcoreapp3.0/System.IO.Pipelines.dll": {
"assemblyVersion": "5.0.0.1",
"fileVersion": "5.0.120.57516"
}
}
},
"Cthangover/1.0.0": {
"dependencies": {
"GodotSharp": "4.6.3",
"GodotSharpEditor": "4.6.3",
"Microsoft.CodeAnalysis.CSharp": "4.11.0"
"Microsoft.CodeAnalysis.CSharp": "3.11.0",
"Microsoft.CodeAnalysis.Workspaces.Common": "3.11.0"
},
"runtime": {
"Cthangover.dll": {
"assemblyVersion": "0.0.0.0",
"fileVersion": "0.0.0.0"
"assemblyVersion": "1.0.0.0",
"fileVersion": "1.0.0.0"
}
}
}
@@ -169,19 +310,89 @@
"path": "godotsharpeditor/4.6.3",
"hashPath": "godotsharpeditor.4.6.3.nupkg.sha512"
},
"Microsoft.CodeAnalysis.Common/4.11.0": {
"Humanizer.Core/2.2.0": {
"type": "package",
"serviceable": true,
"sha512": "sha512-djf8ujmqYImFgB04UGtcsEhHrzVqzHowS+EEl/Yunc5LdrYrZhGBWUTXoCF0NzYXJxtfuD+UVQarWpvrNc94Qg==",
"path": "microsoft.codeanalysis.common/4.11.0",
"hashPath": "microsoft.codeanalysis.common.4.11.0.nupkg.sha512"
"sha512": "sha512-rsYXB7+iUPP8AHgQ8JP2UZI2xK2KhjcdGr9E6zX3CsZaTLCaw8M35vaAJRo1rfxeaZEVMuXeaquLVCkZ7JcZ5Q==",
"path": "humanizer.core/2.2.0",
"hashPath": "humanizer.core.2.2.0.nupkg.sha512"
},
"Microsoft.CodeAnalysis.CSharp/4.11.0": {
"Microsoft.Bcl.AsyncInterfaces/5.0.0": {
"type": "package",
"serviceable": true,
"sha512": "sha512-6XYi2EusI8JT4y2l/F3VVVS+ISoIX9nqHsZRaG6W5aFeJ5BEuBosHfT/ABb73FN0RZ1Z3cj2j7cL28SToJPXOw==",
"path": "microsoft.codeanalysis.csharp/4.11.0",
"hashPath": "microsoft.codeanalysis.csharp.4.11.0.nupkg.sha512"
"sha512": "sha512-W8DPQjkMScOMTtJbPwmPyj9c3zYSFGawDW3jwlBOOsnY+EzZFLgNQ/UMkK35JmkNOVPdCyPr2Tw7Vv9N+KA3ZQ==",
"path": "microsoft.bcl.asyncinterfaces/5.0.0",
"hashPath": "microsoft.bcl.asyncinterfaces.5.0.0.nupkg.sha512"
},
"Microsoft.CodeAnalysis.Common/3.11.0": {
"type": "package",
"serviceable": true,
"sha512": "sha512-FDKSkRRXnaEWMa2ONkLMo0ZAt/uiV1XIXyodwKIgP1AMIKA7JJKXx/OwFVsvkkUT4BeobLwokoxFw70fICahNg==",
"path": "microsoft.codeanalysis.common/3.11.0",
"hashPath": "microsoft.codeanalysis.common.3.11.0.nupkg.sha512"
},
"Microsoft.CodeAnalysis.CSharp/3.11.0": {
"type": "package",
"serviceable": true,
"sha512": "sha512-aDRRb7y/sXoJyDqFEQ3Il9jZxyUMHkShzZeCRjQf3SS84n2J0cTEi3TbwVZE9XJvAeMJhGfVVxwOdjYBg6ljmw==",
"path": "microsoft.codeanalysis.csharp/3.11.0",
"hashPath": "microsoft.codeanalysis.csharp.3.11.0.nupkg.sha512"
},
"Microsoft.CodeAnalysis.Workspaces.Common/3.11.0": {
"type": "package",
"serviceable": true,
"sha512": "sha512-YAbH4LCJfh8DhDGwYzSHqvnF06lKkVwblr8C+GwIYCv0i3Rzqjnbversat+i2n9k8twQ43yxVGTYK5p/mIOj4w==",
"path": "microsoft.codeanalysis.workspaces.common/3.11.0",
"hashPath": "microsoft.codeanalysis.workspaces.common.3.11.0.nupkg.sha512"
},
"System.Composition/1.0.31": {
"type": "package",
"serviceable": true,
"sha512": "sha512-I+D26qpYdoklyAVUdqwUBrEIckMNjAYnuPJy/h9dsQItpQwVREkDFs4b4tkBza0kT2Yk48Lcfsv2QQ9hWsh9Iw==",
"path": "system.composition/1.0.31",
"hashPath": "system.composition.1.0.31.nupkg.sha512"
},
"System.Composition.AttributedModel/1.0.31": {
"type": "package",
"serviceable": true,
"sha512": "sha512-NHWhkM3ZkspmA0XJEsKdtTt1ViDYuojgSND3yHhTzwxepiwqZf+BCWuvCbjUt4fe0NxxQhUDGJ5km6sLjo9qnQ==",
"path": "system.composition.attributedmodel/1.0.31",
"hashPath": "system.composition.attributedmodel.1.0.31.nupkg.sha512"
},
"System.Composition.Convention/1.0.31": {
"type": "package",
"serviceable": true,
"sha512": "sha512-GLjh2Ju71k6C0qxMMtl4efHa68NmWeIUYh4fkUI8xbjQrEBvFmRwMDFcylT8/PR9SQbeeL48IkFxU/+gd0nYEQ==",
"path": "system.composition.convention/1.0.31",
"hashPath": "system.composition.convention.1.0.31.nupkg.sha512"
},
"System.Composition.Hosting/1.0.31": {
"type": "package",
"serviceable": true,
"sha512": "sha512-fN1bT4RX4vUqjbgoyuJFVUizAl2mYF5VAb+bVIxIYZSSc0BdnX+yGAxcavxJuDDCQ1K+/mdpgyEFc8e9ikjvrg==",
"path": "system.composition.hosting/1.0.31",
"hashPath": "system.composition.hosting.1.0.31.nupkg.sha512"
},
"System.Composition.Runtime/1.0.31": {
"type": "package",
"serviceable": true,
"sha512": "sha512-0LEJN+2NVM89CE4SekDrrk5tHV5LeATltkp+9WNYrR+Huiyt0vaCqHbbHtVAjPyeLWIc8dOz/3kthRBj32wGQg==",
"path": "system.composition.runtime/1.0.31",
"hashPath": "system.composition.runtime.1.0.31.nupkg.sha512"
},
"System.Composition.TypedParts/1.0.31": {
"type": "package",
"serviceable": true,
"sha512": "sha512-0Zae/FtzeFgDBBuILeIbC/T9HMYbW4olAmi8XqqAGosSOWvXfiQLfARZEhiGd0LVXaYgXr0NhxiU1LldRP1fpQ==",
"path": "system.composition.typedparts/1.0.31",
"hashPath": "system.composition.typedparts.1.0.31.nupkg.sha512"
},
"System.IO.Pipelines/5.0.1": {
"type": "package",
"serviceable": true,
"sha512": "sha512-qEePWsaq9LoEEIqhbGe6D5J8c9IqQOUuTzzV6wn1POlfdLkJliZY3OlB0j0f17uMWlqZYjH7txj+2YbyrIA8Yg==",
"path": "system.io.pipelines/5.0.1",
"hashPath": "system.io.pipelines.5.0.1.nupkg.sha512"
},
"Cthangover/1.0.0": {
"type": "project",
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+2
View File
@@ -0,0 +1,2 @@
mod_live2d_name = Live2D Cubism
mod_live2d_desc = Live2D Cubism C# runtime: model loading, rendering, physics, animation, effects.
+2
View File
@@ -0,0 +1,2 @@
mod_live2d_name = Live2D Cubism
mod_live2d_desc = C#-рантайм Live2D Cubism: загрузка и отрисовка моделей, физика, анимации и эффекты.
+2 -3
View File
@@ -5,7 +5,6 @@
"author": "ct",
"description": "Live2D Cubism C# runtime: model loading, rendering, physics, animation, effects",
"category": "library, visual",
"depends": ["visual_novel_core>=1"],
"depends_native": ["native/*.dll"],
"sources": ["src/*.cs"]
"depends": ["visual_novel_core>=1", "interface>=1"],
"depends_native": ["native/*.dll", "native/*.so"]
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.
Binary file not shown.
@@ -71,6 +71,19 @@ namespace Cthangover.Live2D.Cubism.Core
[DllImport(DllName, CallingConvention = CallingConvention.StdCall)]
public static extern unsafe IntPtr csmReviveMocInPlace(void* address, uint size);
/// <summary>Native log callback: receives a pointer to a NUL-terminated csmChar* message.</summary>
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
public delegate void CsmLogDelegate(IntPtr messagePtr);
/// <summary>
/// Installs the Core log handler. Without it every native-side failure
/// (invalid moc3, unsupported version, alignment errors) is invisible.
/// Keep the delegate in a static field for the process lifetime — the
/// Core stores the raw function pointer.
/// </summary>
[DllImport(DllName, CallingConvention = CallingConvention.StdCall)]
public static extern void csmSetLogFunction(CsmLogDelegate logFunction);
/// <summary>Queries the memory size needed to initialize a model from the given moc.</summary>
[DllImport(DllName, CallingConvention = CallingConvention.StdCall)]
public static extern unsafe uint csmGetSizeofModel(IntPtr moc);
+92 -47
View File
@@ -1,17 +1,21 @@
using System;
using System.Collections.Generic;
namespace Cthangover.Live2D.Cubism.Framework
{
/// <summary>
/// Abstract base for all Live2D Cubism motions (animations and expressions).
/// Provides fade-in/fade-out timing, looping, weight blending, and event
/// callbacks shared across <see cref="CubismMotion"/> and
/// <see cref="CubismExpressionMotion"/>.
/// Порт ACubismMotion из Cubism SDK for Native 5-r.5: владеет временами фейда,
/// весом, цикличностью и колбэками; очередь (<see cref="CubismMotionQueueManager"/>)
/// вызывает <see cref="UpdateParameters"/> раз в кадр, тот в свою очередь —
/// <see cref="SetupMotionQueueEntry"/> (при старте), <see cref="UpdateFadeWeight"/>
/// и виртуальный <see cref="DoUpdateParameters"/>.
///
/// Motions are managed by <see cref="CubismMotionQueueManager"/>, which
/// calls <see cref="SetupMotionQueueEntry"/> on start, then
/// <see cref="UpdateFadeWeight"/> and <see cref="DoUpdateParameters"/>
/// every frame.
/// Завершение выставляется по EndTime: сам моушен ставит IsFinished в
/// DoUpdateParameters (незацикленный), а обёртка <see cref="UpdateParameters"/>
/// после применения параметров закрывает запись, когда текущее время прошло EndTime
/// (например, после запущенного через очередь fade-out). Колбэки при этом не
/// дублируются: OnFinishedMotion вызывается только самим моушеном.
/// </summary>
public abstract class ACubismMotion
{
@@ -36,6 +40,9 @@ namespace Cthangover.Live2D.Cubism.Framework
protected bool _isLoopFadeIn = true;
protected bool _previousLoopState;
/// <summary>Сработавшие события (UserData); наполняется в <see cref="GetFiredEvent"/>.</summary>
protected readonly List<string> _firedEventValues = new();
/// <summary>
/// Duration of the fade-in transition in seconds.
/// A negative value means the default (1 second) will be used
@@ -75,8 +82,8 @@ namespace Cthangover.Live2D.Cubism.Framework
/// <summary>
/// Invoked when the motion finishes (non-looping only).
/// Automatically called by <see cref="CubismMotion.DoUpdateParameters"/>
/// when timeOffset exceeds duration.
/// Автоматически вызывается самим моушеном в DoUpdateParameters по достижении
/// длительности; очередь этот колбэк не дублирует.
/// </summary>
public FinishedMotionCallback OnFinishedMotion { get; set; }
@@ -101,73 +108,111 @@ namespace Cthangover.Live2D.Cubism.Framework
public virtual float GetLoopDuration() => -1f;
/// <summary>
/// Initializes a queue entry with start/end times based on current
/// <see cref="FadeInSeconds"/>, <see cref="OffsetSeconds"/>,
/// and <see cref="GetDuration"/>. Fires <see cref="OnBeganMotion"/>.
/// Порт ACubismMotion::UpdateParameters — кадровая обёртка, вызываемая очередью.
/// Проверяет доступность записи, инициализирует её при старте, считает fade-вес,
/// применяет параметры и после этого закрывает запись, если время прошло EndTime.
/// </summary>
public void UpdateParameters(CubismNativeModel model, CubismMotionQueueEntry entry, float userTimeSeconds)
{
if (!entry.IsAvailable || entry.IsFinished)
{
return;
}
SetupMotionQueueEntry(entry, userTimeSeconds);
float fadeWeight = UpdateFadeWeight(entry, userTimeSeconds);
DoUpdateParameters(model, userTimeSeconds, fadeWeight, entry);
// Пост-проверка SDK: EndTime > 0 и уже пройден — запись завершена.
if ((entry.EndTime > 0f) && (entry.EndTime < userTimeSeconds))
{
entry.IsFinished = true;
}
}
/// <summary>
/// Порт ACubismMotion::SetupMotionQueueEntry: при первом кадре записывает
/// StartTime (с учётом OffsetSeconds), FadeInStartTime и EndTime
/// (если EndTime ещё не выставлен извне, например фейдаутом от очереди).
/// </summary>
public void SetupMotionQueueEntry(CubismMotionQueueEntry entry, float userTimeSeconds)
{
var fadeIn = _fadeInSeconds >= 0f ? _fadeInSeconds : 1f;
if (!entry.IsAvailable || entry.IsFinished)
{
return;
}
if (entry.IsStarted)
{
return;
}
entry.IsStarted = true;
entry.StartTime = userTimeSeconds - _offsetSeconds;
entry.FadeInStartTime = userTimeSeconds;
var duration = GetDuration();
UpdateEndTime(entry, duration);
// EndTime мог быть выставлен заранее (SetFadeout до старта).
if (entry.EndTime < 0f)
{
AdjustEndTime(entry);
}
OnBeganMotion?.Invoke(this);
}
/// <summary>
/// Computes the final frame weight = <see cref="Weight"/> x fadeIn x fadeOut.
/// Fade curves use the sine easing function from <see cref="CubismMath.GetEasingSine"/>.
/// Порт ACubismMotion::UpdateFadeWeight: вес = Weight x fadeIn x fadeOut
/// с синус-эйзингом. При fadeIn/fadeOut == 0 соответствующий множитель = 1.
/// </summary>
public float UpdateFadeWeight(CubismMotionQueueEntry entry, float userTimeSeconds)
{
var fadeIn = GetFadeIn(entry, userTimeSeconds);
var fadeOut = GetFadeOut(entry, userTimeSeconds);
return _weight * fadeIn * fadeOut;
if (entry == null)
{
return -1f;
}
private float GetFadeIn(CubismMotionQueueEntry entry, float userTimeSeconds)
{
var fadeInTime = _fadeInSeconds >= 0f ? _fadeInSeconds : 1f;
if (fadeInTime <= 0f) return 1f;
var t = (userTimeSeconds - entry.FadeInStartTime) / fadeInTime;
return CubismMath.GetEasingSine(t);
}
float fadeWeight = _weight;
private float GetFadeOut(CubismMotionQueueEntry entry, float userTimeSeconds)
{
var fadeOutTime = _fadeOutSeconds >= 0f ? _fadeOutSeconds : 1f;
if (fadeOutTime <= 0f) return 1f;
var t = (entry.EndTime - userTimeSeconds) / fadeOutTime;
return CubismMath.GetEasingSine(t);
float fadeIn = _fadeInSeconds == 0f
? 1f
: CubismMath.GetEasingSine((userTimeSeconds - entry.FadeInStartTime) / _fadeInSeconds);
float fadeOut = (_fadeOutSeconds == 0f || entry.EndTime < 0f)
? 1f
: CubismMath.GetEasingSine((entry.EndTime - userTimeSeconds) / _fadeOutSeconds);
fadeWeight = fadeWeight * fadeIn * fadeOut;
entry.SetState(userTimeSeconds, fadeWeight);
return fadeWeight;
}
/// <summary>
/// Sets the queue entry's EndTime based on loop state and duration.
/// Looping motions get EndTime = -1 (infinite).
/// Порт ACubismMotion::AdjustEndTime: EndTime = StartTime + GetDuration();
/// для цикличных моушенов (GetDuration() <= 0) EndTime = -1 (бесконечность).
/// </summary>
protected void UpdateEndTime(CubismMotionQueueEntry entry, float duration)
protected void AdjustEndTime(CubismMotionQueueEntry entry)
{
entry.EndTime = _isLoop ? -1f : entry.StartTime + duration;
float duration = GetDuration();
float endTime = duration <= 0f ? -1f : entry.StartTime + duration;
entry.EndTime = endTime;
}
/// <summary>
/// Recalculates EndTime when <see cref="IsLoop"/> changes mid-playback.
/// Called by <see cref="CubismMotion.DoUpdateParameters"/> every frame
/// to detect loop state transitions.
/// Порт ACubismMotion::GetFiredEvent: базовая реализация не содержит событий.
/// <see cref="CubismMotion"/> переопределяет и наполняет список по UserData.
/// </summary>
protected void AdjustEndTime(CubismMotionQueueEntry entry, float duration)
public virtual List<string> GetFiredEvent(float beforeCheckTimeSeconds, float motionTimeSeconds)
{
if (_isLoop != _previousLoopState)
{
_previousLoopState = _isLoop;
UpdateEndTime(entry, duration);
}
return _firedEventValues;
}
/// <summary>
/// Applies motion parameter values to the model for the current frame.
/// Called every frame by <see cref="CubismMotionQueueManager.DoUpdateMotion"/>.
/// Called every frame by <see cref="ACubismMotion.UpdateParameters"/>.
/// </summary>
/// <param name="model">The native Cubism model to modify.</param>
/// <param name="userTimeSeconds">Elapsed user time in seconds.</param>
@@ -1,4 +1,3 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
@@ -31,18 +30,26 @@ namespace Cthangover.Live2D.Cubism.Framework
/// Expression motion, parsed from a Cubism .exp3.json file.
/// Inherits fade and weight management from <see cref="ACubismMotion"/>.
///
/// Expressions are single-frame poses that transition between values using
/// one of three blend modes per parameter:
/// Формулы применения — точный порт CubismExpressionMotion::DoUpdateParameters
/// через Set/Add/MultiplyParameterValue из CubismModel (SDK 5-r.5), weight — fade-вес:
/// <list type="bullet">
/// <item><see cref="ExpressionBlendType.Additive"/> — adds <c>value * weight</c></item>
/// <item><see cref="ExpressionBlendType.Multiply"/> — multiplies by <c>1 + value * weight</c></item>
/// <item><see cref="ExpressionBlendType.Overwrite"/> — lerps from current to target using weight</item>
/// <item><see cref="ExpressionBlendType.Additive"/> — <c>value + expValue * weight</c></item>
/// <item><see cref="ExpressionBlendType.Multiply"/> — <c>value * (1 + (expValue - 1) * weight)</c></item>
/// <item><see cref="ExpressionBlendType.Overwrite"/> — <c>value * (1 - weight) + expValue * weight</c></item>
/// </list>
///
/// Loaded and started by <see cref="CubismModelNode.StartExpression"/>.
/// Как и в SDK, результат зажимается в [min, max] параметра (порт
/// CubismModel::SetParameterValue), а параметры применяются в порядке их
/// перечисления в exp3.json: при нескольких записях с одним Id побеждает последняя.
/// Fade-время по умолчанию — 1 c (DefaultFadeTime), если в файле не задано.
///
/// Загружается и запускается через <see cref="CubismModelNode.StartExpression"/>.
/// </summary>
public class CubismExpressionMotion : ACubismMotion
{
/// <summary>Порт DefaultFadeTime из CubismExpressionMotion.cpp.</summary>
private const float DefaultFadeTime = 1f;
private readonly List<ExpressionParameter> _parameters = new();
/// <summary>
@@ -55,23 +62,23 @@ namespace Cthangover.Live2D.Cubism.Framework
var json = JsonDocument.Parse(jsonBytes);
var root = json.RootElement;
if (root.TryGetProperty("FadeInTime", out var fi))
_fadeInSeconds = fi.GetSingle();
if (root.TryGetProperty("FadeOutTime", out var fo))
_fadeOutSeconds = fo.GetSingle();
// Порт CubismExpressionMotion::Parse: root[FadeInTime].ToFloat(DefaultFadeTime).
_fadeInSeconds = root.TryGetProperty("FadeInTime", out var fi) && fi.ValueKind == JsonValueKind.Number ? fi.GetSingle() : DefaultFadeTime;
_fadeOutSeconds = root.TryGetProperty("FadeOutTime", out var fo) && fo.ValueKind == JsonValueKind.Number ? fo.GetSingle() : DefaultFadeTime;
if (root.TryGetProperty("Parameters", out var paramsArr))
{
foreach (var p in paramsArr.EnumerateArray())
{
var blend = p.TryGetProperty("Blend", out var b)
// "Blend": null or absent -> Additive (SDK ToBlendFunction default).
var blend = p.TryGetProperty("Blend", out var b) && b.ValueKind == JsonValueKind.String
? ParseBlendType(b.GetString())
: ExpressionBlendType.Additive;
_parameters.Add(new ExpressionParameter
{
ParameterId = p.GetProperty("Id").GetString(),
Value = p.GetProperty("Value").GetSingle(),
Value = p.TryGetProperty("Value", out var val) && val.ValueKind == JsonValueKind.Number ? val.GetSingle() : 0f,
BlendType = blend
});
}
@@ -96,30 +103,41 @@ namespace Cthangover.Live2D.Cubism.Framework
var values = model.GetParameterValues();
var minValues = model.GetParameterMinimumValues();
var maxValues = model.GetParameterMaximumValues();
var defaultValues = model.GetParameterDefaultValues();
for (int pi = 0; pi < model.ParameterCount; pi++)
{
var pid = CubismNativeModel.ReadStringFromPtrArray(idsPtr, pi);
var indexById = new Dictionary<string, int>(model.ParameterCount);
for (int i = 0; i < model.ParameterCount; i++)
indexById[CubismNativeModel.ReadStringFromPtrArray(idsPtr, i)] = i;
// Порт: цикл по параметрам выражения (в порядке exp3.json).
foreach (var ep in _parameters)
{
if (ep.ParameterId != pid) continue;
if (!indexById.TryGetValue(ep.ParameterId, out int pi)) continue;
switch (ep.BlendType)
{
case ExpressionBlendType.Additive:
values[pi] = values[pi] + ep.Value * weight;
// AddParameterValue: value + (expValue * weight)
values[pi] = ClampParameterValue(values[pi] + ep.Value * weight, minValues[pi], maxValues[pi]);
break;
case ExpressionBlendType.Multiply:
values[pi] = values[pi] * (1f + ep.Value * weight);
// MultiplyParameterValue: value * (1 + (expValue - 1) * weight)
values[pi] = ClampParameterValue(values[pi] * (1f + (ep.Value - 1f) * weight), minValues[pi], maxValues[pi]);
break;
case ExpressionBlendType.Overwrite:
values[pi] = values[pi] * (1f - weight) + ep.Value * weight;
break;
}
// SetParameterValue(id, value, weight): expValue зажимается, затем лерп
values[pi] = values[pi] * (1f - weight)
+ ClampParameterValue(ep.Value, minValues[pi], maxValues[pi]) * weight;
break;
}
}
}
/// <summary>Порт GetParameterClampValue — зажим значения параметра в [min, max].</summary>
private static float ClampParameterValue(float value, float min, float max)
{
if (value < min) return min;
if (value > max) return max;
return value;
}
}
}
+100 -68
View File
@@ -5,20 +5,29 @@ using Godot;
namespace Cthangover.Live2D.Cubism.Framework
{
/// <summary>
/// Manages the 2D transformation (scale, translation, alignment) for a
/// Cubism model rendered inside a Godot <see cref="Node2D"/>.
/// Port of CubismModelMatrix.cpp (Cubism SDK for Native 5-r.5).
/// Manages the 2D transformation (scale, translation, alignment) of a Cubism
/// model inside its logical coordinate space.
///
/// Works with two coordinate systems:
/// SDK semantics to be aware of:
/// <list type="bullet">
/// <item>Cubism canvas coordinates (the model's native coordinate space)</item>
/// <item>Godot node local coordinates (where the model is placed)</item>
/// <item><see cref="SetWidth"/> and <see cref="SetHeight"/> MULTIPLY the
/// current scale by target / canvas-size (they do not assign it),
/// exactly like the C++ original.</item>
/// <item>The (width, height) constructor performs
/// <c>SetHeight(2.0f)</c> like the SDK, i.e. the model is initially
/// scaled to occupy 2 logical units of height.</item>
/// <item>Translation methods are INCREMENTAL in the SDK: SetX/SetY,
/// SetPosition, Top, Left (via TranslateX/Y) add to the current
/// translation. SetupFromLayout is unaffected because it starts from a
/// fresh matrix (translation 0).</item>
/// <item>CenterX/CenterY/Bottom/Right compute their offset from the full
/// scaled size (canvas size × current scale).</item>
/// </list>
///
/// The scale is computed relative to the canvas dimensions stored at construction.
/// <see cref="SetWidth"/> and <see cref="SetHeight"/> set a uniform scale based
/// on the ratio of target size to canvas size. <see cref="SetupFromLayout"/> reads
/// positioning keys from a Cubism .model3.json "Layout" section.
/// <see cref="ApplyToNode"/> writes the final transform to a Godot Node2D.
/// <see cref="SetupFromLayout"/> reads positioning keys from a Cubism
/// .model3.json "Layout" section; <see cref="ApplyToNode"/> writes the final
/// transform to a Godot <see cref="Node2D"/>.
/// </summary>
public class CubismModelMatrix
{
@@ -33,68 +42,80 @@ namespace Cthangover.Live2D.Cubism.Framework
public CubismModelMatrix() { }
/// <summary>
/// Creates a matrix referenced to the given canvas pixel dimensions.
/// Subsequent calls to <see cref="SetWidth"/> and <see cref="SetHeight"/>
/// will compute scale as target / canvas.
/// Creates a matrix referenced to the given canvas dimensions.
/// Like the SDK constructor, this calls <see cref="SetHeight"/>(2.0f),
/// so the model initially occupies 2 logical units of height.
/// </summary>
public CubismModelMatrix(float width, float height)
{
_width = width;
_height = height;
SetHeight(2.0f);
}
/// <summary>Current uniform scale on the X axis.</summary>
/// <summary>Current scale on the X axis.</summary>
public float ScaleX => _scaleX;
/// <summary>Current uniform scale on the Y axis.</summary>
/// <summary>Current scale on the Y axis.</summary>
public float ScaleY => _scaleY;
/// <summary>Current X translation offset in Godot local coordinates.</summary>
/// <summary>Current X translation offset.</summary>
public float TranslateX => _translateX;
/// <summary>Current Y translation offset in Godot local coordinates.</summary>
/// <summary>Current Y translation offset.</summary>
public float TranslateY => _translateY;
/// <summary>SDK CubismModelMatrix::Scale — multiplies the current scale.</summary>
public void Scale(float scaleX, float scaleY)
{
_scaleX *= scaleX;
_scaleY *= scaleY;
}
/// <summary>
/// Sets uniform scale to match the given width. Scale = targetWidth / canvasWidth.
/// Does nothing if canvas width is zero or unknown.
/// SDK SetWidth: multiplies the current uniform scale by w / canvasWidth.
/// Ignored when the canvas width is zero or unknown (the SDK would divide by zero).
/// </summary>
public void SetWidth(float w)
{
if (_width > 0f)
{
var scale = w / _width;
_scaleX = scale;
_scaleY = scale;
}
if (_width <= 0f) return;
var scaleX = w / _width;
var scaleY = scaleX;
Scale(scaleX, scaleY);
}
/// <summary>
/// Sets uniform scale to match the given height. Scale = targetHeight / canvasHeight.
/// Does nothing if canvas height is zero or unknown.
/// SDK SetHeight: multiplies the current uniform scale by h / canvasHeight.
/// Ignored when the canvas height is zero or unknown (the SDK would divide by zero).
/// </summary>
public void SetHeight(float h)
{
if (_height > 0f)
if (_height <= 0f) return;
var scaleX = h / _height;
var scaleY = scaleX;
Scale(scaleX, scaleY);
}
/// <summary>SDK Translate — adds x and y to the current translation
/// (the SDK's TranslateX/TranslateY helpers are folded in here; the
/// TranslateX/TranslateY properties above are the read accessors).</summary>
public void Translate(float x, float y)
{
var scale = h / _height;
_scaleX = scale;
_scaleY = scale;
}
_translateX += x;
_translateY += y;
}
/// <summary>Sets absolute translation position.</summary>
public void SetPosition(float x, float y)
{
_translateX = x;
_translateY = y;
}
/// <summary>SDK SetPosition — incremental (adds to the current translation).</summary>
public void SetPosition(float x, float y) => Translate(x, y);
/// <summary>Sets absolute X translation.</summary>
public void SetX(float x) => _translateX = x;
/// <summary>SDK SetX — incremental (adds to the current X translation).</summary>
public void SetX(float x) => _translateX += x;
/// <summary>Sets absolute Y translation.</summary>
public void SetY(float y) => _translateY = y;
/// <summary>SDK SetY — incremental (adds to the current Y translation).</summary>
public void SetY(float y) => _translateY += y;
/// <summary>
/// Centers the model horizontally and vertically at the given point.
@@ -106,34 +127,45 @@ namespace Cthangover.Live2D.Cubism.Framework
CenterY(y);
}
/// <summary>
/// Centers horizontally: translateX = x - (width * scaleX) / 2.
/// </summary>
public void CenterX(float x) => _translateX = x - (_width * _scaleX) / 2f;
/// <summary>SDK CenterX — adds x - (width × scaleX) / 2 to the X translation.</summary>
public void CenterX(float x)
{
var w = _width * _scaleX;
_translateX += x - (w / 2f);
}
/// <summary>SDK CenterY — adds y - (height × scaleY) / 2 to the Y translation.</summary>
public void CenterY(float y)
{
var h = _height * _scaleY;
_translateY += y - (h / 2f);
}
/// <summary>SDK Top — adds y to the Y translation (via SetY).</summary>
public void Top(float y) => SetY(y);
/// <summary>SDK Bottom — adds y - (height × scaleY) to the Y translation.</summary>
public void Bottom(float y)
{
var h = _height * _scaleY;
_translateY += y - h;
}
/// <summary>SDK Left — adds x to the X translation (via SetX).</summary>
public void Left(float x) => SetX(x);
/// <summary>SDK Right — adds x - (width × scaleX) to the X translation.</summary>
public void Right(float x)
{
var w = _width * _scaleX;
_translateX += x - w;
}
/// <summary>
/// Centers vertically: translateY = y - (height * scaleY) / 2.
/// </summary>
public void CenterY(float y) => _translateY = y - (_height * _scaleY) / 2f;
/// <summary>Aligns the top edge of the model to y.</summary>
public void Top(float y) => _translateY = y;
/// <summary>Aligns the bottom edge of the model to y.</summary>
public void Bottom(float y) => _translateY = y - _height * _scaleY;
/// <summary>Aligns the left edge of the model to x.</summary>
public void Left(float x) => _translateX = x;
/// <summary>Aligns the right edge of the model to x.</summary>
public void Right(float x) => _translateX = x - _width * _scaleX;
/// <summary>
/// Configures scale and position from a layout dictionary.
/// Supported keys: "width", "height", "x", "y", "center_x", "center_y",
/// "top", "bottom", "left", "right".
/// Width/height are applied first (since they affect scale),
/// then position keys are applied.
/// SDK SetupFromLayout — configures scale and position from a layout
/// dictionary (lowercased keys). Width/height are applied first (they
/// affect scale), then the position keys: "x", "y", "center_x",
/// "center_y", "top", "bottom", "left", "right".
/// </summary>
public void SetupFromLayout(Dictionary<string, float> layout)
{
+228 -49
View File
@@ -2,6 +2,7 @@ using Cthangover.Core.Mods;
using Cthangover.Core.UI.Event;
using Cthangover.Core.Utils;
using Godot;
using System.Collections.Generic;
namespace Cthangover.Live2D.Cubism.Framework
{
@@ -36,6 +37,11 @@ namespace Cthangover.Live2D.Cubism.Framework
private CubismRenderer2D _renderer;
private CubismModelSettingJson _modelSetting;
// The autoload event controller this node registered its OnUpdate with.
// Kept so _ExitTree can unregister: the controller outlives the scene,
// and a stale registration would keep dispatching into a freed node.
private SceneEventController _eventController;
private CubismEyeBlink _eyeBlink;
private CubismBreath _breath;
private CubismPose _pose;
@@ -53,7 +59,29 @@ namespace Cthangover.Live2D.Cubism.Framework
private string _modelDir;
private IModFileProvider _fileProvider;
private bool _layoutLogged;
private readonly System.Collections.Generic.HashSet<string> _manualParameters = new();
// Parameters claimed by SetLiveParameter with their last written values.
// Re-applied after the Load/Save snapshot bracket every frame, so manual
// control survives the per-frame parameter restore.
private readonly System.Collections.Generic.Dictionary<string, float> _manualValues = new();
// Cache of parameter index by id, built once after model load — hot loops
// (face-track injection, SetLiveParameter) resolve by index without
// per-frame Marshal.PtrToStringAnsi / string comparisons.
private readonly Dictionary<string, int> _parameterIndexById = new();
private FaceTrackEntry[] _faceTrackCache = System.Array.Empty<FaceTrackEntry>();
private readonly List<string> _eyeBlinkIds = new();
private readonly List<string> _lipSyncIds = new();
/// <summary>One precomputed face-track injection: values[index] = targetAxis * scale.</summary>
private struct FaceTrackEntry
{
public int Index;
public string Id;
public bool FromTargetX;
public float Scale;
}
/// <summary>
/// All model parameters with their IDs, bounds, and current values.
@@ -178,6 +206,13 @@ namespace Cthangover.Live2D.Cubism.Framework
_nativeModel = new CubismNativeModel();
_nativeModel.LoadFromProvider(jsonBytes, _modelDir, _fileProvider);
if (!_nativeModel.IsLoaded)
{
// LoadFromProvider logs failures (missing moc, invalid JSON) and returns
// without a native model — FinishSetup would P/Invoke on IntPtr.Zero.
GameLogger.Log("LIVE2D", $"LoadModelFromMod: native model load failed for '{relativePath}'", LogLevel.Error);
return;
}
FinishSetup(jsonBytes);
}
@@ -202,7 +237,10 @@ namespace Cthangover.Live2D.Cubism.Framework
var ec = GetNodeOrNull<SceneEventController>("/root/EventController");
if (ec != null)
{
_eventController = ec;
ec.AddUpdateEventListener(this);
}
GameLogger.Log("LIVE2D", $"Model loaded: {_nativeModel.ParameterCount} params, {_nativeModel.DrawableCount} drawables, canvas={_nativeModel.CanvasWidth}x{_nativeModel.CanvasHeight}");
}
@@ -211,15 +249,17 @@ namespace Cthangover.Live2D.Cubism.Framework
/// <summary>
/// Per-frame update called by the SceneEventController.
/// Execution order:
/// Execution order (SDK LAppModel::Update):
/// <list type="number">
/// <item>Apply deferred layout (first valid frame only)</item>
/// <item>Update <see cref="_targetPoint"/> for face tracking</item>
/// <item>Inject face-tracking parameters (ParamAngleX/Y, ParamEyeBallX/Y)
/// into the native model</item>
/// <item>Restore the parameter snapshot (<c>LoadParameters</c>)</item>
/// <item>Evaluate motion queue (character animations)</item>
/// <item>Evaluate expression queue (facial expressions)</item>
/// <item>Run effect layers in order: breath, eye blink, pose, physics</item>
/// <item>Save the parameter snapshot (<c>SaveParameters</c>) — additive layers
/// (expressions, breath, face-track) start every frame from the same
/// baseline and never compound frame-over-frame</item>
/// <item>Run effect layers in SDK 5 scheduler order (ICubismUpdater.hpp):
/// eye blink → expression → look/face-track → breath → physics → pose</item>
/// <item>Call <c>csmUpdateModel</c> on the native model</item>
/// <item>Update the renderer (visibility, opacity, vertex positions)</item>
/// </list>
@@ -227,6 +267,10 @@ namespace Cthangover.Live2D.Cubism.Framework
public void OnUpdate()
{
if (!_loaded) return;
// If the node was freed while detached from the tree, _ExitTree never
// ran and the stale registration survives: bail out before any Godot
// API call on the disposed meshes (ObjectDisposedException).
if (!GodotObject.IsInstanceValid(this) || !IsInsideTree()) return;
var now = Time.GetTicksMsec();
var dt = _lastTickMsec == 0 ? 0.016f : (now - _lastTickMsec) / 1000f;
@@ -237,45 +281,108 @@ namespace Cthangover.Live2D.Cubism.Framework
_userTimeSeconds += dt;
_targetPoint.Update(dt);
var tx = _targetPoint.X;
var ty = _targetPoint.Y;
unsafe
{
var values = _nativeModel.GetParameterValues();
var idsPtr = _nativeModel.GetParameterIds();
for (int i = 0; i < _nativeModel.ParameterCount; i++)
{
var id = CubismNativeModel.ReadStringFromPtrArray(idsPtr, i);
if (_manualParameters.Contains(id)) continue;
switch (id)
{
case "ParamAngleX": values[i] = tx * 30f; break;
case "ParamAngleY": values[i] = ty * 30f; break;
case "ParamEyeBallX": values[i] = tx; break;
case "ParamEyeBallY": values[i] = ty; break;
}
}
}
// LAppModel.cpp:388-398 — Load/SaveParameters bracket around the motion
// phase. Without it, Additive/Multiply expressions and breath deltas
// accumulate frame-over-frame and drive parameters into their clamps.
_nativeModel.LoadParameters();
_motionManager.DoUpdateMotion(_nativeModel, _userTimeSeconds);
_expressionManager.DoUpdateMotion(_nativeModel, _userTimeSeconds);
_breath?.UpdateParameters(_nativeModel, dt);
_nativeModel.SaveParameters();
// Manual values survive the snapshot bracket: LoadParameters restores the
// pre-motion baseline and would otherwise silently revert SetLiveParameter.
ReapplyManualParameters();
// SDK 5 update-scheduler order (ICubismUpdater.hpp):
// EyeBlink(200) → Expression(300) → Look(400) → Breath(500) → Physics(600) → Pose(800).
_eyeBlink?.UpdateParameters(_nativeModel, dt);
_pose?.UpdateParameters(_nativeModel, dt);
_expressionManager.DoUpdateMotion(_nativeModel, _userTimeSeconds);
InjectFaceTrackParameters();
_breath?.UpdateParameters(_nativeModel, dt);
_physics?.Evaluate(_nativeModel, dt);
_pose?.UpdateParameters(_nativeModel, dt);
_nativeModel.UpdateModel();
_renderer.UpdateModel();
}
/// <summary>
/// Frees the pinned native model memory when the node leaves the scene tree.
/// Without this the pinned GCHandles (moc + model buffers) leak for the rest
/// of the process — <see cref="CubismNativeModel.Dispose"/> is unreachable otherwise.
/// </summary>
public override void _ExitTree()
{
// Invalidate before anything else: the autoload controller may
// dispatch OnUpdate between tree exit and the deferred delete pass,
// and the meshes die together with this subtree.
_loaded = false;
if (_eventController != null)
{
_eventController.RemoveUpdateEventListener(this);
_eventController = null;
}
_nativeModel?.Dispose();
_nativeModel = null;
}
/// <summary>
/// Adds the face-tracking target to the native parameter values via the
/// precomputed index cache — no string reads or comparisons in the per-frame
/// path. Parameters claimed through <see cref="SetLiveParameter"/> are skipped.
/// Mirrors SDK CubismLook::UpdateParameters (AddParameterValue): AngleX/Y =
/// target × 30, EyeBallX/Y = target — layered on top of the motion result,
/// never replacing it. The Load/Save bracket in <see cref="OnUpdate"/>
/// keeps the addition from accumulating across frames.
/// </summary>
private unsafe void InjectFaceTrackParameters()
{
if (_faceTrackCache.Length == 0) return;
var values = _nativeModel.GetParameterValues();
var tx = _targetPoint.X;
var ty = _targetPoint.Y;
for (int i = 0; i < _faceTrackCache.Length; i++)
{
var entry = _faceTrackCache[i];
if (_manualValues.ContainsKey(entry.Id)) continue;
values[entry.Index] += (entry.FromTargetX ? tx : ty) * entry.Scale;
}
}
/// <summary>
/// Restores values set through <see cref="SetLiveParameter"/> after
/// <c>LoadParameters</c> resets the native parameter arrays to the snapshot.
/// </summary>
private unsafe void ReapplyManualParameters()
{
if (_manualValues.Count == 0) return;
var values = _nativeModel.GetParameterValues();
foreach (var kv in _manualValues)
{
if (!_parameterIndexById.TryGetValue(kv.Key, out var index)) continue;
values[index] = kv.Value;
}
}
private void DeferredApplyLayout()
{
if (_layoutLogged) return;
if (!_contentBoundsReady) return;
// Layout from model3.json takes precedence over the Fit logic
// (same as LAppModel::SetupModel -> modelMatrix->SetupFromLayout).
var layout = _modelSetting?.GetLayoutMap();
if (layout != null && layout.Count > 0)
{
ApplyLayoutFromModelSetting(layout);
return;
}
var parentSize = GetParentSize();
if (parentSize.X <= 0f || parentSize.Y <= 0f) return;
@@ -300,6 +407,36 @@ namespace Cthangover.Live2D.Cubism.Framework
GameLogger.Log("LIVE2D", $"ApplyLayout: parentSize={parentSize} contentSize={cw}x{ch} contentCenter={_contentCenter} scale={scale} pos={Position}");
}
/// <summary>
/// Applies the "Layout" section of model3.json through
/// <see cref="CubismModelMatrix.SetupFromLayout"/> (SDK semantics:
/// canvas logical units, model height defaults to 2 units via the matrix
/// constructor). The logical view scale maps 2 logical units to the
/// parent height (like the SDK samples' view matrix); Y is flipped
/// because Godot's Y axis points down.
/// </summary>
private void ApplyLayoutFromModelSetting(Dictionary<string, float> layout)
{
var ppu = _nativeModel.PixelsPerUnit;
if (ppu <= 0f) ppu = 1f;
var matrix = new CubismModelMatrix(CanvasWidth / ppu, CanvasHeight / ppu);
matrix.SetupFromLayout(layout);
var parentSize = GetParentSize();
var logicalPixelsPerUnit = parentSize.Y > 0f ? parentSize.Y / 2f : ppu;
Scale = new Vector2(
matrix.ScaleX * logicalPixelsPerUnit / ppu,
matrix.ScaleY * logicalPixelsPerUnit / ppu);
Position = new Vector2(
matrix.TranslateX * logicalPixelsPerUnit,
-matrix.TranslateY * logicalPixelsPerUnit);
_layoutLogged = true;
GameLogger.Log("LIVE2D", $"ApplyModelSettingLayout: matrixScale=({matrix.ScaleX:F3},{matrix.ScaleY:F3}) translate=({matrix.TranslateX:F3},{matrix.TranslateY:F3}) nodeScale={Scale} pos={Position}");
}
private Vector2 GetParentSize()
{
var parent = GetParent();
@@ -329,6 +466,44 @@ namespace Cthangover.Live2D.Cubism.Framework
Value = defaults[i]
};
}
// Parameter index cache is built once after load — hot loops
// resolve by index only.
_parameterIndexById.Clear();
for (int i = 0; i < count; i++)
_parameterIndexById[Parameters[i].Id] = i;
_faceTrackCache = BuildFaceTrackCache();
}
/// <summary>
/// Precomputes the face-track injection list once after load:
/// ParamAngleX/Y (×30) and ParamEyeBallX/Y (×1) — LAppModel semantics.
/// Parameters absent from the model are skipped.
/// </summary>
private FaceTrackEntry[] BuildFaceTrackCache()
{
var specs = new (string Id, bool FromTargetX, float Scale)[]
{
("ParamAngleX", true, 30f),
("ParamAngleY", false, 30f),
("ParamEyeBallX", true, 1f),
("ParamEyeBallY", false, 1f),
};
var result = new List<FaceTrackEntry>(specs.Length);
foreach (var spec in specs)
{
if (!_parameterIndexById.TryGetValue(spec.Item1, out var index)) continue;
result.Add(new FaceTrackEntry
{
Index = index,
Id = spec.Item1,
FromTargetX = spec.Item2,
Scale = spec.Item3
});
}
return result.ToArray();
}
private unsafe void SetupParts()
@@ -351,13 +526,20 @@ namespace Cthangover.Live2D.Cubism.Framework
private void SetupEffects()
{
if (EnableEyeBlink && _modelSetting.GetEyeBlinkParameterCount() > 0)
// Collect the Groups (EyeBlink/LipSync) parameter ids once — they are
// wired into every motion via SetEffectIds (see StartMotion).
_eyeBlinkIds.Clear();
for (int i = 0; i < _modelSetting.GetEyeBlinkParameterCount(); i++)
_eyeBlinkIds.Add(_modelSetting.GetEyeBlinkParameterId(i));
_lipSyncIds.Clear();
for (int i = 0; i < _modelSetting.GetLipSyncParameterCount(); i++)
_lipSyncIds.Add(_modelSetting.GetLipSyncParameterId(i));
if (EnableEyeBlink && _eyeBlinkIds.Count > 0)
{
_eyeBlink = new CubismEyeBlink();
var ids = new System.Collections.Generic.List<string>();
for (int i = 0; i < _modelSetting.GetEyeBlinkParameterCount(); i++)
ids.Add(_modelSetting.GetEyeBlinkParameterId(i));
_eyeBlink.SetParameterIds(ids);
_eyeBlink.SetParameterIds(_eyeBlinkIds);
}
var physicsPath = _modelSetting.GetPhysicsFileName();
@@ -439,25 +621,18 @@ namespace Cthangover.Live2D.Cubism.Framework
unsafe
{
var values = _nativeModel.GetParameterValues();
var idsPtr = _nativeModel.GetParameterIds();
for (int i = 0; i < _nativeModel.ParameterCount; i++)
if (!_parameterIndexById.TryGetValue(id, out var index))
{
var paramId = CubismNativeModel.ReadStringFromPtrArray(idsPtr, i);
if (paramId != id) continue;
values[i] = value;
_manualParameters.Add(id);
if (i < Parameters.Length)
Parameters[i].Value = value;
GameLogger.Log("LIVE2D", $"SetLiveParameter: parameter '{id}' not found", LogLevel.Warning);
return;
}
}
GameLogger.Log("LIVE2D", $"SetLiveParameter: parameter '{id}' not found", LogLevel.Warning);
_nativeModel.GetParameterValues()[index] = value;
_manualValues[id] = value;
if (index < Parameters.Length)
Parameters[index].Value = value;
}
}
/// <summary>
@@ -484,6 +659,10 @@ namespace Cthangover.Live2D.Cubism.Framework
if (fadeIn >= 0f) motion.FadeInSeconds = fadeIn;
if (fadeOut >= 0f) motion.FadeOutSeconds = fadeOut;
// Wire the motion's model curves (EyeBlink/LipSync) to the parameter
// ids from model3.json Groups — same as LAppModel.StartMotion.
motion.SetEffectIds(_eyeBlinkIds, _lipSyncIds);
_motionManager.StartMotion(motion, true, _userTimeSeconds);
}
File diff suppressed because it is too large. Load diff
@@ -18,6 +18,22 @@ namespace Cthangover.Live2D.Cubism.Framework
/// </summary>
public enum CubismMotionSegmentType { Linear = 0, Bezier = 1, Stepped = 2, InverseStepped = 3 }
/// <summary>
/// Данные одного сегмента кривой — порт CubismMotionSegment из CubismMotionInternal.hpp:
/// индекс первой контрольной точки в общем плоском массиве точек и тип интерполяции.
/// Соседние сегменты кривой разделяют общую точку: BasePointIndex следующего сегмента
/// равен индексу последней точки предыдущего, а оценка кривой ищет сегмент по времени
/// первой точки СЛЕДУЮЩЕГО сегмента.
/// </summary>
internal struct MotionSegmentData
{
/// <summary>Индекс первой контрольной точки сегмента в CubismMotion._points.</summary>
public int BasePointIndex;
/// <summary>Тип сегмента (Linear/Bezier/Stepped/InverseStepped).</summary>
public CubismMotionSegmentType SegmentType;
}
/// <summary>
/// A time-value pair representing a keyframe or control point on a motion curve.
/// </summary>
@@ -9,7 +9,7 @@ namespace Cthangover.Live2D.Cubism.Framework
/// curve statistics, beziers-restricted flag) and the Curves/UserData sections.
///
/// Parsed curve segments are returned as flat float arrays (interleaved
/// segment-type markers and point data), decoded by <see cref="CubismMotion.ParseSegments"/>.
/// segment-type markers and point data), decoded by CubismMotion.ParseSegmentsInternal.
/// </summary>
public class CubismMotionJson
{
@@ -40,7 +40,7 @@ namespace Cthangover.Live2D.Cubism.Framework
_duration = _meta.GetProperty("Duration").GetSingle();
_isLoop = _meta.TryGetProperty("Loop", out var loop) && loop.GetBoolean();
_fps = _meta.TryGetProperty("Fps", out var fpsEl) ? fpsEl.GetSingle() : 30f;
_fps = _meta.TryGetProperty("Fps", out var fpsEl) && fpsEl.ValueKind == JsonValueKind.Number ? fpsEl.GetSingle() : 30f;
_curveCount = _meta.TryGetProperty("CurveCount", out var ccEl) ? ccEl.GetInt32() : 0;
_totalSegmentCount = _meta.TryGetProperty("TotalSegmentCount", out var tscEl) ? tscEl.GetInt32() : 0;
_totalPointCount = _meta.TryGetProperty("TotalPointCount", out var tpcEl) ? tpcEl.GetInt32() : 0;
@@ -75,13 +75,13 @@ namespace Cthangover.Live2D.Cubism.Framework
/// Motion-level fade-in time in seconds, or -1 if not specified.
/// </summary>
public float GetFadeInTime() =>
_meta.TryGetProperty("FadeInTime", out var v) ? v.GetSingle() : -1f;
_meta.TryGetProperty("FadeInTime", out var v) && v.ValueKind == JsonValueKind.Number ? v.GetSingle() : -1f;
/// <summary>
/// Motion-level fade-out time in seconds, or -1 if not specified.
/// </summary>
public float GetFadeOutTime() =>
_meta.TryGetProperty("FadeOutTime", out var v) ? v.GetSingle() : -1f;
_meta.TryGetProperty("FadeOutTime", out var v) && v.ValueKind == JsonValueKind.Number ? v.GetSingle() : -1f;
/// <summary>
/// Returns the target string for the curve at the given index
@@ -100,18 +100,20 @@ namespace Cthangover.Live2D.Cubism.Framework
/// Per-curve fade-in time in seconds, or -1 if not specified.
/// </summary>
public float GetCurveFadeInTime(int index) =>
_curves[index].TryGetProperty("FadeInTime", out var v) ? v.GetSingle() : -1f;
_curves[index].TryGetProperty("FadeInTime", out var v) && v.ValueKind == JsonValueKind.Number ? v.GetSingle() : -1f;
/// <summary>
/// Per-curve fade-out time in seconds, or -1 if not specified.
/// </summary>
public float GetCurveFadeOutTime(int index) =>
_curves[index].TryGetProperty("FadeOutTime", out var v) ? v.GetSingle() : -1f;
_curves[index].TryGetProperty("FadeOutTime", out var v) && v.ValueKind == JsonValueKind.Number ? v.GetSingle() : -1f;
/// <summary>
/// Reads the raw float array of segment data for a curve.
/// The first element is the segment type marker (cast to int),
/// followed by point data depending on the segment type.
/// Формат Segments из motion3.json: [t0, v0, type, t, v, ...] — первые ДВА
/// элемента это стартовая точка кривой (время, значение), далее для каждого
/// сегмента маркер типа и его точки (Linear/Stepped/InverseStepped — 1 точка,
/// Bezier — 3). Разбор: <see cref="CubismMotion.ParseSegmentsInternal"/>.
/// </summary>
public void GetCurveSegments(int index, out float[] segments)
{
@@ -1,9 +1,12 @@
using System;
using System.Collections.Generic;
namespace Cthangover.Live2D.Cubism.Framework
{
/// <summary>
/// Represents one enqueued motion in the playback queue.
/// Holds timing data (start time, end time, fade-in start time),
/// lifecycle flags (available, finished, auto-delete), and a
/// lifecycle flags (available, started, finished, auto-delete), and a
/// reference to the motion itself.
///
/// Created by <see cref="CubismMotionQueueManager.StartMotion"/>
@@ -11,23 +14,41 @@ namespace Cthangover.Live2D.Cubism.Framework
/// </summary>
public class CubismMotionQueueEntry
{
/// <summary>Absolute user time when motion playback begins.</summary>
public float StartTime;
/// <summary>Absolute user time when motion playback begins. -1 until the first update.</summary>
public float StartTime = -1f;
/// <summary>
/// Absolute user time when playback ends. -1 for infinite (looping) motions.
/// </summary>
public float EndTime;
public float EndTime = -1f;
/// <summary>Absolute user time when fade-in begins (may differ from StartTime for offset motions).</summary>
public float FadeInStartTime;
/// <summary>Whether this entry should be processed during updates.</summary>
public bool IsAvailable;
public bool IsAvailable = true;
/// <summary>Flag set when playback completes. Checked by the queue manager during cleanup.</summary>
public bool IsFinished;
/// <summary>Flag set on the first update — порт CubismMotionQueueEntry::_started.</summary>
public bool IsStarted;
/// <summary>Время последнего кадра (состояние), порт CubismMotionQueueEntry::SetState.</summary>
public float StateTime;
/// <summary>Вес последнего кадра (состояние), порт CubismMotionQueueEntry::SetState.</summary>
public float StateWeight;
/// <summary>Абсолютное время последней проверки событий UserData (сдвигается каждый кадр).</summary>
public float LastCheckEventTime;
/// <summary>Длительность фейда-аута, назначенного при вытеснении новым моушеном.</summary>
public float FadeOutSeconds;
/// <summary>Флаг вытеснения: фейд-аут начат, EndTime будет назначен при следующем обновлении.</summary>
public bool IsTriggeredFadeOut;
/// <summary>The motion instance driving parameter updates.</summary>
public ACubismMotion Motion;
@@ -45,29 +66,70 @@ namespace Cthangover.Live2D.Cubism.Framework
{
Motion = motion;
AutoDelete = autoDelete;
IsAvailable = true;
}
/// <summary>
/// Порт CubismMotionQueueEntry::SetFadeout: помечает запись для фейда-аута
/// (вызывается очередью, когда стартует новый моушен).
/// </summary>
public void SetFadeout(float fadeOutSeconds)
{
FadeOutSeconds = fadeOutSeconds;
IsTriggeredFadeOut = true;
}
/// <summary>
/// Порт CubismMotionQueueEntry::StartFadeout: назначает EndTime, сокращая его,
/// если фейд-аут уже был запущен раньше и раньше должен закончиться.
/// </summary>
public void StartFadeout(float fadeOutSeconds, float userTimeSeconds)
{
float newEndTimeSeconds = userTimeSeconds + fadeOutSeconds;
IsTriggeredFadeOut = true;
if (EndTime < 0f || newEndTimeSeconds < EndTime)
{
EndTime = newEndTimeSeconds;
}
}
/// <summary>Порт CubismMotionQueueEntry::SetState — сохраняет время и вес последнего кадра.</summary>
public void SetState(float timeSeconds, float weight)
{
StateTime = timeSeconds;
StateWeight = weight;
}
}
/// <summary>
/// Manages a queue of <see cref="ACubismMotion"/> instances for a single model.
/// Supports priority reservation (higher-priority motions preempt lower ones),
/// concurrent playback of multiple motions, and automatic cleanup of finished entries.
/// Поддержка вытеснения по приоритету (ReserveMotion), параллельного проигрывания
/// и автосборки завершённых записей. Порт CubismMotionQueueManager из SDK 5-r.5:
///
/// Two instances are used by <see cref="CubismModelNode"/>:
/// one for character motions (animations) and one for expressions.
/// Each is updated independently in the per-frame loop, allowing motions
/// and expressions to blend independently with their own fade weights.
///
/// The update loop iterates entries in reverse for safe removal during
/// traversal. Finished auto-delete entries are removed immediately;
/// non-auto-delete entries remain in the queue with their IsFinished flag set.
/// - StartMotion назначает всем прежним записям фейд-аут;
/// - каждый кадр DoUpdateMotion вызывает <see cref="ACubismMotion.UpdateParameters"/>,
/// затем проверяет события UserData (<see cref="ACubismMotion.GetFiredEvent"/>) и
/// удаляет finished-записи;
/// - колбэком завершения владеет сам моушен: очередь его НЕ дублирует.
/// </summary>
public class CubismMotionQueueManager
{
private readonly System.Collections.Generic.List<CubismMotionQueueEntry> _entries = new();
private readonly List<CubismMotionQueueEntry> _entries = new();
private readonly List<string> _firedEventValues = new();
private int _reservedPriority = -1;
/// <summary>
/// Событие, вызываемое для каждого сработавшего UserData-события за кадр
/// (порт _eventCallback из CubismMotionQueueManager).
/// </summary>
public event Action<ACubismMotion, string> EventFired;
/// <summary>
/// События, собранные за последний кадр DoUpdateMotion по всем записям очереди.
/// Список очищается в начале каждого обновления — читайте его сразу после вызова.
/// </summary>
public IReadOnlyList<string> FiredEventValues => _firedEventValues;
/// <summary>
/// Sets the reserved priority threshold.
/// Motions with priority below this are rejected by <see cref="ReserveMotion"/>.
@@ -89,21 +151,27 @@ namespace Cthangover.Live2D.Cubism.Framework
}
/// <summary>
/// Creates and enqueues a motion for playback.
/// Instantiates a <see cref="CubismMotionQueueEntry"/>, calls
/// <see cref="ACubismMotion.SetupMotionQueueEntry"/> on it, and
/// adds it to the queue. Returns the entry for optional tracking.
/// Enqueues a motion for playback. Прежним записям назначается фейд-аут
/// (SetFadeout), после чего они плавно завершаются; сама запись инициализируется
/// при первом кадре <see cref="ACubismMotion.UpdateParameters"/>.
/// Параметр userTimeSeconds оставлен для совместимости вызовов.
/// </summary>
/// <param name="motion">The motion to play.</param>
/// <param name="autoDelete">Whether to auto-remove on finish.</param>
/// <param name="userTimeSeconds">Current user time for start time calculation.</param>
/// <param name="userTimeSeconds">Current user time (не используется, порт сигнатуры).</param>
public CubismMotionQueueEntry StartMotion(ACubismMotion motion, bool autoDelete, float userTimeSeconds)
{
if (motion == null)
return null;
// Порт CubismMotionQueueManager::StartMotion: прежние моушены вытесняются фейд-аутом.
foreach (var existing in _entries)
{
if (existing == null) continue;
existing.SetFadeout(existing.Motion?.FadeOutSeconds ?? 0f);
}
var entry = new CubismMotionQueueEntry(motion, autoDelete);
motion.SetupMotionQueueEntry(entry, userTimeSeconds);
_entries.Add(entry);
return entry;
}
@@ -123,40 +191,68 @@ namespace Cthangover.Live2D.Cubism.Framework
public bool IsFinished()
{
foreach (var entry in _entries)
if (!entry.IsFinished)
if (entry != null && !entry.IsFinished)
return false;
return true;
}
/// <summary>
/// Per-frame update for all queued motions.
/// Iterates in reverse for safe removal. For each active entry:
/// computes fade weight, invokes <see cref="ACubismMotion.DoUpdateParameters"/>,
/// and checks for completion. Finished auto-delete entries are removed.
/// Per-frame update — порт CubismMotionQueueManager::DoUpdateMotion.
/// Для каждой записи: UpdateParameters (фейд + применение значений + финализация
/// IsFinished самим моушеном), сборUserData-событий за кадр, затем удаление
/// finished-записей и старт отложенного фейда-аута вытесненных.
/// Обход прямой, как в SDK: моушены применяются от старых к новым.
/// </summary>
public void DoUpdateMotion(CubismNativeModel model, float userTimeSeconds)
{
for (int i = _entries.Count - 1; i >= 0; i--)
_firedEventValues.Clear();
int i = 0;
while (i < _entries.Count)
{
var entry = _entries[i];
if (!entry.IsAvailable || entry.IsFinished)
CubismMotionQueueEntry entry = _entries[i];
if (entry == null)
{
if (entry.AutoDelete)
_entries.RemoveAt(i);
continue;
}
var weight = entry.Motion.UpdateFadeWeight(entry, userTimeSeconds);
entry.Motion.DoUpdateParameters(model, userTimeSeconds, weight, entry);
ACubismMotion motion = entry.Motion;
if (motion == null)
{
_entries.RemoveAt(i);
continue;
}
if (entry.Motion.GetDuration() > 0f)
// ------ Применение значений (финализация — внутри моушена/обёртки) ------
motion.UpdateParameters(model, entry, userTimeSeconds);
// ------ Проверка UserData-событий за кадр ------
var fired = motion.GetFiredEvent(
entry.LastCheckEventTime - entry.StartTime,
userTimeSeconds - entry.StartTime);
for (int e = 0; e < fired.Count; e++)
EventFired?.Invoke(motion, fired[e]);
_firedEventValues.AddRange(fired);
entry.LastCheckEventTime = userTimeSeconds;
// ------ Удаление завершённых записей ------
if (entry.IsFinished)
{
if (userTimeSeconds >= entry.EndTime && !entry.Motion.IsLoop)
_entries.RemoveAt(i);
continue;
}
// Фейд-аут, запланированный при вытеснении, стартует после обновления.
if (entry.IsTriggeredFadeOut)
{
entry.IsFinished = true;
entry.Motion.OnFinishedMotion?.Invoke(entry.Motion);
}
entry.StartFadeout(entry.FadeOutSeconds, userTimeSeconds);
}
i++;
}
}
}
@@ -29,6 +29,34 @@ namespace Cthangover.Live2D.Cubism.Framework
private GCHandle _mocAlignedHandle;
private GCHandle _modelAlignedHandle;
// The Core stores the raw function pointer, so the delegate must live
// for the whole process — a collected delegate would crash native code.
private static readonly CubismCoreBindings.CsmLogDelegate NativeLogDelegate = OnNativeLog;
private static bool _nativeLogWired;
static CubismNativeModel()
{
WireNativeLog();
}
/// <summary>
/// Routes native Core log output (the only channel for native-side errors:
/// invalid moc3, unsupported version, alignment failures) into GameLogger.
/// </summary>
private static void WireNativeLog()
{
if (_nativeLogWired) return;
_nativeLogWired = true;
CubismCoreBindings.csmSetLogFunction(NativeLogDelegate);
}
private static void OnNativeLog(IntPtr messagePtr)
{
if (messagePtr == IntPtr.Zero) return;
string message = Marshal.PtrToStringAnsi(messagePtr) ?? string.Empty;
GameLogger.Log("LIVE2D-CORE", message);
}
private int _drawableCount;
private int _parameterCount;
@@ -67,18 +95,63 @@ namespace Cthangover.Live2D.Cubism.Framework
/// </summary>
public unsafe void LoadMoc(byte[] mocBytes)
{
if (IsLoaded)
{
// Guard against a silent native-memory leak: handles are pinned and the
// previous moc/model are never freed, so a second LoadMoc on the same
// instance is always a bug in the caller.
throw new InvalidOperationException(
"LoadMoc: a moc is already loaded in this CubismNativeModel instance. " +
"Create a new instance to load another model.");
}
int alignedMocSize = (mocBytes.Length + CubismCoreBindings.AlignofMoc - 1)
/ CubismCoreBindings.AlignofMoc * CubismCoreBindings.AlignofMoc;
byte[] mocBuffer = new byte[alignedMocSize + CubismCoreBindings.AlignofMoc];
_mocAlignedHandle = GCHandle.Alloc(mocBuffer, GCHandleType.Pinned);
try
{
IntPtr mocPtr = _mocAlignedHandle.AddrOfPinnedObject();
long alignedAddr = (mocPtr.ToInt64() + CubismCoreBindings.AlignofMoc - 1)
/ CubismCoreBindings.AlignofMoc * CubismCoreBindings.AlignofMoc;
var alignedMocPtr = new IntPtr(alignedAddr);
Marshal.Copy(mocBytes, 0, alignedMocPtr, mocBytes.Length);
// csmReviveMocInPlace (verified by disassembly of the shipped DLL) hard-requires
// size % 64 == 0 and returns failure with '"size" is invalid.' otherwise. Cubism
// Editor always exports moc3 files whose length is a multiple of 64, but we do not
// rely on that: the buffer is already zero-initialized and 64-byte padded, so we
// pass the padded size — the Core reads only the real content, zero padding is safe.
uint mocBufferSize = (uint)alignedMocSize;
// Validate the moc3 buffer against the loaded Core before reviving it.
// Both checks use Core functions that were previously declared but never
// called — this gives diagnosable errors instead of a bare
// "csmReviveMocInPlace failed" for wrong-version or corrupted files.
uint mocVersion = CubismCoreBindings.csmGetMocVersion((void*)alignedMocPtr, mocBufferSize);
uint coreVersion = CubismCoreBindings.csmGetLatestMocVersion();
if (mocVersion == 0)
{
GameLogger.Log("LIVE2D", "LoadMoc: buffer is not a recognized .moc3 file (csmGetMocVersion returned 0)", LogLevel.Error);
throw new InvalidOperationException("The .moc3 buffer is not a recognized moc3 file");
}
if (mocVersion > coreVersion)
{
GameLogger.Log("LIVE2D",
$"LoadMoc: .moc3 version {mocVersion} is newer than Cubism Core {coreVersion} — ship a newer Live2DCubismCore.dll",
LogLevel.Error);
throw new InvalidOperationException(
$".moc3 version {mocVersion} is not supported by Cubism Core {coreVersion}");
}
if (CubismCoreBindings.csmHasMocConsistency((void*)alignedMocPtr, mocBufferSize) != 1)
{
GameLogger.Log("LIVE2D", "LoadMoc: .moc3 failed the consistency check (csmHasMocConsistency != 1) — data is corrupted", LogLevel.Error);
throw new InvalidOperationException(".moc3 data is inconsistent (csmHasMocConsistency failed)");
}
IntPtr moc = CubismCoreBindings.csmReviveMocInPlace(
(void*)alignedMocPtr, (uint)mocBytes.Length);
(void*)alignedMocPtr, mocBufferSize);
if (moc == IntPtr.Zero)
{
GameLogger.Log("LIVE2D", "LoadMoc: csmReviveMocInPlace failed — .moc3 data is invalid or corrupted", LogLevel.Error);
@@ -100,6 +173,18 @@ namespace Cthangover.Live2D.Cubism.Framework
GameLogger.Log("LIVE2D", "LoadMoc: csmInitializeModelInPlace failed — model memory allocation failed", LogLevel.Error);
throw new InvalidOperationException("csmInitializeModelInPlace failed");
}
}
catch
{
// Any failure after the Alloc calls must release the pinned buffers —
// otherwise the GCHandles (and the native memory they pin) leak for the
// rest of the process, because LoadMoc throws before Dispose is reachable.
if (_mocAlignedHandle.IsAllocated) _mocAlignedHandle.Free();
if (_modelAlignedHandle.IsAllocated) _modelAlignedHandle.Free();
_mocAlignedHandle = default;
_modelAlignedHandle = default;
throw;
}
var sizeInPixels = default(CubismCoreBindings.csmVector2);
var originInPixels = default(CubismCoreBindings.csmVector2);
@@ -319,6 +404,39 @@ namespace Cthangover.Live2D.Cubism.Framework
public unsafe IntPtr GetPartIds() =>
CubismCoreBindings.csmGetPartIds(_model);
// Snapshot of the parameter values for the Load/Save bracket
// (port of CubismModel.cpp:878-910).
private float[] _savedParameterValues;
/// <summary>
/// Порт CubismModel::SaveParameters: сохраняет текущие значения параметров в снимок.
/// Вызывается сразу после фазы моушена, чтобы аддитивные слои (выражения, дыхание,
/// look-инъекция) каждый кадр стартовали от одного и того же базового состояния
/// и не накапливались кадр-за-кадром.
/// </summary>
public unsafe void SaveParameters()
{
if (_model == IntPtr.Zero) return;
var values = GetParameterValues();
if (_savedParameterValues == null || _savedParameterValues.Length != _parameterCount)
_savedParameterValues = new float[_parameterCount];
for (int i = 0; i < _parameterCount; i++)
_savedParameterValues[i] = values[i];
}
/// <summary>
/// Порт CubismModel::LoadParameters: восстанавливает значения параметров из снимка.
/// До первого SaveParameters ничего не делает (как в SDK).
/// </summary>
public unsafe void LoadParameters()
{
if (_model == IntPtr.Zero || _savedParameterValues == null) return;
var values = GetParameterValues();
var count = System.Math.Min(_parameterCount, _savedParameterValues.Length);
for (int i = 0; i < count; i++)
values[i] = _savedParameterValues[i];
}
/// <summary>
/// Frees pinned memory handles. The native model handle is set to zero;
/// the underlying CubismCore memory was allocated inline and lives in
File diff suppressed because it is too large. Load diff
+203 -96
View File
@@ -1,4 +1,3 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
@@ -17,7 +16,7 @@ namespace Cthangover.Live2D.Cubism.Framework
/// <summary>
/// A single particle in a physics chain. Holds position, velocity, force accumulators,
/// and per-particle properties (mobility, delay, acceleration, radius) from the
/// .physics3.json file. Positions are computed relative to the chain anchor.
/// .physics3.json file. Mirrors SDK CubismPhysicsParticle.
/// </summary>
public struct CubismPhysicsParticle
{
@@ -61,8 +60,11 @@ namespace Cthangover.Live2D.Cubism.Framework
/// <summary>
/// Maps a physics chain output back to a model parameter.
/// Defines which particle vertex to read, the output type (X, Y, angle),
/// translation scale, weight, and reflection.
/// <see cref="VertexIndex"/> is the pendulum vertex index relative to the
/// owning sub-rig's base particle (SDK semantics). The translation scale is
/// deliberately never loaded: native CubismPhysicsJson reads only the
/// single "Scale" value into <see cref="AngleScale"/>, so X/Y-typed outputs
/// scale by zero exactly like the native implementation.
/// </summary>
public struct CubismPhysicsOutput
{
@@ -95,146 +97,251 @@ namespace Cthangover.Live2D.Cubism.Framework
}
/// <summary>
/// JSON parser for the Cubism .physics3.json format.
/// Reads the Meta section (fps, gravity, wind) and the PhysicsSettings
/// array (sub-rigs with their inputs, outputs, and vertices/particles).
///
/// All data is accessed through typed out-parameter methods that
/// parse JSON elements directly — avoiding intermediate allocation
/// of temporary data structures.
/// JSON parser for the Cubism .physics3.json format. Field-by-field port of
/// Cubism SDK for Native 5-r.5 Physics/CubismPhysicsJson.cpp: every getter
/// reads the same key at the same JSON path. Missing numeric values yield
/// 0 / false like the native error-value accessors; in particular a
/// missing Meta.Fps yields 0 (the simulation then steps per display frame).
/// Note: native does not read InitialPosition/InitialVelocity for vertices —
/// those keys are ignored here as well; particles are laid out by
/// CubismPhysics.Initialize().
/// </summary>
public class CubismPhysicsJson
{
private readonly JsonDocument _json;
/// <summary>
/// Parses the given .physics3.json bytes.
/// </summary>
/// <summary>Parses the given .physics3.json bytes.</summary>
public CubismPhysicsJson(byte[] jsonBytes)
{
_json = JsonDocument.Parse(jsonBytes);
}
/// <summary>Physics simulation FPS from the Meta section, default 30.</summary>
public float GetFps()
{
var meta = _json.RootElement.GetProperty("Meta");
return meta.TryGetProperty("Fps", out var v) ? v.GetSingle() : 30f;
}
// ------------------------------------------------------------------
// Meta
// ------------------------------------------------------------------
/// <summary>Number of sub-rig entries in PhysicsSettings.</summary>
public int GetSubRigCount() =>
_json.RootElement.TryGetProperty("PhysicsSettings", out var arr) ? arr.GetArrayLength() : 0;
/// <summary>Reads gravity vector from Meta.EffectiveForces.Gravity. Defaults to (0, -1).</summary>
/// <summary>Gravity vector from Meta.EffectiveForces.Gravity (missing values read as 0).</summary>
public void GetGravity(out float x, out float y)
{
x = 0; y = -1;
var meta = _json.RootElement.GetProperty("Meta");
if (meta.TryGetProperty("EffectiveForces", out var ef) &&
ef.TryGetProperty("Gravity", out var g))
x = 0f;
y = 0f;
if (_json.RootElement.TryGetProperty("Meta", out var meta) &&
meta.TryGetProperty("EffectiveForces", out var effectiveForces) &&
effectiveForces.TryGetProperty("Gravity", out var gravity))
{
x = g.TryGetProperty("X", out var gx) ? gx.GetSingle() : 0f;
y = g.TryGetProperty("Y", out var gy) ? gy.GetSingle() : -1f;
x = GetFloat(gravity, "X");
y = GetFloat(gravity, "Y");
}
}
/// <summary>Reads wind vector from Meta.EffectiveForces.Wind. Defaults to (0, 0).</summary>
/// <summary>Wind vector from Meta.EffectiveForces.Wind (missing values read as 0).</summary>
public void GetWind(out float x, out float y)
{
x = 0; y = 0;
var meta = _json.RootElement.GetProperty("Meta");
if (meta.TryGetProperty("EffectiveForces", out var ef) &&
ef.TryGetProperty("Wind", out var w))
x = 0f;
y = 0f;
if (_json.RootElement.TryGetProperty("Meta", out var meta) &&
meta.TryGetProperty("EffectiveForces", out var effectiveForces) &&
effectiveForces.TryGetProperty("Wind", out var wind))
{
x = w.TryGetProperty("X", out var wx) ? wx.GetSingle() : 0f;
y = w.TryGetProperty("Y", out var wy) ? wy.GetSingle() : 0f;
x = GetFloat(wind, "X");
y = GetFloat(wind, "Y");
}
}
/// <summary>Number of input entries in the given sub-rig.</summary>
public int GetInputCount(int subRigIndex) =>
GetSubRig(subRigIndex).GetProperty("Input").GetArrayLength();
/// <summary>Simulation FPS from Meta.Fps; 0 when absent (native behavior).</summary>
public float GetFps()
{
return _json.RootElement.TryGetProperty("Meta", out var meta) ? GetFloat(meta, "Fps") : 0f;
}
/// <summary>Number of output entries in the given sub-rig.</summary>
public int GetOutputCount(int subRigIndex) =>
GetSubRig(subRigIndex).GetProperty("Output").GetArrayLength();
/// <summary>Sub-rig count from Meta.PhysicsSettingCount.</summary>
public int GetSubRigCount()
{
if (_json.RootElement.TryGetProperty("Meta", out var meta) &&
meta.TryGetProperty("PhysicsSettingCount", out var count))
{
return count.GetInt32();
}
/// <summary>Number of vertex/particle entries in the given sub-rig.</summary>
public int GetVertexCount(int subRigIndex) =>
GetSubRig(subRigIndex).GetProperty("Vertices").GetArrayLength();
// Defensive fallback: use the actual settings array length
// (native would return 0 when the key is absent).
return _json.RootElement.TryGetProperty("PhysicsSettings", out var settings)
? settings.GetArrayLength()
: 0;
}
/// <summary>Reads position normalization (min, max, default) for the given sub-rig.</summary>
/// <summary>Total input count from Meta.TotalInputCount.</summary>
public int GetTotalInputCount() => GetMetaInt("TotalInputCount");
/// <summary>Total output count from Meta.TotalOutputCount.</summary>
public int GetTotalOutputCount() => GetMetaInt("TotalOutputCount");
/// <summary>Total vertex/particle count from Meta.VertexCount.</summary>
public int GetVertexCount() => GetMetaInt("VertexCount");
// ------------------------------------------------------------------
// Normalization
// ------------------------------------------------------------------
/// <summary>Position normalization (Minimum/Maximum/Default) for a sub-rig; missing values read as 0.</summary>
public void GetNormalizationPosition(int subRigIndex, out CubismPhysicsNormalization norm)
{
norm = default;
var sr = GetSubRig(subRigIndex);
if (!sr.TryGetProperty("Normalization", out var n)) return;
if (!n.TryGetProperty("Position", out var pos)) return;
norm.Minimum = pos.TryGetProperty("Minimum", out var p) ? p.GetSingle() : -30f;
norm.Maximum = pos.TryGetProperty("Maximum", out var pp) ? pp.GetSingle() : 30f;
norm.Default = pos.TryGetProperty("Default", out var pd) ? pd.GetSingle() : 0f;
if (GetSubRig(subRigIndex).TryGetProperty("Normalization", out var n) &&
n.TryGetProperty("Position", out var pos))
{
norm.Minimum = GetFloat(pos, "Minimum");
norm.Maximum = GetFloat(pos, "Maximum");
norm.Default = GetFloat(pos, "Default");
}
}
/// <summary>Reads angle normalization (min, max, default) for the given sub-rig.</summary>
/// <summary>Angle normalization (Minimum/Maximum/Default) for a sub-rig; missing values read as 0.</summary>
public void GetNormalizationAngle(int subRigIndex, out CubismPhysicsNormalization norm)
{
norm = default;
var sr = GetSubRig(subRigIndex);
if (!sr.TryGetProperty("Normalization", out var n)) return;
if (!n.TryGetProperty("Angle", out var angle)) return;
norm.Minimum = angle.TryGetProperty("Minimum", out var p) ? p.GetSingle() : -30f;
norm.Maximum = angle.TryGetProperty("Maximum", out var pp) ? pp.GetSingle() : 30f;
norm.Default = angle.TryGetProperty("Default", out var pd) ? pd.GetSingle() : 0f;
if (GetSubRig(subRigIndex).TryGetProperty("Normalization", out var n) &&
n.TryGetProperty("Angle", out var angle))
{
norm.Minimum = GetFloat(angle, "Minimum");
norm.Maximum = GetFloat(angle, "Maximum");
norm.Default = GetFloat(angle, "Default");
}
}
/// <summary>Reads a single input entry: parameter ID, weight, type, reflect flag.</summary>
public void GetInput(int subRigIndex, int inputIndex, out string parameterId, out float weight,
out CubismPhysicsSource type, out bool reflect)
// ------------------------------------------------------------------
// Input
// ------------------------------------------------------------------
/// <summary>Number of input entries in the given sub-rig.</summary>
public int GetInputCount(int subRigIndex) => GetEntriesLength(subRigIndex, "Input");
/// <summary>Input weight (Input[i].Weight).</summary>
public float GetInputWeight(int subRigIndex, int inputIndex) =>
GetFloat(GetInputEntry(subRigIndex, inputIndex), "Weight");
/// <summary>Input reflect flag (Input[i].Reflect).</summary>
public bool GetInputReflect(int subRigIndex, int inputIndex) =>
GetBool(GetInputEntry(subRigIndex, inputIndex), "Reflect");
/// <summary>Input type tag (Input[i].Type): "X", "Y" or "Angle".</summary>
public string GetInputType(int subRigIndex, int inputIndex) =>
GetString(GetInputEntry(subRigIndex, inputIndex), "Type");
/// <summary>Source parameter id (Input[i].Source.Id).</summary>
public string GetInputSourceId(int subRigIndex, int inputIndex)
{
var inp = GetSubRig(subRigIndex).GetProperty("Input")[inputIndex];
parameterId = inp.GetProperty("Source").GetProperty("Id").GetString();
weight = inp.TryGetProperty("Weight", out var w) ? w.GetSingle() : 1f;
type = ParseSource(inp.TryGetProperty("Type", out var t) ? t.GetString() : "X");
reflect = inp.TryGetProperty("Reflect", out var r) && r.GetBoolean();
var entry = GetInputEntry(subRigIndex, inputIndex);
return entry.TryGetProperty("Source", out var source) ? GetString(source, "Id") : null;
}
/// <summary>Reads a single output entry: destination parameter ID, vertex index, scales, type, reflect.</summary>
public void GetOutput(int subRigIndex, int outputIndex, out string parameterId, out int vertexIndex,
out float scaleX, out float scaleY, out float weight, out CubismPhysicsSource type, out bool reflect)
// ------------------------------------------------------------------
// Output
// ------------------------------------------------------------------
/// <summary>Number of output entries in the given sub-rig.</summary>
public int GetOutputCount(int subRigIndex) => GetEntriesLength(subRigIndex, "Output");
/// <summary>Pendulum vertex index (Output[i].VertexIndex), relative to the sub-rig base particle.</summary>
public int GetOutputVertexIndex(int subRigIndex, int outputIndex) =>
GetInt(GetOutputEntry(subRigIndex, outputIndex), "VertexIndex");
/// <summary>Angle scale (Output[i].Scale). Native maps the single "Scale" value to the angle scale only.</summary>
public float GetOutputAngleScale(int subRigIndex, int outputIndex) =>
GetFloat(GetOutputEntry(subRigIndex, outputIndex), "Scale");
/// <summary>Output weight (Output[i].Weight).</summary>
public float GetOutputWeight(int subRigIndex, int outputIndex) =>
GetFloat(GetOutputEntry(subRigIndex, outputIndex), "Weight");
/// <summary>Destination parameter id (Output[i].Destination.Id).</summary>
public string GetOutputsDestinationId(int subRigIndex, int outputIndex)
{
var outp = GetSubRig(subRigIndex).GetProperty("Output")[outputIndex];
parameterId = outp.GetProperty("Destination").GetProperty("Id").GetString();
vertexIndex = outp.TryGetProperty("VertexIndex", out var v) ? v.GetInt32() : 0;
scaleX = scaleY = outp.TryGetProperty("Scale", out var s) ? s.GetSingle() : 1f;
weight = outp.TryGetProperty("Weight", out var w) ? w.GetSingle() : 1f;
type = ParseSource(outp.TryGetProperty("Type", out var t) ? t.GetString() : "X");
reflect = outp.TryGetProperty("Reflect", out var r) && r.GetBoolean();
var entry = GetOutputEntry(subRigIndex, outputIndex);
return entry.TryGetProperty("Destination", out var destination) ? GetString(destination, "Id") : null;
}
/// <summary>Reads a single particle/vertex: mobility, delay, acceleration, radius, position.</summary>
public void GetParticle(int subRigIndex, int vertexIndex,
out float mobility, out float delay, out float acceleration, out float radius,
out float posX, out float posY)
/// <summary>Output type tag (Output[i].Type): "X", "Y" or "Angle".</summary>
public string GetOutputType(int subRigIndex, int outputIndex) =>
GetString(GetOutputEntry(subRigIndex, outputIndex), "Type");
/// <summary>Output reflect flag (Output[i].Reflect).</summary>
public bool GetOutputReflect(int subRigIndex, int outputIndex) =>
GetBool(GetOutputEntry(subRigIndex, outputIndex), "Reflect");
// ------------------------------------------------------------------
// Particle (Vertices)
// ------------------------------------------------------------------
/// <summary>Number of vertex/particle entries in the given sub-rig.</summary>
public int GetParticleCount(int subRigIndex) => GetEntriesLength(subRigIndex, "Vertices");
/// <summary>Particle mobility (Vertices[i].Mobility).</summary>
public float GetParticleMobility(int subRigIndex, int vertexIndex) =>
GetFloat(GetVertexEntry(subRigIndex, vertexIndex), "Mobility");
/// <summary>Particle delay (Vertices[i].Delay).</summary>
public float GetParticleDelay(int subRigIndex, int vertexIndex) =>
GetFloat(GetVertexEntry(subRigIndex, vertexIndex), "Delay");
/// <summary>Particle acceleration (Vertices[i].Acceleration).</summary>
public float GetParticleAcceleration(int subRigIndex, int vertexIndex) =>
GetFloat(GetVertexEntry(subRigIndex, vertexIndex), "Acceleration");
/// <summary>Particle radius (Vertices[i].Radius).</summary>
public float GetParticleRadius(int subRigIndex, int vertexIndex) =>
GetFloat(GetVertexEntry(subRigIndex, vertexIndex), "Radius");
/// <summary>Particle position (Vertices[i].Position.X/Y).</summary>
public void GetParticlePosition(int subRigIndex, int vertexIndex, out float x, out float y)
{
var verts = GetSubRig(subRigIndex).GetProperty("Vertices")[vertexIndex];
mobility = verts.TryGetProperty("Mobility", out var m) ? m.GetSingle() : 1f;
delay = verts.TryGetProperty("Delay", out var d) ? d.GetSingle() : 0f;
acceleration = verts.TryGetProperty("Acceleration", out var a) ? a.GetSingle() : 1f;
radius = verts.TryGetProperty("Radius", out var r) ? r.GetSingle() : 1f;
var pos = verts.GetProperty("Position");
posX = pos.TryGetProperty("X", out var px) ? px.GetSingle() : 0f;
posY = pos.TryGetProperty("Y", out var py) ? py.GetSingle() : 0f;
var entry = GetVertexEntry(subRigIndex, vertexIndex);
x = 0f;
y = 0f;
if (entry.TryGetProperty("Position", out var position))
{
x = GetFloat(position, "X");
y = GetFloat(position, "Y");
}
}
// ------------------------------------------------------------------
// Helpers
// ------------------------------------------------------------------
private JsonElement GetSubRig(int index) =>
_json.RootElement.GetProperty("PhysicsSettings")[index];
private static CubismPhysicsSource ParseSource(string v) => v switch
private JsonElement GetInputEntry(int subRigIndex, int inputIndex) =>
GetSubRig(subRigIndex).GetProperty("Input")[inputIndex];
private JsonElement GetOutputEntry(int subRigIndex, int outputIndex) =>
GetSubRig(subRigIndex).GetProperty("Output")[outputIndex];
private JsonElement GetVertexEntry(int subRigIndex, int vertexIndex) =>
GetSubRig(subRigIndex).GetProperty("Vertices")[vertexIndex];
private int GetEntriesLength(int subRigIndex, string name)
{
"Angle" => CubismPhysicsSource.Angle,
"Y" => CubismPhysicsSource.Y,
_ => CubismPhysicsSource.X
};
var subRig = GetSubRig(subRigIndex);
return subRig.TryGetProperty(name, out var entries) ? entries.GetArrayLength() : 0;
}
private int GetMetaInt(string name) =>
_json.RootElement.TryGetProperty("Meta", out var meta) ? GetInt(meta, name) : 0;
private static float GetFloat(JsonElement element, string name) =>
element.TryGetProperty(name, out var value) ? value.GetSingle() : 0f;
private static int GetInt(JsonElement element, string name) =>
element.TryGetProperty(name, out var value) ? value.GetInt32() : 0;
private static bool GetBool(JsonElement element, string name) =>
element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.True;
private static string GetString(JsonElement element, string name) =>
element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String
? value.GetString()
: string.Empty;
}
}
+262 -126
View File
@@ -6,44 +6,75 @@ namespace Cthangover.Live2D.Cubism.Framework
{
/// <summary>
/// Manages pose-based part visibility from a Cubism .pose3.json file.
/// Poses organize model parts into mutual-exclusion groups — only one
/// part in each group is visible at a time. This is commonly used for
/// eye states (open/closed/surprised) and other binary/multi-state
/// part switches.
/// Pose groups organize model parts into mutual-exclusion groups — only one
/// part in each group is visible at a time. Commonly used for eye states
/// (open/closed/surprised) and other multi-state part switches.
///
/// How it works:
/// This is a faithful port of CubismPose.cpp (Cubism SDK for Native 5-r.5):
/// <list type="number">
/// <item>On model change (detected via ModelPtr comparison), all
/// part groups are reset: the first part in each group gets
/// opacity 1, others get opacity 0</item>
/// <item>Each frame, the currently visible part in each group
/// fades toward opacity 1 while all others fade toward 0</item>
/// <item>Linked parts (defined by the "Link" array in pose data)
/// copy their parent's opacity, cascading visibility changes</item>
/// <item>Part groups are stored as a flattened list with per-group counts
/// (matching the SDK layout, so index arithmetic in Reset/DoFade is identical).</item>
/// <item>Each group part references both a part index (for opacity) and a
/// parameter index (for visibility). The visible part of a group is
/// decided by the PARAMETER VALUE (&gt; Epsilon), not by the part opacity.
/// If a pose parameter/part does not exist in the model, the SDK's
/// "not exist" maps are emulated, and the group falls back to its
/// first part with opacity 1.</item>
/// <item>Reset (on model change): the first part of each group gets
/// parameter value 1 / part opacity 1, all others get 0; linked parts
/// resolve their indices too.</item>
/// <item>DoFade: the visible part fades toward 1 at rate
/// deltaTime / fadeTimeSeconds while all other parts are pushed toward
/// 0 with the SDK's piecewise-linear crossing formula (Phi = 0.5) and
/// back-opacity threshold (0.15). If no part is visible, the group
/// falls back to its first part with opacity 1. If fadeTimeSeconds is
/// 0, the visible part becomes fully opaque immediately.</item>
/// <item>CopyPartOpacities: parts with a "Link" list copy their current
/// part opacity to every linked part.</item>
/// </list>
///
/// The fade uses a constant rate (1 / fadeTimeSeconds per second).
/// A piecewise-linear alpha calculation with a crossing threshold (Phi=0.5)
/// prevents parts from being partially visible during transitions.
/// </summary>
public class CubismPose
{
private struct PartData
private const float Epsilon = 0.001f;
private const float DefaultFadeInSeconds = 0.5f;
private const float Phi = 0.5f;
private const float BackOpacityThreshold = 0.15f;
private sealed class PartData
{
public string PartId;
public int PartIndex;
public int ParameterIndex;
public List<PartData> Link;
public int PartIndex = -1;
public int ParameterIndex = -1;
public readonly List<PartData> Link = new();
/// <summary>
/// SDK CubismPose::PartData::Initialize — resolves both indices for
/// this part id and initializes its pose parameter to 1.
/// </summary>
public void Initialize(CubismNativeModel model, CubismPose owner)
{
ParameterIndex = owner.FindParameterIndex(model, PartId);
PartIndex = owner.FindPartIndex(model, PartId);
owner.SetParameterValue(model, ParameterIndex, PartId, 1f);
}
}
private readonly List<List<PartData>> _partGroups = new();
private readonly float _fadeTimeSeconds;
private readonly List<PartData> _partGroups = new();
private readonly List<int> _partGroupCounts = new();
private float _fadeTimeSeconds = DefaultFadeInSeconds;
private IntPtr _lastModel = IntPtr.Zero;
// Emulation of the SDK's _notExistParameterValues / _notExistPartOpacities maps:
// pose3.json may reference parameters/parts that do not exist in the model.
// They are keyed by id here (the SDK keys parameter entries by synthesized
// indices; per-id keys are equivalent for this emulation).
private readonly Dictionary<string, float> _notExistParameterValues = new();
private readonly Dictionary<string, float> _notExistPartOpacities = new();
/// <summary>
/// Parses a .pose3.json byte buffer.
/// Extracts the global fade time and all part groups with their
/// linked-part chains.
/// Extracts the fade time and all part groups (flattened, with counts per
/// group) including their linked-part chains.
/// </summary>
public CubismPose(byte[] pose3JsonBytes)
{
@@ -51,168 +82,273 @@ namespace Cthangover.Live2D.Cubism.Framework
var json = JsonDocument.Parse(pose3JsonBytes);
var root = json.RootElement;
_fadeTimeSeconds = root.TryGetProperty("FadeInTime", out var ft) ? ft.GetSingle() : 0.5f;
if (!root.TryGetProperty("Groups", out var groups)) return;
if (root.TryGetProperty("FadeInTime", out var ft) && ft.ValueKind == JsonValueKind.Number)
{
_fadeTimeSeconds = ft.GetSingle();
if (_fadeTimeSeconds < 0f)
_fadeTimeSeconds = DefaultFadeInSeconds;
}
if (!root.TryGetProperty("Groups", out var groups) || groups.ValueKind != JsonValueKind.Array) return;
foreach (var group in groups.EnumerateArray())
{
var partList = new List<PartData>();
var groupCount = 0;
foreach (var part in group.EnumerateArray())
{
var pd = new PartData
{
PartId = part.GetProperty("Id").GetString(),
PartIndex = -1,
ParameterIndex = -1,
Link = new List<PartData>()
PartId = part.GetProperty("Id").GetString()
};
if (part.TryGetProperty("Link", out var links))
if (part.TryGetProperty("Link", out var links) && links.ValueKind == JsonValueKind.Array)
{
foreach (var link in links.EnumerateArray())
pd.Link.Add(new PartData { PartId = link.GetString(), PartIndex = -1, ParameterIndex = -1 });
pd.Link.Add(new PartData { PartId = link.GetString() });
}
partList.Add(pd);
_partGroups.Add(pd);
++groupCount;
}
_partGroups.Add(partList);
_partGroupCounts.Add(groupCount);
}
}
/// <summary>
/// Per-frame pose update. Detects model pointer changes to trigger
/// a full reset, then fades parts within each group and propagates
/// opacities to linked parts.
/// Per-frame pose update, mirroring CubismPose::UpdateParameters.
/// Detects model changes to trigger a full reset, then fades each group
/// and propagates opacities to linked parts.
/// Called from <see cref="CubismModelNode.OnUpdate"/> after expressions.
/// </summary>
public unsafe void UpdateParameters(CubismNativeModel model, float deltaTimeSeconds)
public void UpdateParameters(CubismNativeModel model, float deltaTimeSeconds)
{
if (model == null || model.ModelPtr == IntPtr.Zero) return;
// 前回のモデルと同じではないときは初期化が必要
if (_lastModel != model.ModelPtr)
{
_lastModel = model.ModelPtr;
Reset(model);
}
foreach (var group in _partGroups)
DoFade(model, group, deltaTimeSeconds);
_lastModel = model.ModelPtr;
// 設定から時間を変更すると、経過時間がマイナスになることがあるので、経過時間0として対応。
if (deltaTimeSeconds < 0f)
deltaTimeSeconds = 0f;
var beginIndex = 0;
for (int i = 0; i < _partGroupCounts.Count; i++)
{
var partGroupCount = _partGroupCounts[i];
DoFade(model, deltaTimeSeconds, beginIndex, partGroupCount);
beginIndex += partGroupCount;
}
CopyPartOpacities(model);
}
/// <summary>SDK CubismPose::Reset — re-resolves indices and restores the
/// default state: the first part of each group is visible (parameter 1,
/// opacity 1), all others hidden, links re-initialized.</summary>
private unsafe void Reset(CubismNativeModel model)
{
var idsPtr = model.GetPartIds();
var opacities = model.GetPartOpacities();
int partCount = model.GetPartCount();
var beginIndex = 0;
foreach (var group in _partGroups)
for (int gi = 0; gi < _partGroupCounts.Count; gi++)
{
for (int i = 0; i < group.Count; i++)
{
var pd = group[i];
pd.PartIndex = -1;
pd.ParameterIndex = -1;
var groupCount = _partGroupCounts[gi];
for (int pi = 0; pi < partCount; pi++)
for (int j = beginIndex; j < beginIndex + groupCount; j++)
{
if (CubismNativeModel.ReadStringFromPtrArray(idsPtr, pi) == pd.PartId)
var pd = _partGroups[j];
pd.Initialize(model, this);
if (pd.PartIndex < 0)
continue;
SetPartOpacity(model, pd.PartIndex, pd.PartId, j == beginIndex ? 1f : 0f);
SetParameterValue(model, pd.ParameterIndex, pd.PartId, j == beginIndex ? 1f : 0f);
for (int k = 0; k < pd.Link.Count; k++)
pd.Link[k].Initialize(model, this);
}
beginIndex += groupCount;
}
}
/// <summary>SDK CubismPose::DoFade — visibility is driven by the group
/// part PARAMETER values; the visible part fades in while the rest are
/// faded out with the crossing-threshold formula.</summary>
private unsafe void DoFade(CubismNativeModel model, float deltaTimeSeconds, int beginIndex, int partGroupCount)
{
pd.PartIndex = pi;
opacities[pi] = i == 0 ? 1f : 0f;
var visiblePartIndex = -1;
var newOpacity = 1f;
// 現在、表示状態になっているパーツを取得
for (int i = beginIndex; i < beginIndex + partGroupCount; i++)
{
var pd = _partGroups[i];
if (GetParameterValue(model, pd.ParameterIndex, pd.PartId) > Epsilon)
{
if (visiblePartIndex >= 0)
break;
}
}
group[i] = pd;
}
}
}
visiblePartIndex = i;
private unsafe void DoFade(CubismNativeModel model, List<PartData> group, float deltaTimeSeconds)
if (_fadeTimeSeconds == 0f)
{
const float Phi = 0.5f;
const float BackOpacityThreshold = 0.15f;
newOpacity = 1f;
continue;
}
var idsPtr = model.GetPartIds();
var opacities = model.GetPartOpacities();
int partCount = model.GetPartCount();
float newOpacity = 1f;
int visibleIndex = 0;
for (int i = 0; i < group.Count; i++)
{
var pd = group[i];
if (pd.PartIndex < 0) continue;
if (opacities[pd.PartIndex] > 0.001f)
{
visibleIndex = i;
break;
newOpacity = ComputeVisibleOpacity(
GetPartOpacity(model, pd.PartIndex, pd.PartId),
deltaTimeSeconds,
_fadeTimeSeconds);
}
}
for (int i = 0; i < group.Count; i++)
if (visiblePartIndex < 0)
{
var pd = group[i];
if (pd.PartIndex < 0) continue;
visiblePartIndex = 0;
newOpacity = 1f;
}
if (i == visibleIndex)
// 表示パーツ、非表示パーツの不透明度を設定する
for (int i = beginIndex; i < beginIndex + partGroupCount; i++)
{
var fadeRate = deltaTimeSeconds / _fadeTimeSeconds;
newOpacity = MathF.Min(opacities[pd.PartIndex] + fadeRate, 1f);
opacities[pd.PartIndex] = newOpacity;
var pd = _partGroups[i];
if (visiblePartIndex == i)
{
SetPartOpacity(model, pd.PartIndex, pd.PartId, newOpacity); // 先に設定
}
else
{
var opacity = GetPartOpacity(model, pd.PartIndex, pd.PartId);
var a1 = ComputeHiddenOpacity(newOpacity);
if (opacity > a1)
opacity = a1;
SetPartOpacity(model, pd.PartIndex, pd.PartId, opacity);
}
}
}
/// <summary>SDK CubismPose::CopyPartOpacities — every group part with a
/// Link list copies its current part opacity to all linked parts.</summary>
private unsafe void CopyPartOpacities(CubismNativeModel model)
{
foreach (var partData in _partGroups)
{
if (partData.Link.Count == 0)
continue; // 連動するパラメータはない
var opacity = GetPartOpacity(model, partData.PartIndex, partData.PartId);
for (int linkIndex = 0; linkIndex < partData.Link.Count; linkIndex++)
{
var linkPart = partData.Link[linkIndex];
if (linkPart.PartIndex < 0)
continue;
SetPartOpacity(model, linkPart.PartIndex, linkPart.PartId, opacity);
}
}
}
/// <summary>Resolves a part id to a part index, or -1 when absent.</summary>
private unsafe int FindPartIndex(CubismNativeModel model, string partId)
{
var idsPtr = model.GetPartIds();
var partCount = model.GetPartCount();
for (int i = 0; i < partCount; i++)
{
if (CubismNativeModel.ReadStringFromPtrArray(idsPtr, i) == partId)
return i;
}
return -1;
}
/// <summary>Resolves a parameter id to a parameter index, or -1 when absent.</summary>
private unsafe int FindParameterIndex(CubismNativeModel model, string parameterId)
{
var idsPtr = model.GetParameterIds();
var parameterCount = model.ParameterCount;
for (int i = 0; i < parameterCount; i++)
{
if (CubismNativeModel.ReadStringFromPtrArray(idsPtr, i) == parameterId)
return i;
}
return -1;
}
private unsafe float GetParameterValue(CubismNativeModel model, int parameterIndex, string parameterId)
{
if (parameterIndex < 0)
return _notExistParameterValues.TryGetValue(parameterId, out var v) ? v : 0f;
return model.GetParameterValues()[parameterIndex];
}
private unsafe void SetParameterValue(CubismNativeModel model, int parameterIndex, string parameterId, float value)
{
if (parameterIndex < 0)
{
_notExistParameterValues[parameterId] = value;
return;
}
model.GetParameterValues()[parameterIndex] = value;
}
private unsafe float GetPartOpacity(CubismNativeModel model, int partIndex, string partId)
{
if (partIndex < 0)
return _notExistPartOpacities.TryGetValue(partId, out var v) ? v : 0f;
return model.GetPartOpacities()[partIndex];
}
private unsafe void SetPartOpacity(CubismNativeModel model, int partIndex, string partId, float opacity)
{
if (partIndex < 0)
{
_notExistPartOpacities[partId] = opacity;
return;
}
model.GetPartOpacities()[partIndex] = opacity;
}
/// <summary>
/// Pure helper (SDK DoFade, visible branch): the visible part's opacity
/// grows by deltaTimeSeconds / fadeTimeSeconds, clamped to 1.
/// Internal for unit tests.
/// </summary>
internal static float ComputeVisibleOpacity(float currentOpacity, float deltaTimeSeconds, float fadeTimeSeconds)
{
var newOpacity = currentOpacity + (deltaTimeSeconds / fadeTimeSeconds);
return newOpacity > 1f ? 1f : newOpacity;
}
/// <summary>
/// Pure helper (SDK DoFade, hidden branch): piecewise-linear alpha with
/// the Phi = 0.5 crossing and the 0.15 back-opacity threshold.
/// Internal for unit tests.
/// </summary>
internal static float ComputeHiddenOpacity(float newOpacity)
{
float a1;
if (newOpacity < Phi)
a1 = newOpacity * (Phi - 1f) / Phi + 1f;
a1 = newOpacity * (Phi - 1f) / Phi + 1f; // (0,1),(phi,phi)を通る直線式
else
a1 = (1f - newOpacity) * Phi / (1f - Phi);
a1 = (1f - newOpacity) * Phi / (1f - Phi); // (1,0),(phi,phi)を通る直線式
var backOpacity = (1f - a1) * (1f - newOpacity);
if (backOpacity > BackOpacityThreshold)
a1 = 1f - BackOpacityThreshold / (1f - newOpacity);
opacities[pd.PartIndex] = MathF.Min(opacities[pd.PartIndex], a1);
}
}
}
private unsafe void CopyPartOpacities(CubismNativeModel model)
{
var idsPtr = model.GetPartIds();
var opacities = model.GetPartOpacities();
int partCount = model.GetPartCount();
foreach (var group in _partGroups)
{
for (int gi = 0; gi < group.Count; gi++)
{
var pd = group[gi];
if (pd.PartIndex < 0 || pd.Link == null) continue;
var parentOpacity = opacities[pd.PartIndex];
for (int li = 0; li < pd.Link.Count; li++)
{
var link = pd.Link[li];
for (int pi = 0; pi < partCount; pi++)
{
if (CubismNativeModel.ReadStringFromPtrArray(idsPtr, pi) == link.PartId)
{
link.PartIndex = pi;
opacities[pi] = parentOpacity;
pd.Link[li] = link;
break;
}
}
}
}
}
return a1;
}
}
}
+112 -21
View File
@@ -3,6 +3,7 @@ using System.Buffers.Binary;
using System.Collections.Generic;
using Cthangover.Core.Utils;
using Godot;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Live2D.Cubism.Core;
namespace Cthangover.Live2D.Cubism.Framework
@@ -187,9 +188,18 @@ namespace Cthangover.Live2D.Cubism.Framework
GameLogger.Log("LIVE2D", $"BuildModel: drawable[{di}] opacity={opacity:F3}");
var shader = ResolveShaderForDrawable(di, constantFlags, maskCounts);
if (shader == null)
{
// Shader failed to resolve — skip this drawable instead of
// building a material-less mesh (guards downstream NREs and
// keeps the rest of the model rendering).
GameLogger.Log("LIVE2D", $"BuildModel: drawable[{di}] SKIPPED — shader not resolved", LogLevel.Error);
_meshInstances[di] = null;
_materials[di] = null;
continue;
}
ShaderMaterial mat = null;
if (shader != null)
ShaderMaterial mat;
{
mat = new ShaderMaterial { Shader = shader };
mat.SetShaderParameter("color_base", new Color(1f, 1f, 1f, 1f));
@@ -221,15 +231,8 @@ namespace Cthangover.Live2D.Cubism.Framework
mi.Material = mat;
}
if (texture != null)
{
mat.SetShaderParameter("tex_main", texture);
GameLogger.Log("LIVE2D", $"BuildModel: drawable[{di}] texture Ok");
}
else
{
GameLogger.Log("LIVE2D", $"BuildModel: drawable[{di}] texture MISSING (texIndex={texIndex})");
}
if (texture == null)
GameLogger.Log("LIVE2D", $"BuildModel: drawable[{di}] texture MISSING (texIndex={texIndex})", LogLevel.Warning);
mi.Visible = true;
_parentNode.AddChild(mi);
@@ -428,6 +431,8 @@ namespace Cthangover.Live2D.Cubism.Framework
var maskTexIndex = model.GetTextureIndices()[drawableIndex];
var texture = maskTexIndex >= 0 && maskTexIndex < model.Textures.Length ? model.Textures[maskTexIndex] : null;
mi2d.Mesh = mesh;
var shader = ResolveShader("2d_cubism_mask");
if (shader == null) return mesh;
@@ -436,7 +441,6 @@ namespace Cthangover.Live2D.Cubism.Framework
if (texture != null)
mat.SetShaderParameter("tex_main", texture);
mi2d.Mesh = mesh;
mi2d.Material = mat;
return mesh;
}
@@ -478,6 +482,8 @@ namespace Cthangover.Live2D.Cubism.Framework
mi.ZIndex = renderOrders[di];
if (mat != null)
{
var mc = multiplyColors[di];
mat.SetShaderParameter("color_multiply", new Color(mc.X, mc.Y, mc.Z, mc.W));
@@ -486,6 +492,7 @@ namespace Cthangover.Live2D.Cubism.Framework
if ((flags & CubismCoreBindings.OpacityDidChange) != 0)
mat.SetShaderParameter("color_base", new Color(1f, 1f, 1f, opacities[di]));
}
if ((flags & CubismCoreBindings.VertexPositionsDidChange) != 0)
{
@@ -493,21 +500,103 @@ namespace Cthangover.Live2D.Cubism.Framework
}
}
UpdateMaskGroups(dynamicFlags, dc);
_model.ResetDynamicFlags();
}
/// <summary>
/// Per-frame mask refresh, mirroring CubismRenderer::DrawClip:
/// the clip bounding box is recomputed from the CURRENT vertex positions
/// of the mask drawables each frame. When the clip rect moves, the mask
/// meshes are re-offset into the SubViewport space and the masked
/// drawables' mesh_offset shader parameter is refreshed; when the rect
/// outgrows the render target, the SubViewport is resized (power of two,
/// so this stays rare). Without flag changes and offset movement the
/// per-drawable work is skipped.
/// </summary>
private unsafe void UpdateMaskGroups(byte* dynamicFlags, int dc)
{
if (_maskGroups.Count == 0) return;
var vertexCounts = _model.GetVertexCounts();
var vertexPositions = _model.GetVertexPositions();
var ppu = _model.PixelsPerUnit;
for (int gi = 0; gi < _maskGroups.Count; gi++)
{
var group = _maskGroups[gi];
if (group.MaskDrawableIndices == null) continue;
for (int mi = 0; mi < group.MaskDrawableIndices.Length; mi++)
var maskIndices = group.MaskDrawableIndices;
if (maskIndices == null || maskIndices.Length == 0) continue;
// Current bounds over the mask drawables (pixel space).
float minX = float.MaxValue, minY = float.MaxValue, maxX = float.MinValue, maxY = float.MinValue;
var any = false;
for (int mi = 0; mi < maskIndices.Length; mi++)
{
var di = group.MaskDrawableIndices[mi];
var di = maskIndices[mi];
if (di < 0 || di >= dc) continue;
var mFlags = dynamicFlags[di];
if ((mFlags & CubismCoreBindings.VertexPositionsDidChange) == 0) continue;
var vCount = vertexCounts[di];
var vp = vertexPositions[di];
for (int v = 0; v < vCount; v++)
{
float x = vp[v].X * ppu;
float y = -vp[v].Y * ppu;
if (x < minX) minX = x;
if (y < minY) minY = y;
if (x > maxX) maxX = x;
if (y > maxY) maxY = y;
any = true;
}
}
if (!any) continue;
const float pad = 4f;
float newOffsetX = minX - pad;
float newOffsetY = minY - pad;
float needed = Math.Max(maxX - minX + pad * 2f, maxY - minY + pad * 2f);
needed = Math.Max(needed, 64f);
int neededSize = NextPowerOfTwo((int)Mathf.Ceil(needed));
bool offsetChanged = newOffsetX != group.OffsetX || newOffsetY != group.OffsetY;
bool sizeChanged = neededSize != group.ViewportSize;
if (sizeChanged)
{
group.ViewportSize = neededSize;
group.Viewport.Size = new Vector2I(neededSize, neededSize);
}
if (offsetChanged)
{
group.OffsetX = newOffsetX;
group.OffsetY = newOffsetY;
}
_maskGroups[gi] = group;
// Rewrite mask mesh vertices when the mask drawable moved or the
// clip rect moved (offsets are baked into the vertex positions).
for (int mi = 0; mi < maskIndices.Length; mi++)
{
var di = maskIndices[mi];
if (di < 0 || di >= dc) continue;
bool flagged = (dynamicFlags[di] & CubismCoreBindings.VertexPositionsDidChange) != 0;
if (!flagged && !offsetChanged) continue;
UpdateMaskMeshVertices(di, group.MaskArrayMeshes[mi], gi);
}
}
_model.ResetDynamicFlags();
// Masked drawables sample the mask texture through mesh_offset;
// refresh it whenever the clip rect moved or was resized.
if (offsetChanged || sizeChanged)
{
for (int di = 0; di < dc; di++)
{
if (_drawableMaskGroupIndex == null || di >= _drawableMaskGroupIndex.Length) break;
if (_drawableMaskGroupIndex[di] != gi) continue;
var mat = _materials[di];
if (mat != null)
mat.SetShaderParameter("mesh_offset", new Vector2(group.OffsetX, group.OffsetY));
}
}
}
}
private unsafe void UpdateMeshVertices(int drawableIndex, ArrayMesh mesh)
@@ -594,7 +683,7 @@ namespace Cthangover.Live2D.Cubism.Framework
_drawableMaskChannel = null;
foreach (var group in _maskGroups)
group.Viewport.QueueFree();
group.Viewport?.QueueFree();
_maskGroups.Clear();
}
@@ -631,7 +720,9 @@ namespace Cthangover.Live2D.Cubism.Framework
{
try
{
var shader = Cthangover.Core.Mods.ModManager.Instance?.ResolveShader(name);
// Canonical resource path: shader resolution goes through the mod
// resolver system (ShaderResolver via ModManager.GetResolver).
var shader = Cthangover.Core.Mods.ModManager.Instance?.GetResolver<ShaderResolver>()?.Resolve(name);
if (shader != null)
{
GameLogger.Log("LIVE2D", $"ResolveShader: '{name}' OK");
@@ -7,20 +7,19 @@ namespace Cthangover.Live2D.Cubism.Framework
/// Drives the ParamAngleX/Y and ParamEyeBallX/Y model parameters in
/// <see cref="CubismModelNode"/>.
///
/// Uses a velocity-based smoothing approach: each update computes a
/// desired velocity toward the target, applies acceleration limits,
/// enforces a maximum safe approach speed to prevent overshoot near
/// the target, and integrates velocity into position.
///
/// The tracking is frame-rate independent — delta time is scaled by
/// a factor of 30 (matching Cubism's internal 30fps reference).
/// Acceleration and max velocity are clamped per Cubism SDK conventions
/// (FaceParamMaxV = 40, FrameToMaxSpeed = 4.5).
/// Bit-exact port of Cubism SDK for Native 5-r.5 Math/CubismTargetPoint.cpp:
/// a velocity-based follower that accelerates toward the target with a
/// capped acceleration (reaching top speed in 0.15 s), then smoothly
/// decelerates so the configured acceleration can still brake before the
/// target. All time is expressed in internal 30 fps frame units.
/// </summary>
public class CubismTargetPoint
{
private const float FaceParamMaxV = 40f;
private const float FrameToMaxSpeed = 4.5f;
/// <summary>Internal reference frame rate (SDK FrameRate).</summary>
private const int FrameRate = 30;
/// <summary>Threshold below which no movement is applied (SDK Epsilon).</summary>
private const float Epsilon = 0.01f;
private float _faceTargetX, _faceTargetY;
private float _faceX, _faceY;
@@ -45,54 +44,92 @@ namespace Cthangover.Live2D.Cubism.Framework
}
/// <summary>
/// Advances the smoothing simulation by one frame.
/// Computes delta-time-weighted velocity toward target, applies
/// acceleration clamping and safe approach speed limiting, then
/// integrates position.
///
/// Called each frame from <see cref="CubismModelNode.OnUpdate"/>
/// before injecting face-tracking parameters.
/// Advances the smoothing simulation by one frame (SDK CubismTargetPoint::Update).
/// The very first call only anchors the internal clock and performs no
/// integration, matching the native implementation.
/// </summary>
public void Update(float deltaTimeSeconds)
{
// Add the delta time.
_userTimeSeconds += deltaTimeSeconds;
var deltaTimeWeight = (_userTimeSeconds - _lastTimeSeconds) * 30f;
// The average speed of swinging the head from center to side is about 1 second.
// Considering acceleration/deceleration, twice that is the maximum speed.
// The face direction is 0.0 at center and +-1.0 at the sides.
const float FaceParamMaxV = 40f / 10f; // Moves 40 in 7.5 seconds (5.3/sc)
float MaxV = FaceParamMaxV * 1f / FrameRate; // Upper speed limit per frame
// First call only anchors the clock; no integration (SDK behavior).
if (_lastTimeSeconds == 0f)
{
_lastTimeSeconds = _userTimeSeconds;
return;
}
float deltaTimeWeight = (_userTimeSeconds - _lastTimeSeconds) * FrameRate;
_lastTimeSeconds = _userTimeSeconds;
if (deltaTimeWeight <= 0f) return;
// Time until maximum speed, expressed in internal frames.
const float TimeToMaxSpeed = 0.15f;
const float FrameToMaxSpeed = TimeToMaxSpeed * FrameRate; // sec * frame/sec
float MaxA = deltaTimeWeight * MaxV / FrameToMaxSpeed; // Acceleration per frame
var maxV = FaceParamMaxV / 30f;
var maxA = deltaTimeWeight * maxV / FrameToMaxSpeed;
// The target direction is the vector (dx, dy).
float dx = _faceTargetX - _faceX;
float dy = _faceTargetY - _faceY;
var dx = _faceTargetX - _faceX;
var dy = _faceTargetY - _faceY;
var distance = MathF.Sqrt(dx * dx + dy * dy);
if (distance < 0.01f) return;
var vx = maxV * dx / distance;
var vy = maxV * dy / distance;
var ax = vx - _faceVX;
var ay = vy - _faceVY;
var aLen = MathF.Sqrt(ax * ax + ay * ay);
if (aLen > 0f)
if (MathF.Abs(dx) <= Epsilon && MathF.Abs(dy) <= Epsilon)
{
var clampedA = MathF.Min(aLen, maxA);
ax = ax * clampedA / aLen;
ay = ay * clampedA / aLen;
return; // No change.
}
var maxSafeV = 0.5f * (MathF.Sqrt(maxA * maxA + 8f * maxA * distance) - maxA);
var vLen = MathF.Sqrt(_faceVX * _faceVX + _faceVY * _faceVY);
if (vLen > maxSafeV && vLen > 0f)
// Slow down when the distance requires more than the maximum speed.
float d = MathF.Sqrt((dx * dx) + (dy * dy));
// Maximum speed vector toward the target.
float vx = MaxV * dx / d;
float vy = MaxV * dy / d;
// Acceleration needed to change from the current velocity to the new one.
float ax = vx - _faceVX;
float ay = vy - _faceVY;
float a = MathF.Sqrt((ax * ax) + (ay * ay));
// While accelerating.
if (a < -MaxA || a > MaxA)
{
var scale = maxSafeV / vLen;
_faceVX *= scale;
_faceVY *= scale;
ax *= MaxA / a;
ay *= MaxA / a;
}
// Add the acceleration to the original velocity to get the new velocity.
_faceVX += ax;
_faceVY += ay;
// When approaching the target direction, decelerate smoothly:
// from the relation between acceleration, speed and distance, compute
// the maximum speed that can still be stopped with the configured
// acceleration, and clamp the current speed to it.
{
// Relation between acceleration, velocity and distance.
// 2 6 2 3
// sqrt(a t + 16 a h t - 8 a h) - a t
// v = --------------------------------------
// 2
// 4 t - 2
// (t=1)
float maxV = 0.5f * (MathF.Sqrt((MaxA * MaxA) + 16f * MaxA * d - 8f * MaxA * d) - MaxA);
float curV = MathF.Sqrt((_faceVX * _faceVX) + (_faceVY * _faceVY));
if (curV > maxV)
{
// Current speed > maximum speed: decelerate to the maximum.
_faceVX *= maxV / curV;
_faceVY *= maxV / curV;
}
}
_faceX += _faceVX;
_faceY += _faceVY;
}
+10 -2
View File
@@ -1,4 +1,4 @@
using Godot;
using Godot;
using Cthangover.Live2D.Cubism.Framework;
namespace Cthangover.Live2D.Cubism.Services
@@ -19,6 +19,13 @@ namespace Cthangover.Live2D.Cubism.Services
public float AnchorX = 0.5f;
public float AnchorY = 0.5f;
public FitMode ScaleFit = FitMode.FitAuto;
/// <summary>
/// Enables the automatic eye-blink effect. Disable for pixel-deterministic
/// rendering (screenshot/visual tests): the blink fires on a random timer
/// and would otherwise change pixels between captures.
/// </summary>
public bool EnableEyeBlink = true;
}
/// <summary>
@@ -68,7 +75,8 @@ namespace Cthangover.Live2D.Cubism.Services
Name = config.NodeName,
AnchorX = config.AnchorX,
AnchorY = config.AnchorY,
ScaleFit = fitMode
ScaleFit = fitMode,
EnableEyeBlink = config.EnableEyeBlink
};
parent.AddChild(model);
+6
View File
@@ -0,0 +1,6 @@
using System.Runtime.CompilerServices;
// Exposes internal pure-math/parsing helpers of the Live2D mod to the game's
// unit test project (tests/Cthangover.Tests). Internal members are the test
// seam; public runtime API stays unchanged.
[assembly: InternalsVisibleTo("Cthangover.Tests")]
+1
View File
@@ -4,6 +4,7 @@ using Cthangover.Core.Utils;
using Godot;
using Cthangover.Live2D.Cubism.Services;
using Cthangover.Mods.Interface.View;
namespace Mods.Live2D
{
public class AddLive2DModelAction : IScenarioAction