diff --git a/dll/Live2D.deps.json b/dll/Live2D.deps.json index 7a4e42a..e0e3e16 100644 --- a/dll/Live2D.deps.json +++ b/dll/Live2D.deps.json @@ -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", diff --git a/dll/Live2D.dll b/dll/Live2D.dll index 40bc7e9..4d16d25 100644 Binary files a/dll/Live2D.dll and b/dll/Live2D.dll differ diff --git a/dll/Live2D.pdb b/dll/Live2D.pdb index fc6edba..802ae23 100644 Binary files a/dll/Live2D.pdb and b/dll/Live2D.pdb differ diff --git a/locale/en.properties b/locale/en.properties new file mode 100644 index 0000000..636bff6 --- /dev/null +++ b/locale/en.properties @@ -0,0 +1,2 @@ +mod_live2d_name = Live2D Cubism +mod_live2d_desc = Live2D Cubism C# runtime: model loading, rendering, physics, animation, effects. diff --git a/locale/ru.properties b/locale/ru.properties new file mode 100644 index 0000000..cdd84d2 --- /dev/null +++ b/locale/ru.properties @@ -0,0 +1,2 @@ +mod_live2d_name = Live2D Cubism +mod_live2d_desc = C#-рантайм Live2D Cubism: загрузка и отрисовка моделей, физика, анимации и эффекты. diff --git a/manifest.json b/manifest.json index 70a7f7e..03e093c 100644 --- a/manifest.json +++ b/manifest.json @@ -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"] } diff --git a/mod.png b/mod.png index d112f2e..be69cc8 100644 Binary files a/mod.png and b/mod.png differ diff --git a/native/Live2DCubismCore.lib b/native/Live2DCubismCore.lib new file mode 100644 index 0000000..8327883 Binary files /dev/null and b/native/Live2DCubismCore.lib differ diff --git a/native/libLive2DCubismCore.so b/native/libLive2DCubismCore.so new file mode 100644 index 0000000..b4df6fb Binary files /dev/null and b/native/libLive2DCubismCore.so differ diff --git a/source_code/Cubism/Core/CubismCoreBindings.cs b/source_code/Cubism/Core/CubismCoreBindings.cs index 1deec03..35694e4 100644 --- a/source_code/Cubism/Core/CubismCoreBindings.cs +++ b/source_code/Cubism/Core/CubismCoreBindings.cs @@ -71,6 +71,19 @@ namespace Cthangover.Live2D.Cubism.Core [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] public static extern unsafe IntPtr csmReviveMocInPlace(void* address, uint size); + /// Native log callback: receives a pointer to a NUL-terminated csmChar* message. + [UnmanagedFunctionPointer(CallingConvention.StdCall)] + public delegate void CsmLogDelegate(IntPtr messagePtr); + + /// + /// 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. + /// + [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] + public static extern void csmSetLogFunction(CsmLogDelegate logFunction); + /// Queries the memory size needed to initialize a model from the given moc. [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] public static extern unsafe uint csmGetSizeofModel(IntPtr moc); diff --git a/source_code/Cubism/Framework/ACubismMotion.cs b/source_code/Cubism/Framework/ACubismMotion.cs index b05da30..1794886 100644 --- a/source_code/Cubism/Framework/ACubismMotion.cs +++ b/source_code/Cubism/Framework/ACubismMotion.cs @@ -1,17 +1,21 @@ using System; +using System.Collections.Generic; namespace Cthangover.Live2D.Cubism.Framework { /// /// Abstract base for all Live2D Cubism motions (animations and expressions). - /// Provides fade-in/fade-out timing, looping, weight blending, and event - /// callbacks shared across and - /// . - /// - /// Motions are managed by , which - /// calls on start, then - /// and - /// every frame. + /// Порт ACubismMotion из Cubism SDK for Native 5-r.5: владеет временами фейда, + /// весом, цикличностью и колбэками; очередь () + /// вызывает раз в кадр, тот в свою очередь — + /// (при старте), + /// и виртуальный . + /// + /// Завершение выставляется по EndTime: сам моушен ставит IsFinished в + /// DoUpdateParameters (незацикленный), а обёртка + /// после применения параметров закрывает запись, когда текущее время прошло EndTime + /// (например, после запущенного через очередь fade-out). Колбэки при этом не + /// дублируются: OnFinishedMotion вызывается только самим моушеном. /// public abstract class ACubismMotion { @@ -36,6 +40,9 @@ namespace Cthangover.Live2D.Cubism.Framework protected bool _isLoopFadeIn = true; protected bool _previousLoopState; + /// Сработавшие события (UserData); наполняется в . + protected readonly List _firedEventValues = new(); + /// /// 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 /// /// Invoked when the motion finishes (non-looping only). - /// Automatically called by - /// when timeOffset exceeds duration. + /// Автоматически вызывается самим моушеном в DoUpdateParameters по достижении + /// длительности; очередь этот колбэк не дублирует. /// public FinishedMotionCallback OnFinishedMotion { get; set; } @@ -101,73 +108,111 @@ namespace Cthangover.Live2D.Cubism.Framework public virtual float GetLoopDuration() => -1f; /// - /// Initializes a queue entry with start/end times based on current - /// , , - /// and . Fires . + /// Порт ACubismMotion::UpdateParameters — кадровая обёртка, вызываемая очередью. + /// Проверяет доступность записи, инициализирует её при старте, считает fade-вес, + /// применяет параметры и после этого закрывает запись, если время прошло EndTime. /// - public void SetupMotionQueueEntry(CubismMotionQueueEntry entry, float userTimeSeconds) + public void UpdateParameters(CubismNativeModel model, CubismMotionQueueEntry entry, float userTimeSeconds) { - var fadeIn = _fadeInSeconds >= 0f ? _fadeInSeconds : 1f; - entry.StartTime = userTimeSeconds - _offsetSeconds; - entry.FadeInStartTime = userTimeSeconds; - var duration = GetDuration(); - UpdateEndTime(entry, duration); - OnBeganMotion?.Invoke(this); - } - - /// - /// Computes the final frame weight = x fadeIn x fadeOut. - /// Fade curves use the sine easing function from . - /// - public float UpdateFadeWeight(CubismMotionQueueEntry entry, float userTimeSeconds) - { - var fadeIn = GetFadeIn(entry, userTimeSeconds); - var fadeOut = GetFadeOut(entry, userTimeSeconds); - return _weight * fadeIn * fadeOut; - } - - 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); - } - - 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); - } - - /// - /// Sets the queue entry's EndTime based on loop state and duration. - /// Looping motions get EndTime = -1 (infinite). - /// - protected void UpdateEndTime(CubismMotionQueueEntry entry, float duration) - { - entry.EndTime = _isLoop ? -1f : entry.StartTime + duration; - } - - /// - /// Recalculates EndTime when changes mid-playback. - /// Called by every frame - /// to detect loop state transitions. - /// - protected void AdjustEndTime(CubismMotionQueueEntry entry, float duration) - { - if (_isLoop != _previousLoopState) + if (!entry.IsAvailable || entry.IsFinished) { - _previousLoopState = _isLoop; - UpdateEndTime(entry, duration); + 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; } } + /// + /// Порт ACubismMotion::SetupMotionQueueEntry: при первом кадре записывает + /// StartTime (с учётом OffsetSeconds), FadeInStartTime и EndTime + /// (если EndTime ещё не выставлен извне, например фейдаутом от очереди). + /// + public void SetupMotionQueueEntry(CubismMotionQueueEntry entry, float userTimeSeconds) + { + if (!entry.IsAvailable || entry.IsFinished) + { + return; + } + + if (entry.IsStarted) + { + return; + } + + entry.IsStarted = true; + entry.StartTime = userTimeSeconds - _offsetSeconds; + entry.FadeInStartTime = userTimeSeconds; + + // EndTime мог быть выставлен заранее (SetFadeout до старта). + if (entry.EndTime < 0f) + { + AdjustEndTime(entry); + } + + OnBeganMotion?.Invoke(this); + } + + /// + /// Порт ACubismMotion::UpdateFadeWeight: вес = Weight x fadeIn x fadeOut + /// с синус-эйзингом. При fadeIn/fadeOut == 0 соответствующий множитель = 1. + /// + public float UpdateFadeWeight(CubismMotionQueueEntry entry, float userTimeSeconds) + { + if (entry == null) + { + return -1f; + } + + float fadeWeight = _weight; + + 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; + } + + /// + /// Порт ACubismMotion::AdjustEndTime: EndTime = StartTime + GetDuration(); + /// для цикличных моушенов (GetDuration() <= 0) EndTime = -1 (бесконечность). + /// + protected void AdjustEndTime(CubismMotionQueueEntry entry) + { + float duration = GetDuration(); + float endTime = duration <= 0f ? -1f : entry.StartTime + duration; + entry.EndTime = endTime; + } + + /// + /// Порт ACubismMotion::GetFiredEvent: базовая реализация не содержит событий. + /// переопределяет и наполняет список по UserData. + /// + public virtual List GetFiredEvent(float beforeCheckTimeSeconds, float motionTimeSeconds) + { + return _firedEventValues; + } + /// /// Applies motion parameter values to the model for the current frame. - /// Called every frame by . + /// Called every frame by . /// /// The native Cubism model to modify. /// Elapsed user time in seconds. diff --git a/source_code/Cubism/Framework/CubismExpressionMotion.cs b/source_code/Cubism/Framework/CubismExpressionMotion.cs index 16b4e05..da98dd4 100644 --- a/source_code/Cubism/Framework/CubismExpressionMotion.cs +++ b/source_code/Cubism/Framework/CubismExpressionMotion.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using System.Text.Json; @@ -30,19 +29,27 @@ namespace Cthangover.Live2D.Cubism.Framework /// /// Expression motion, parsed from a Cubism .exp3.json file. /// Inherits fade and weight management from . - /// - /// 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-вес: /// - /// — adds value * weight - /// — multiplies by 1 + value * weight - /// — lerps from current to target using weight + /// — value + expValue * weight + /// — value * (1 + (expValue - 1) * weight) + /// — value * (1 - weight) + expValue * weight /// - /// - /// Loaded and started by . + /// + /// Как и в SDK, результат зажимается в [min, max] параметра (порт + /// CubismModel::SetParameterValue), а параметры применяются в порядке их + /// перечисления в exp3.json: при нескольких записях с одним Id побеждает последняя. + /// Fade-время по умолчанию — 1 c (DefaultFadeTime), если в файле не задано. + /// + /// Загружается и запускается через . /// public class CubismExpressionMotion : ACubismMotion { + /// Порт DefaultFadeTime из CubismExpressionMotion.cpp. + private const float DefaultFadeTime = 1f; + private readonly List _parameters = new(); /// @@ -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 indexById = new Dictionary(model.ParameterCount); + for (int i = 0; i < model.ParameterCount; i++) + indexById[CubismNativeModel.ReadStringFromPtrArray(idsPtr, i)] = i; + + // Порт: цикл по параметрам выражения (в порядке exp3.json). + foreach (var ep in _parameters) { - var pid = CubismNativeModel.ReadStringFromPtrArray(idsPtr, pi); - 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; - break; - case ExpressionBlendType.Multiply: - values[pi] = values[pi] * (1f + ep.Value * weight); - break; - case ExpressionBlendType.Overwrite: - values[pi] = values[pi] * (1f - weight) + ep.Value * weight; - break; - } - break; + switch (ep.BlendType) + { + case ExpressionBlendType.Additive: + // AddParameterValue: value + (expValue * weight) + values[pi] = ClampParameterValue(values[pi] + ep.Value * weight, minValues[pi], maxValues[pi]); + break; + case ExpressionBlendType.Multiply: + // MultiplyParameterValue: value * (1 + (expValue - 1) * weight) + values[pi] = ClampParameterValue(values[pi] * (1f + (ep.Value - 1f) * weight), minValues[pi], maxValues[pi]); + break; + case ExpressionBlendType.Overwrite: + // SetParameterValue(id, value, weight): expValue зажимается, затем лерп + values[pi] = values[pi] * (1f - weight) + + ClampParameterValue(ep.Value, minValues[pi], maxValues[pi]) * weight; + break; } } } + + /// Порт GetParameterClampValue — зажим значения параметра в [min, max]. + private static float ClampParameterValue(float value, float min, float max) + { + if (value < min) return min; + if (value > max) return max; + return value; + } } } diff --git a/source_code/Cubism/Framework/CubismModelMatrix.cs b/source_code/Cubism/Framework/CubismModelMatrix.cs index dd23988..e1f83e7 100644 --- a/source_code/Cubism/Framework/CubismModelMatrix.cs +++ b/source_code/Cubism/Framework/CubismModelMatrix.cs @@ -5,20 +5,29 @@ using Godot; namespace Cthangover.Live2D.Cubism.Framework { /// - /// Manages the 2D transformation (scale, translation, alignment) for a - /// Cubism model rendered inside a Godot . - /// - /// Works with two coordinate systems: + /// 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. + /// + /// SDK semantics to be aware of: /// - /// Cubism canvas coordinates (the model's native coordinate space) - /// Godot node local coordinates (where the model is placed) + /// and MULTIPLY the + /// current scale by target / canvas-size (they do not assign it), + /// exactly like the C++ original. + /// The (width, height) constructor performs + /// SetHeight(2.0f) like the SDK, i.e. the model is initially + /// scaled to occupy 2 logical units of height. + /// 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). + /// CenterX/CenterY/Bottom/Right compute their offset from the full + /// scaled size (canvas size × current scale). /// - /// - /// The scale is computed relative to the canvas dimensions stored at construction. - /// and set a uniform scale based - /// on the ratio of target size to canvas size. reads - /// positioning keys from a Cubism .model3.json "Layout" section. - /// writes the final transform to a Godot Node2D. + /// + /// reads positioning keys from a Cubism + /// .model3.json "Layout" section; writes the final + /// transform to a Godot . /// public class CubismModelMatrix { @@ -33,68 +42,80 @@ namespace Cthangover.Live2D.Cubism.Framework public CubismModelMatrix() { } /// - /// Creates a matrix referenced to the given canvas pixel dimensions. - /// Subsequent calls to and - /// will compute scale as target / canvas. + /// Creates a matrix referenced to the given canvas dimensions. + /// Like the SDK constructor, this calls (2.0f), + /// so the model initially occupies 2 logical units of height. /// public CubismModelMatrix(float width, float height) { _width = width; _height = height; + + SetHeight(2.0f); } - /// Current uniform scale on the X axis. + /// Current scale on the X axis. public float ScaleX => _scaleX; - /// Current uniform scale on the Y axis. + /// Current scale on the Y axis. public float ScaleY => _scaleY; - /// Current X translation offset in Godot local coordinates. + /// Current X translation offset. public float TranslateX => _translateX; - /// Current Y translation offset in Godot local coordinates. + /// Current Y translation offset. public float TranslateY => _translateY; + /// SDK CubismModelMatrix::Scale — multiplies the current scale. + public void Scale(float scaleX, float scaleY) + { + _scaleX *= scaleX; + _scaleY *= scaleY; + } + /// - /// 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). /// 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); } /// - /// 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). /// public void SetHeight(float h) { - if (_height > 0f) - { - var scale = h / _height; - _scaleX = scale; - _scaleY = scale; - } + if (_height <= 0f) return; + + var scaleX = h / _height; + var scaleY = scaleX; + Scale(scaleX, scaleY); } - /// Sets absolute translation position. - public void SetPosition(float x, float y) + /// 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). + public void Translate(float x, float y) { - _translateX = x; - _translateY = y; + _translateX += x; + _translateY += y; } - /// Sets absolute X translation. - public void SetX(float x) => _translateX = x; + /// SDK SetPosition — incremental (adds to the current translation). + public void SetPosition(float x, float y) => Translate(x, y); - /// Sets absolute Y translation. - public void SetY(float y) => _translateY = y; + /// SDK SetX — incremental (adds to the current X translation). + public void SetX(float x) => _translateX += x; + + /// SDK SetY — incremental (adds to the current Y translation). + public void SetY(float y) => _translateY += y; /// /// Centers the model horizontally and vertically at the given point. @@ -106,34 +127,45 @@ namespace Cthangover.Live2D.Cubism.Framework CenterY(y); } - /// - /// Centers horizontally: translateX = x - (width * scaleX) / 2. - /// - public void CenterX(float x) => _translateX = x - (_width * _scaleX) / 2f; + /// SDK CenterX — adds x - (width × scaleX) / 2 to the X translation. + public void CenterX(float x) + { + var w = _width * _scaleX; + _translateX += x - (w / 2f); + } + + /// SDK CenterY — adds y - (height × scaleY) / 2 to the Y translation. + public void CenterY(float y) + { + var h = _height * _scaleY; + _translateY += y - (h / 2f); + } + + /// SDK Top — adds y to the Y translation (via SetY). + public void Top(float y) => SetY(y); + + /// SDK Bottom — adds y - (height × scaleY) to the Y translation. + public void Bottom(float y) + { + var h = _height * _scaleY; + _translateY += y - h; + } + + /// SDK Left — adds x to the X translation (via SetX). + public void Left(float x) => SetX(x); + + /// SDK Right — adds x - (width × scaleX) to the X translation. + public void Right(float x) + { + var w = _width * _scaleX; + _translateX += x - w; + } /// - /// Centers vertically: translateY = y - (height * scaleY) / 2. - /// - public void CenterY(float y) => _translateY = y - (_height * _scaleY) / 2f; - - /// Aligns the top edge of the model to y. - public void Top(float y) => _translateY = y; - - /// Aligns the bottom edge of the model to y. - public void Bottom(float y) => _translateY = y - _height * _scaleY; - - /// Aligns the left edge of the model to x. - public void Left(float x) => _translateX = x; - - /// Aligns the right edge of the model to x. - public void Right(float x) => _translateX = x - _width * _scaleX; - - /// - /// 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". /// public void SetupFromLayout(Dictionary layout) { diff --git a/source_code/Cubism/Framework/CubismModelNode.cs b/source_code/Cubism/Framework/CubismModelNode.cs index 169dae0..98aab0b 100644 --- a/source_code/Cubism/Framework/CubismModelNode.cs +++ b/source_code/Cubism/Framework/CubismModelNode.cs @@ -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 _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 _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 _parameterIndexById = new(); + + private FaceTrackEntry[] _faceTrackCache = System.Array.Empty(); + + private readonly List _eyeBlinkIds = new(); + private readonly List _lipSyncIds = new(); + + /// One precomputed face-track injection: values[index] = targetAxis * scale. + private struct FaceTrackEntry + { + public int Index; + public string Id; + public bool FromTargetX; + public float Scale; + } /// /// 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("/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 /// /// Per-frame update called by the SceneEventController. - /// Execution order: + /// Execution order (SDK LAppModel::Update): /// /// Apply deferred layout (first valid frame only) /// Update for face tracking - /// Inject face-tracking parameters (ParamAngleX/Y, ParamEyeBallX/Y) - /// into the native model + /// Restore the parameter snapshot (LoadParameters) /// Evaluate motion queue (character animations) - /// Evaluate expression queue (facial expressions) - /// Run effect layers in order: breath, eye blink, pose, physics + /// Save the parameter snapshot (SaveParameters) — additive layers + /// (expressions, breath, face-track) start every frame from the same + /// baseline and never compound frame-over-frame + /// Run effect layers in SDK 5 scheduler order (ICubismUpdater.hpp): + /// eye blink → expression → look/face-track → breath → physics → pose /// Call csmUpdateModel on the native model /// Update the renderer (visibility, opacity, vertex positions) /// @@ -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(); } + /// + /// 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 — is unreachable otherwise. + /// + 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; + } + + /// + /// 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 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 + /// keeps the addition from accumulating across frames. + /// + 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; + } + } + + /// + /// Restores values set through after + /// LoadParameters resets the native parameter arrays to the snapshot. + /// + 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}"); } + /// + /// Applies the "Layout" section of model3.json through + /// (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. + /// + private void ApplyLayoutFromModelSetting(Dictionary 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(); + } + + /// + /// 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. + /// + 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(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(); - 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; + } } /// @@ -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); } diff --git a/source_code/Cubism/Framework/CubismMotion.cs b/source_code/Cubism/Framework/CubismMotion.cs index f04b8da..a733458 100644 --- a/source_code/Cubism/Framework/CubismMotion.cs +++ b/source_code/Cubism/Framework/CubismMotion.cs @@ -1,11 +1,22 @@ using System; using System.Collections.Generic; -using System.Text.Json; namespace Cthangover.Live2D.Cubism.Framework { public delegate float MotionSegmentFunction(float[] segments, int baseIndex, float time); + /// + /// Версия обработки зацикленного времени — порт CubismMotion::MotionBehavior (SDK 5-r.5). + /// V2 (по умолчанию): длительность цикла расширяется на один кадр (1/Fps), а переход + /// между циклами интерполируется через коррекцию конечной точки (CorrectEndPoint). + /// V1: время цикла жёстко перезаписывается без кадра-хвоста (поведение старых SDK). + /// + public enum MotionBehavior + { + V1 = 0, + V2 = 1, + } + /// /// Parses and plays a Cubism .motion3.json animation. /// Manages three categories of curves: @@ -14,22 +25,24 @@ namespace Cthangover.Live2D.Cubism.Framework /// Parameter curves — arbitrary model parameter animations /// Part opacity curves — visibility/opacity of model parts /// - /// - /// Each curve is a sequence of segments (linear, stepped, bezier) stored in - /// flat arrays for performance. Evaluation uses a segment-finding pass - /// followed by per-segment interpolation — linear, stepped, or bezier - /// (analytic via Cardano formula or binary search for restricted beziers). - /// - /// The four-pass update cycle (): + /// + /// Данные хранятся как в нативном CubismMotionData: один общий плоский массив точек + /// () и общий массив сегментов (); + /// каждая кривая ссылается на свой диапазон сегментов через BaseSegmentIndex/SegmentCount. + /// Оценка кривой сначала ищет сегмент по времени ПЕРВОЙ точки следующего сегмента, + /// а за пределами последнего сегмента возвращает значение последней точки + /// (при зацикливании V2 — интерполирует переход конца в начало). + /// + /// Обновление параметров () — четыре прохода, порт SDK: /// - /// Evaluate model curves (eye blink, lip sync, opacity) - /// Evaluate parameter curves, applying eye blink/lip sync modulation - /// Apply eye blink/lip sync to remaining (non-animated) parameters - /// Evaluate part opacity curves + /// Model-кривые: EyeBlink/LipSync значения и ModelOpacity + /// Parameter-кривые: модуляция на EyeBlink (* значение) и LipSync (+ значение) + /// Не анимированные параметры групп EyeBlink/LipSync: лерп с fade-весом + /// PartOpacity-кривые /// - /// - /// Parameter fade-in/out is handled per-curve (overriding the motion-level - /// fade settings). Curve targets and IDs come from the motion3.json metadata. + /// + /// Фейд применяется по кривой: если у кривой заданы собственные FadeInTime/FadeOutTime — + /// используется они (база — фейд самого моушена), иначе общий fade-вес моушена. /// public class CubismMotion : ACubismMotion { @@ -37,27 +50,38 @@ namespace Cthangover.Live2D.Cubism.Framework private const string ModelCurveIdLipSync = "LipSync"; private const string ModelCurveIdOpacity = "Opacity"; + /// Порт SDK-константы MaxTargetSize: лимит целей EyeBlink/LipSync (ширина битовой маски). + private const int MaxTargetSize = 64; + + /// Порт CubismMath::Epsilon — порог сравнения в алгоритме Кардано. + private const float MathEpsilon = 0.00001f; + private readonly float _sourceFrameRate; private float _lastWeight; - private CubismMotionJson _motionJson; + private float _loopDurationSeconds = -1f; + private MotionBehavior _motionBehavior = MotionBehavior.V2; + private readonly CubismMotionJson _motionJson; + private readonly bool _areBeziersRestricted; private readonly List _modelCurves = new(); private readonly List _parameterCurves = new(); private readonly List _partOpacityCurves = new(); + // Общие плоские хранилища точек и сегментов (порт CubismMotionData.Points/Segments). + private CubismMotionPoint[] _points = Array.Empty(); + private MotionSegmentData[] _segments = Array.Empty(); + private readonly List _eyeBlinkParameterIds = new(); private readonly List _lipSyncParameterIds = new(); private readonly List _events = new(); - private readonly List _firedEventValues = new(); private float _modelOpacity = 1f; - private bool _areBeziersRestricted; /// - /// Parses a .motion3.json byte buffer. - /// Reads the meta section (duration, loop, fps, fade times, bezier flag) - /// and delegates curve/event parsing to private helpers. + /// Parses a .motion3.json byte buffer: метаданные (длительность, loop, fps, фейды, + /// признак restricted-безье) и кривые/события через /. + /// Отсутствующее или отрицательное время фейда заменяется на 1 c — порт CubismMotion::Parse. /// public CubismMotion(byte[] motion3JsonBytes) { @@ -65,9 +89,12 @@ namespace Cthangover.Live2D.Cubism.Framework _sourceFrameRate = _motionJson.GetFps(); _areBeziersRestricted = _motionJson.AreBeziersRestricted(); _isLoop = _motionJson.IsLoop(); + _loopDurationSeconds = _motionJson.GetDuration(); - if (_motionJson.GetFadeInTime() >= 0f) _fadeInSeconds = _motionJson.GetFadeInTime(); - if (_motionJson.GetFadeOutTime() >= 0f) _fadeOutSeconds = _motionJson.GetFadeOutTime(); + var fadeIn = _motionJson.GetFadeInTime(); + var fadeOut = _motionJson.GetFadeOutTime(); + _fadeInSeconds = fadeIn < 0f ? 1f : fadeIn; + _fadeOutSeconds = fadeOut < 0f ? 1f : fadeOut; ParseCurves(); ParseEvents(); @@ -75,10 +102,9 @@ namespace Cthangover.Live2D.Cubism.Framework /// /// Configures which parameters are affected by eye blink and lip sync - /// modulation. These lists typically come from the model3.json Groups. - /// When a parameter curve targets one of these IDs, its value is - /// multiplied by the eye blink curve value (for blink) or added to - /// the lip sync curve value (for lip sync) in Pass 2. + /// modulation (обычно из Groups модели в model3.json). Кривая параметра из + /// этих групп умножается на значение EyeBlink-кривой / складывается с LipSync + /// в проходе 2; параметры групп без собственной кривой получают лерп в проходе 3. /// public void SetEffectIds(List eyeBlinkIds, List lipSyncIds) { @@ -88,315 +114,706 @@ namespace Cthangover.Live2D.Cubism.Framework if (lipSyncIds != null) _lipSyncParameterIds.AddRange(lipSyncIds); } - /// - /// Returns the total duration from the motion3.json, or -1 for looping motions. - /// - public override float GetDuration() => _isLoop ? -1f : _motionJson.GetDuration(); + /// Returns -1 for looping motions (they never end), иначе полную длительность. + public override float GetDuration() => _isLoop ? -1f : _loopDurationSeconds; + + /// Полная длительность одного цикла, даже если моушен зациклен. + public override float GetLoopDuration() => _loopDurationSeconds; + + /// Порт CubismMotion::SetMotionBehavior — версия обработки зацикленного времени. + public void SetMotionBehavior(MotionBehavior behavior) => _motionBehavior = behavior; + + /// Порт CubismMotion::GetMotionBehavior. + public MotionBehavior GetMotionBehavior() => _motionBehavior; /// - /// Returns the full duration even when looping is active — used for computing - /// loop cycle length. + /// true, если среди Model-кривых есть кривая "Opacity" — порт CubismMotion::IsExistModelOpacity: + /// наличие кривой, а не текущее значение прозрачности. /// - public override float GetLoopDuration() => _motionJson.GetDuration(); - - /// Whether the model's overall opacity is actively controlled by this motion. - public bool IsExistModelOpacity() => _modelOpacity > 0f; - - /// Current model opacity value from the Opacity model curve, clamped [0,1]. - public float GetModelOpacity() => _modelOpacity; - - /// - /// Collects event values that fired between two time points. - /// Clears the internal fired-event list on each call. (Currently returns 0f; - /// the fired values list is populated as a side effect.) - /// - public float GetFiredEventValue(float beforeTime, float motionTime) + public bool IsExistModelOpacity() { - _firedEventValues.Clear(); - foreach (var evt in _events) - if (evt.FireTime > beforeTime && evt.FireTime <= motionTime) - _firedEventValues.Add(evt.Value); - return 0f; + foreach (var curve in _modelCurves) + if (curve.Id == ModelCurveIdOpacity) return true; + return false; } + /// + /// Текущая ModelOpacity из Model-кривой "Opacity" (1, если кривой нет). + /// Рендерер должен умножать на неё прозрачность drawable'ов — SDK применяет + /// её в CubismModel::SetModelOpacity, здесь значение отдаётся наружу. + /// + public float GetModelOpacity() => _modelOpacity; + + /// + /// События (UserData), сработавшие в интервале (beforeCheckTime, motionTime]. + /// Список очищается при каждом вызове; очередь вызывает метод раз в кадр + /// с lastCheckTime..currentTime, поэтому события не теряются между кадрами. + /// + public override List GetFiredEvent(float beforeCheckTimeSeconds, float motionTimeSeconds) + { + _firedEventValues.Clear(); + foreach (var evt in _events) + if (evt.FireTime > beforeCheckTimeSeconds && evt.FireTime <= motionTimeSeconds) + _firedEventValues.Add(evt.Value); + return _firedEventValues; + } + + /// Совместимая обёртка над с тем же списком результатов. + public List GetFiredEventValue(float beforeTime, float motionTime) => + GetFiredEvent(beforeTime, motionTime); + /// /// Four-pass parameter update. Called by - /// every frame. + /// every frame (через ). /// public override unsafe void DoUpdateParameters( - CubismNativeModel model, float userTimeSeconds, float weight, CubismMotionQueueEntry entry) + CubismNativeModel model, float userTimeSeconds, float fadeWeight, CubismMotionQueueEntry entry) { if (model == null || entry == null) return; - AdjustEndTime(entry, _motionJson.GetDuration()); - - var timeOffset = userTimeSeconds - entry.StartTime; - var duration = _motionJson.GetDuration(); - if (_isLoop && duration > 0f) + // Порт CubismMotion::DoUpdateParameters: пересчёт EndTime при смене loop-состояния (V2). + if (_motionBehavior == MotionBehavior.V2 && _previousLoopState != _isLoop) { - while (timeOffset > duration) - timeOffset -= duration; + AdjustEndTime(entry); + _previousLoopState = _isLoop; } - var fadeWeight = weight; + float timeOffsetSeconds = userTimeSeconds - entry.StartTime; + if (timeOffsetSeconds < 0f) timeOffsetSeconds = 0f; // защита от старта в "будущем" + + float lipSyncValue = float.MaxValue; // FLT_MAX — сигнатура "кривой нет" + float eyeBlinkValue = float.MaxValue; + ulong lipSyncFlags = 0UL; + ulong eyeBlinkFlags = 0UL; + + // Базовые (моушен-уровень) фейды для кривых без собственных fade-настроек. + float tmpFadeIn = _fadeInSeconds <= 0f + ? 1f + : CubismMath.GetEasingSine((userTimeSeconds - entry.FadeInStartTime) / _fadeInSeconds); + + float tmpFadeOut = (_fadeOutSeconds <= 0f || entry.EndTime < 0f) + ? 1f + : CubismMath.GetEasingSine((entry.EndTime - userTimeSeconds) / _fadeOutSeconds); + + // 'Repeat' time as necessary. + float time = timeOffsetSeconds; + float duration = _loopDurationSeconds; + bool isCorrection = _motionBehavior == MotionBehavior.V2 && _isLoop; + + if (_isLoop) + { + if (_motionBehavior == MotionBehavior.V2) + duration += 1f / _sourceFrameRate; // кадр-хвост для интерполяции цикла + while (time > duration) + time -= duration; + } - int paramCount = model.ParameterCount; var idsPtr = model.GetParameterIds(); var values = model.GetParameterValues(); var minValues = model.GetParameterMinimumValues(); var maxValues = model.GetParameterMaximumValues(); - var defaultValues = model.GetParameterDefaultValues(); - float eyeBlinkValue = 0f; - float lipSyncValue = 0f; - _modelOpacity = 1f; - var modifiedFlags = new bool[paramCount]; + // Индексы параметров модели: порт model->GetParameterIndex(curveId). + var paramIndexById = new Dictionary(model.ParameterCount); + for (int i = 0; i < model.ParameterCount; i++) + paramIndexById[CubismNativeModel.ReadStringFromPtrArray(idsPtr, i)] = i; - // Pass 1 — model curves + // Pass 1 — model curves (EyeBlink / LipSync / Opacity). foreach (var curve in _modelCurves) { - var val = EvaluateCurve(curve, timeOffset, fadeWeight); - if (curve.Id == ModelCurveIdEyeBlink) eyeBlinkValue = val; - else if (curve.Id == ModelCurveIdLipSync) lipSyncValue = val; - else if (curve.Id == ModelCurveIdOpacity) _modelOpacity = Math.Clamp(val, 0f, 1f); - } + float value = EvaluateCurveInternal(curve, _points, _segments, time, isCorrection, duration, _areBeziersRestricted); - // Pass 2 — parameter curves - foreach (var curve in _parameterCurves) - { - var curveValue = EvaluateCurve(curve, timeOffset, fadeWeight); - - for (int pi = 0; pi < paramCount; pi++) + if (curve.Id == ModelCurveIdEyeBlink) eyeBlinkValue = value; + else if (curve.Id == ModelCurveIdLipSync) lipSyncValue = value; + else if (curve.Id == ModelCurveIdOpacity) { - var pid = CubismNativeModel.ReadStringFromPtrArray(idsPtr, pi); - if (pid != curve.Id) continue; - - var modulatedValue = curveValue; - if (_eyeBlinkParameterIds.Contains(pid)) - modulatedValue *= eyeBlinkValue; - if (_lipSyncParameterIds.Contains(pid)) - modulatedValue += lipSyncValue; - - var paramFadeWeight = fadeWeight; - if (curve.ParamFadeIn >= 0f || curve.ParamFadeOut >= 0f) - { - var fi = curve.ParamFadeIn >= 0f ? curve.ParamFadeIn : 1f; - var fo = curve.ParamFadeOut >= 0f ? curve.ParamFadeOut : 1f; - var paramFadeIn = CubismMath.GetEasingSine( - (userTimeSeconds - entry.FadeInStartTime) / fi); - var paramFadeOut = CubismMath.GetEasingSine( - (entry.EndTime > 0f ? entry.EndTime - userTimeSeconds : 1f) / fo); - paramFadeWeight = _weight * paramFadeIn * paramFadeOut; - } - - values[pi] = values[pi] + (modulatedValue - values[pi]) * paramFadeWeight; - ClampParameter(pi, values, minValues, maxValues); - modifiedFlags[pi] = true; - break; + // Порт: _modelOpacity = value; model->SetModelOpacity(...). + // Значение отдаётся наружу через GetModelOpacity(). + _modelOpacity = value; } } - // Pass 3 — remaining eye blink / lip sync (non-modulated params) - for (int pi = 0; pi < paramCount; pi++) + // Pass 2 — parameter curves с модуляцией EyeBlink/LipSync и пер-кривым фейдом. + foreach (var curve in _parameterCurves) { - if (modifiedFlags[pi]) continue; - var pid = CubismNativeModel.ReadStringFromPtrArray(idsPtr, pi); - if (_eyeBlinkParameterIds.Contains(pid)) - values[pi] = eyeBlinkValue > 0f ? values[pi] * eyeBlinkValue : values[pi]; - if (_lipSyncParameterIds.Contains(pid)) - values[pi] += lipSyncValue; + if (!paramIndexById.TryGetValue(curve.Id, out int parameterIndex)) continue; + + float sourceValue = values[parameterIndex]; + float value = EvaluateCurveInternal(curve, _points, _segments, time, isCorrection, duration, _areBeziersRestricted); + + if (eyeBlinkValue != float.MaxValue) + { + for (int i = 0; i < _eyeBlinkParameterIds.Count && i < MaxTargetSize; i++) + { + if (_eyeBlinkParameterIds[i] == curve.Id) + { + value *= eyeBlinkValue; + eyeBlinkFlags |= 1UL << i; + break; + } + } + } + + if (lipSyncValue != float.MaxValue) + { + for (int i = 0; i < _lipSyncParameterIds.Count && i < MaxTargetSize; i++) + { + if (_lipSyncParameterIds[i] == curve.Id) + { + value += lipSyncValue; + lipSyncFlags |= 1UL << i; + break; + } + } + } + + float v; + if (curve.FadeInTime < 0f && curve.FadeOutTime < 0f) + { + // Собственных фейдов у кривой нет — применяется общий fade-вес моушена. + v = sourceValue + (value - sourceValue) * fadeWeight; + } + else + { + // Порт: база — фейд самого моушена (tmpFadeIn/tmpFadeOut), не 1. + float fin = curve.FadeInTime < 0f + ? tmpFadeIn + : curve.FadeInTime == 0f + ? 1f + : CubismMath.GetEasingSine((userTimeSeconds - entry.FadeInStartTime) / curve.FadeInTime); + + float fout = curve.FadeOutTime < 0f + ? tmpFadeOut + : (curve.FadeOutTime == 0f || entry.EndTime < 0f) + ? 1f + : CubismMath.GetEasingSine((entry.EndTime - userTimeSeconds) / curve.FadeOutTime); + + float paramWeight = _weight * fin * fout; + v = sourceValue + (value - sourceValue) * paramWeight; + } + + // Порт model->SetParameterValue: значение зажимается в [min, max] параметра. + values[parameterIndex] = ClampParameterValue(v, minValues[parameterIndex], maxValues[parameterIndex]); } - // Pass 4 — part opacity curves + // Pass 3 — EyeBlink/LipSync для параметров, не анимированных кривыми: лерп с fade-весом. + if (eyeBlinkValue != float.MaxValue) + { + for (int i = 0; i < _eyeBlinkParameterIds.Count && i < MaxTargetSize; i++) + { + // Моушен уже записал этот параметр в проходе 2 — моргание не применяем. + if ((eyeBlinkFlags >> i & 1UL) != 0UL) continue; + if (!paramIndexById.TryGetValue(_eyeBlinkParameterIds[i], out int pi)) continue; + + float sourceValue = values[pi]; + float v = sourceValue + (eyeBlinkValue - sourceValue) * fadeWeight; + values[pi] = ClampParameterValue(v, minValues[pi], maxValues[pi]); + } + } + + if (lipSyncValue != float.MaxValue) + { + for (int i = 0; i < _lipSyncParameterIds.Count && i < MaxTargetSize; i++) + { + if ((lipSyncFlags >> i & 1UL) != 0UL) continue; + if (!paramIndexById.TryGetValue(_lipSyncParameterIds[i], out int pi)) continue; + + float sourceValue = values[pi]; + float v = sourceValue + (lipSyncValue - sourceValue) * fadeWeight; + values[pi] = ClampParameterValue(v, minValues[pi], maxValues[pi]); + } + } + + // Pass 4 — part opacity curves (пишутся напрямую в массив прозрачностей частей). var partOpacities = model.GetPartOpacities(); var partIdsPtr = model.GetPartIds(); int partCount = model.GetPartCount(); + var partIndexById = new Dictionary(partCount); + for (int i = 0; i < partCount; i++) + partIndexById[CubismNativeModel.ReadStringFromPtrArray(partIdsPtr, i)] = i; + foreach (var curve in _partOpacityCurves) { - var val = EvaluateCurve(curve, timeOffset, fadeWeight); - for (int pi = 0; pi < partCount; pi++) + if (!partIndexById.TryGetValue(curve.Id, out int pi)) continue; + partOpacities[pi] = EvaluateCurveInternal(curve, _points, _segments, time, isCorrection, duration, _areBeziersRestricted); + } + + // Завершение/перезапуск: моушен сам выставляет IsFinished и зовёт колбэк + // (очередь только удаляет finished-записи — порт CubismMotionQueueManager). + if (timeOffsetSeconds >= duration) + { + if (_isLoop) + UpdateForNextLoop(entry, userTimeSeconds, time); + else { - var pid = CubismNativeModel.ReadStringFromPtrArray(partIdsPtr, pi); - if (pid == curve.Id) - { - partOpacities[pi] = val; - break; - } + OnFinishedMotion?.Invoke(this); + entry.IsFinished = true; } } - // End-of-motion - if (!_isLoop && timeOffset >= duration) - { - entry.IsFinished = true; - OnFinishedMotion?.Invoke(this); - } - else if (_isLoop && timeOffset >= duration) - { - entry.StartTime = userTimeSeconds - (timeOffset - duration); - if (!_isLoopFadeIn) - entry.FadeInStartTime = userTimeSeconds; - } + _lastWeight = fadeWeight; } - private float EvaluateCurve(MotionCurveData curve, float time, float weight) + /// + /// Порт CubismMotion::UpdateForNextLoop: сдвиг StartTime назад на время внутри цикла; + /// фейд-ин перезапускается только при включённом _isLoopFadeIn (поведение V2). + /// + private void UpdateForNextLoop(CubismMotionQueueEntry entry, float userTimeSeconds, float time) { - var segIdx = FindSegmentIndex(curve, time); - if (segIdx < 0 || segIdx >= curve.SegmentCount) return 0f; - return EvaluateSegment(curve, segIdx, time); - } - - private int FindSegmentIndex(MotionCurveData curve, float time) - { - for (int i = 0; i < curve.SegmentCount; i++) + switch (_motionBehavior) { - var nextIdx = curve.BaseSegmentIndex + i + 1; - if (nextIdx < curve.Points.Length) - { - var nextPoint = curve.Points[nextIdx]; - if (time < nextPoint.Time) - return i; - } - } - var lastPt = curve.Points[curve.BaseSegmentIndex + curve.SegmentCount]; - if (time <= lastPt.Time) - return curve.SegmentCount - 1; - return -1; - } - - private float EvaluateSegment(MotionCurveData curve, int segIndex, float time) - { - var basePtIdx = curve.BaseSegmentIndex + segIndex; - var p0 = curve.Points[basePtIdx]; - var p1 = curve.Points[basePtIdx + 1]; - - var segType = curve.SegmentTypes != null && segIndex < curve.SegmentTypes.Length - ? curve.SegmentTypes[segIndex] : CubismMotionSegmentType.Linear; - - switch (segType) - { - case CubismMotionSegmentType.Linear: - return CubismMath.LinearEvaluation(ref p0, ref p1, time); - - case CubismMotionSegmentType.Stepped: - return p0.Value; - - case CubismMotionSegmentType.InverseStepped: - return p1.Value; - - case CubismMotionSegmentType.Bezier: - var cp1 = curve.Points[basePtIdx + 1]; - var cp2 = curve.Points[basePtIdx + 2]; - var cp3 = curve.Points[basePtIdx + 3]; - - if (_areBeziersRestricted) - return CubismMath.BezierEvaluateBinarySearch( - p0.Time, cp1.Time, cp2.Time, cp3.Time, - p0.Value, cp1.Value, cp2.Value, cp3.Value, time); - else - { - var t = CubismMath.CardanoAlgorithmForBezier( - p0.Time, cp1.Time, cp2.Time, cp3.Time, time); - t = Math.Clamp(t, 0f, 1f); - var t1 = 1f - t; - var t2 = t * t; - var t12 = t1 * t1; - return t12 * t1 * p0.Value + 3f * t12 * t * cp1.Value + - 3f * t1 * t2 * cp2.Value + t2 * t * cp3.Value; - } - + case MotionBehavior.V2: default: - return CubismMath.LinearEvaluation(ref p0, ref p1, time); + entry.StartTime = userTimeSeconds - time; + if (_isLoopFadeIn) + entry.FadeInStartTime = userTimeSeconds - time; + OnFinishedMotion?.Invoke(this); + break; + case MotionBehavior.V1: + entry.StartTime = userTimeSeconds; + if (_isLoopFadeIn) + entry.FadeInStartTime = userTimeSeconds; + break; } } - private static unsafe void ClampParameter(int index, float* values, float* min, float* max) - { - if (values[index] < min[index]) values[index] = min[index]; - if (values[index] > max[index]) values[index] = max[index]; - } - + /// + /// Разбор кривых: порт CubismMotion::Parse. Все точки и сегменты складываются + /// в общие плоские списки; кривая хранит BaseSegmentIndex и количество сегментов. + /// private void ParseCurves() { - var segsFlat = new float[_motionJson.GetTotalSegmentCount() * 2]; - var segTypesFlat = new CubismMotionSegmentType[_motionJson.GetTotalSegmentCount()]; - var allPoints = new List(); - int segOffset = 0; + var points = new List(); + var segments = new List(); for (int ci = 0; ci < _motionJson.GetCurveCount(); ci++) { - var target = _motionJson.GetCurveTarget(ci); - var id = _motionJson.GetCurveId(ci); - var fadeIn = _motionJson.GetCurveFadeInTime(ci); - var fadeOut = _motionJson.GetCurveFadeOutTime(ci); - _motionJson.GetCurveSegments(ci, out var rawSegments); - - var segmentCount = ParseSegments(rawSegments, segsFlat, segTypesFlat, segOffset, allPoints); - var curveTarget = ParseTarget(target); + var target = ParseTarget(_motionJson.GetCurveTarget(ci)); var curveData = new MotionCurveData { - Type = curveTarget, - Id = id, - SegmentCount = segmentCount, - BaseSegmentIndex = segOffset, - ParamFadeIn = fadeIn, - ParamFadeOut = fadeOut, - Points = allPoints.ToArray(), - SegmentTypes = segTypesFlat + Type = target, + Id = _motionJson.GetCurveId(ci), + BaseSegmentIndex = segments.Count, + // -1 = время фейда кривой не задано в json (как в SDK). + FadeInTime = _motionJson.GetCurveFadeInTime(ci), + FadeOutTime = _motionJson.GetCurveFadeOutTime(ci), }; - segOffset += segmentCount; + _motionJson.GetCurveSegments(ci, out var rawSegments); + curveData.SegmentCount = ParseSegmentsInternal(rawSegments, points, segments); - switch (curveTarget) + switch (target) { - case CubismMotionCurveTarget.Model: - _modelCurves.Add(curveData); break; - case CubismMotionCurveTarget.Parameter: - _parameterCurves.Add(curveData); break; - case CubismMotionCurveTarget.PartOpacity: - _partOpacityCurves.Add(curveData); break; + case CubismMotionCurveTarget.Model: _modelCurves.Add(curveData); break; + case CubismMotionCurveTarget.Parameter: _parameterCurves.Add(curveData); break; + case CubismMotionCurveTarget.PartOpacity: _partOpacityCurves.Add(curveData); break; } } + + _points = points.ToArray(); + _segments = segments.ToArray(); } - private int ParseSegments(float[] raw, float[] segsFlat, CubismMotionSegmentType[] segTypesFlat, - int segOffset, List points) + /// + /// Разбор массива Segments одной кривой — порт 1:1 цикла CubismMotion::Parse. + /// Формат motion3.json: [t0, v0, type, t, v, type, t, v, v, v(bezier)...]: + /// первые два элемента — стартовая точка кривой, дальше для каждого сегмента + /// маркер типа и его точки (Linear/Stepped/InverseStepped — 1 точка, Bezier — 3). + /// Возвращает число сегментов кривой; точки и сегменты дописываются в общие списки. + /// + internal static int ParseSegmentsInternal(float[] raw, List points, List segments) { int count = 0; - int i = 0; - while (i < raw.Length) + int segmentPosition = 0; + while (segmentPosition < raw.Length) { - var segType = (CubismMotionSegmentType)(int)raw[i++]; - - if (count == 0) + int basePointIndex; + if (segmentPosition == 0) { - // First "segment" is actually the starting point - points.Add(new CubismMotionPoint { Time = raw[i++], Value = raw[i++] }); - continue; + // Первая точка кривой: время и значение — не маркер типа. + basePointIndex = points.Count; + points.Add(new CubismMotionPoint { Time = raw[segmentPosition], Value = raw[segmentPosition + 1] }); + segmentPosition += 2; + } + else + { + // Сегменты продолжаются от последней точки предыдущего сегмента. + basePointIndex = points.Count - 1; } - segTypesFlat[segOffset + count - 1] = segType; - - switch (segType) + var segmentType = (CubismMotionSegmentType)(int)raw[segmentPosition]; + switch (segmentType) { case CubismMotionSegmentType.Linear: case CubismMotionSegmentType.Stepped: case CubismMotionSegmentType.InverseStepped: - points.Add(new CubismMotionPoint { Time = raw[i++], Value = raw[i++] }); + points.Add(new CubismMotionPoint { Time = raw[segmentPosition + 1], Value = raw[segmentPosition + 2] }); + segmentPosition += 3; break; case CubismMotionSegmentType.Bezier: - points.Add(new CubismMotionPoint { Time = raw[i++], Value = raw[i++] }); - points.Add(new CubismMotionPoint { Time = raw[i++], Value = raw[i++] }); - points.Add(new CubismMotionPoint { Time = raw[i++], Value = raw[i++] }); + points.Add(new CubismMotionPoint { Time = raw[segmentPosition + 1], Value = raw[segmentPosition + 2] }); + points.Add(new CubismMotionPoint { Time = raw[segmentPosition + 3], Value = raw[segmentPosition + 4] }); + points.Add(new CubismMotionPoint { Time = raw[segmentPosition + 5], Value = raw[segmentPosition + 6] }); + segmentPosition += 7; break; + + default: + // SDK: CSM_ASSERT(0). Неизвестный тип — останавливаем разбор кривой, + // чтобы не зациклиться на некорректных данных. + return count; } + segments.Add(new MotionSegmentData { BasePointIndex = basePointIndex, SegmentType = segmentType }); count++; } - return count - 1; + return count; + } + + /// + /// Порт CubismMotion::EvaluateCurve (анонимный namespace CubismMotion.cpp). + /// Сегмент ищется по времени ПЕРВОЙ точки следующего сегмента; если время за + /// пределами всех сегментов — значение последней точки, а при зацикленном V2 + /// (isCorrection) — интерполяция конца кривой к её началу (CorrectEndPoint). + /// + internal static float EvaluateCurveInternal( + MotionCurveData curve, + CubismMotionPoint[] points, + MotionSegmentData[] segments, + float time, + bool isCorrection, + float endTime, + bool areBeziersRestricted) + { + int target = -1; + int totalSegmentCount = curve.BaseSegmentIndex + curve.SegmentCount; + int pointPosition = 0; + + for (int i = curve.BaseSegmentIndex; i < totalSegmentCount; i++) + { + // Первая точка следующего сегмента = конечная точка текущего. + pointPosition = segments[i].BasePointIndex + + (segments[i].SegmentType == CubismMotionSegmentType.Bezier ? 3 : 1); + + if (points[pointPosition].Time > time) + { + target = i; + break; + } + } + + if (target == -1) + { + if (isCorrection && time < endTime) + { + // Переход конец -> начало при зацикливании (поведение V2). + return CorrectEndPointInternal( + segments[totalSegmentCount - 1].SegmentType, + points, + segments[curve.BaseSegmentIndex].BasePointIndex, + pointPosition, + time, + endTime); + } + + return points[pointPosition].Value; + } + + return EvaluateSegmentInternal(segments[target].SegmentType, points, segments[target].BasePointIndex, time, areBeziersRestricted); + } + + /// + /// Порт CorrectEndPoint: лерп от конечной точки кривой к начальной, + /// чьё время искусственно продлевается до endTime (границы цикла). + /// + internal static float CorrectEndPointInternal( + CubismMotionSegmentType lastSegmentType, + CubismMotionPoint[] points, + int beginIndex, + int endIndex, + float time, + float endTime) + { + CubismMotionPoint end = points[endIndex]; + CubismMotionPoint begin = points[beginIndex]; + begin.Time = endTime; + + switch (lastSegmentType) + { + case CubismMotionSegmentType.Stepped: + return end.Value; + case CubismMotionSegmentType.InverseStepped: + return begin.Value; + default: // Linear / Bezier / прочее — линейная интерполяция + return LinearEvaluatePoints(end, begin, time); + } + } + + /// Диспетчер оценки одного сегмента — порт таблички SegmentType -> Evaluate. + internal static float EvaluateSegmentInternal( + CubismMotionSegmentType segmentType, + CubismMotionPoint[] points, + int basePointIndex, + float time, + bool areBeziersRestricted) + { + switch (segmentType) + { + case CubismMotionSegmentType.Linear: + return LinearEvaluateInternal(points, basePointIndex, time); + + case CubismMotionSegmentType.Bezier: + return areBeziersRestricted + ? BezierEvaluateBinarySearchInternal(points, basePointIndex, time) + : BezierEvaluateCardanoInternal(points, basePointIndex, time); + + case CubismMotionSegmentType.Stepped: + return points[basePointIndex].Value; + + case CubismMotionSegmentType.InverseStepped: + return points[basePointIndex + 1].Value; + + default: + return LinearEvaluateInternal(points, basePointIndex, time); + } + } + + /// Порт LerpPoints: лерп времени и значения пары контрольных точек. + private static CubismMotionPoint LerpPoints(CubismMotionPoint a, CubismMotionPoint b, float t) + { + return new CubismMotionPoint + { + Time = a.Time + (b.Time - a.Time) * t, + Value = a.Value + (b.Value - a.Value) * t, + }; + } + + /// Порт LinearEvaluate: лерп пары точек, t не опускается ниже 0. + internal static float LinearEvaluatePoints(CubismMotionPoint p0, CubismMotionPoint p1, float time) + { + float t = (time - p0.Time) / (p1.Time - p0.Time); + if (t < 0f) t = 0f; + return p0.Value + (p1.Value - p0.Value) * t; + } + + internal static float LinearEvaluateInternal(CubismMotionPoint[] points, int baseIndex, float time) => + LinearEvaluatePoints(points[baseIndex], points[baseIndex + 1], time); + + /// Порт BezierEvaluate (restricted/старые безье): де Кастельо по параметру t из времени. + internal static float BezierEvaluateInternal(CubismMotionPoint[] points, int baseIndex, float time) + { + float t = (time - points[baseIndex].Time) / (points[baseIndex + 3].Time - points[baseIndex].Time); + if (t < 0f) t = 0f; + + CubismMotionPoint p01 = LerpPoints(points[baseIndex], points[baseIndex + 1], t); + CubismMotionPoint p12 = LerpPoints(points[baseIndex + 1], points[baseIndex + 2], t); + CubismMotionPoint p23 = LerpPoints(points[baseIndex + 2], points[baseIndex + 3], t); + + CubismMotionPoint p012 = LerpPoints(p01, p12, t); + CubismMotionPoint p123 = LerpPoints(p12, p23, t); + + return LerpPoints(p012, p123, t).Value; + } + + /// Порт BezierEvaluateBinarySearch: деление отрезка параметра пополам (x_error = 0.01, 20 итераций). + internal static float BezierEvaluateBinarySearchInternal(CubismMotionPoint[] points, int baseIndex, float time) + { + const float xError = 0.01f; + + float x = time; + float x1 = points[baseIndex].Time; + float x2 = points[baseIndex + 3].Time; + float cx1 = points[baseIndex + 1].Time; + float cx2 = points[baseIndex + 2].Time; + + float ta = 0f; + float tb = 1f; + float t = 0f; + int i = 0; + + for (; i < 20; i++) + { + if (x < x1 + xError) + { + t = ta; + break; + } + + if (x2 - xError < x) + { + t = tb; + break; + } + + float centerx = (cx1 + cx2) * 0.5f; + cx1 = (x1 + cx1) * 0.5f; + cx2 = (x2 + cx2) * 0.5f; + float ctrlx12 = (cx1 + centerx) * 0.5f; + float ctrlx21 = (cx2 + centerx) * 0.5f; + centerx = (ctrlx12 + ctrlx21) * 0.5f; + + if (x < centerx) + { + tb = (ta + tb) * 0.5f; + if (centerx - xError < x) + { + t = tb; + break; + } + + x2 = centerx; + cx2 = ctrlx12; + } + else + { + ta = (ta + tb) * 0.5f; + if (x < centerx + xError) + { + t = ta; + break; + } + + x1 = centerx; + cx1 = ctrlx21; + } + } + + if (i == 20) + t = (ta + tb) * 0.5f; + + if (t < 0f) t = 0f; + if (t > 1f) t = 1f; + + CubismMotionPoint p01 = LerpPoints(points[baseIndex], points[baseIndex + 1], t); + CubismMotionPoint p12 = LerpPoints(points[baseIndex + 1], points[baseIndex + 2], t); + CubismMotionPoint p23 = LerpPoints(points[baseIndex + 2], points[baseIndex + 3], t); + + CubismMotionPoint p012 = LerpPoints(p01, p12, t); + CubismMotionPoint p123 = LerpPoints(p12, p23, t); + + return LerpPoints(p012, p123, t).Value; + } + + /// + /// Порт BezierEvaluateCardanoInterpretation: параметр t ищется решением + /// кубического уравнения по времени (алгоритм Кардано), затем де Кастельо. + /// + internal static float BezierEvaluateCardanoInternal(CubismMotionPoint[] points, int baseIndex, float time) + { + float x1 = points[baseIndex].Time; + float x2 = points[baseIndex + 3].Time; + float cx1 = points[baseIndex + 1].Time; + float cx2 = points[baseIndex + 2].Time; + + float a = x2 - 3f * cx2 + 3f * cx1 - x1; + float b = 3f * cx2 - 6f * cx1 + 3f * x1; + float c = 3f * cx1 - 3f * x1; + float d = x1 - time; + + float t = CardanoAlgorithmForBezierInternal(a, b, c, d); + + CubismMotionPoint p01 = LerpPoints(points[baseIndex], points[baseIndex + 1], t); + CubismMotionPoint p12 = LerpPoints(points[baseIndex + 1], points[baseIndex + 2], t); + CubismMotionPoint p23 = LerpPoints(points[baseIndex + 2], points[baseIndex + 3], t); + + CubismMotionPoint p012 = LerpPoints(p01, p12, t); + CubismMotionPoint p123 = LerpPoints(p12, p23, t); + + return LerpPoints(p012, p123, t).Value; + } + + /// + /// Порт CubismMath::CardanoAlgorithmForBezier — решение кубического уравнения + /// по формулам Кардано с выбором корня, ближайшего к 0.5, и зажимом в [0,1]. + /// + internal static float CardanoAlgorithmForBezierInternal(float a, float b, float c, float d) + { + if (MathF.Abs(a) < MathEpsilon) + return RangeFInternal(QuadraticEquationInternal(b, c, d), 0f, 1f); + + float ba = b / a; + float ca = c / a; + float da = d / a; + + float p = (3f * ca - ba * ba) / 3f; + float p3 = p / 3f; + float q = (2f * ba * ba * ba - 9f * ba * ca + 27f * da) / 27f; + float q2 = q / 2f; + float discriminant = q2 * q2 + p3 * p3 * p3; + + const float center = 0.5f; + const float threshold = center + 0.01f; + + if (discriminant < 0f) + { + float mp3 = -p / 3f; + float mp33 = mp3 * mp3 * mp3; + float r = MathF.Sqrt(mp33); + float t = -q / (2f * r); + float cosphi = RangeFInternal(t, -1f, 1f); + float phi = MathF.Acos(cosphi); + float crtr = MathF.Cbrt(r); + float t1 = 2f * crtr; + + float root1 = t1 * MathF.Cos(phi / 3f) - ba / 3f; + if (MathF.Abs(root1 - center) < threshold) + return RangeFInternal(root1, 0f, 1f); + + float root2 = t1 * MathF.Cos((phi + 2f * MathF.PI) / 3f) - ba / 3f; + if (MathF.Abs(root2 - center) < threshold) + return RangeFInternal(root2, 0f, 1f); + + float root3 = t1 * MathF.Cos((phi + 4f * MathF.PI) / 3f) - ba / 3f; + return RangeFInternal(root3, 0f, 1f); + } + + if (discriminant == 0f) + { + float u1 = q2 < 0f ? MathF.Cbrt(-q2) : -MathF.Cbrt(q2); + + float root1 = 2f * u1 - ba / 3f; + if (MathF.Abs(root1 - center) < threshold) + return RangeFInternal(root1, 0f, 1f); + + float root2 = -u1 - ba / 3f; + return RangeFInternal(root2, 0f, 1f); + } + + float sd = MathF.Sqrt(discriminant); + float uc = MathF.Cbrt(sd - q2); + float vc = MathF.Cbrt(sd + q2); + return RangeFInternal(uc - vc - ba / 3f, 0f, 1f); + } + + /// Порт CubismMath::QuadraticEquation — решение вырожденного (a ≈ 0) случая. + private static float QuadraticEquationInternal(float a, float b, float c) + { + if (MathF.Abs(a) < MathEpsilon) + { + if (MathF.Abs(b) < MathEpsilon) + return -c; + return -c / b; + } + + return -(b + MathF.Sqrt(b * b - 4f * a * c)) / (2f * a); + } + + /// Порт CubismMath::RangeF — зажим значения в [min, max]. + private static float RangeFInternal(float value, float min, float max) + { + if (value < min) value = min; + else if (value > max) value = max; + return value; + } + + /// Порт зажима model->SetParameterValue: значение параметра в границах [min, max]. + private static float ClampParameterValue(float value, float min, float max) + { + if (value < min) return min; + if (value > max) return max; + return value; } private static CubismMotionCurveTarget ParseTarget(string target) => target switch @@ -421,9 +838,9 @@ namespace Cthangover.Live2D.Cubism.Framework } /// - /// Internal data for a single parsed motion curve. - /// Holds the curve type, target ID, segment layout, points array, - /// and optional per-curve fade-in/fade-out durations. + /// Internal data for a single parsed motion curve. Хранит тип цели, ID, + /// диапазон сегментов в общем плоском массиве (BaseSegmentIndex/SegmentCount) + /// и пер-кривые времена фейда; значения < 0 означают «не задано» (как в SDK). /// internal class MotionCurveData { @@ -431,9 +848,7 @@ namespace Cthangover.Live2D.Cubism.Framework public string Id; public int SegmentCount; public int BaseSegmentIndex; - public float ParamFadeIn; - public float ParamFadeOut; - public CubismMotionPoint[] Points; - public CubismMotionSegmentType[] SegmentTypes; + public float FadeInTime; + public float FadeOutTime; } } diff --git a/source_code/Cubism/Framework/CubismMotionInternal.cs b/source_code/Cubism/Framework/CubismMotionInternal.cs index 2caf7a3..364fcd6 100644 --- a/source_code/Cubism/Framework/CubismMotionInternal.cs +++ b/source_code/Cubism/Framework/CubismMotionInternal.cs @@ -18,6 +18,22 @@ namespace Cthangover.Live2D.Cubism.Framework /// public enum CubismMotionSegmentType { Linear = 0, Bezier = 1, Stepped = 2, InverseStepped = 3 } + /// + /// Данные одного сегмента кривой — порт CubismMotionSegment из CubismMotionInternal.hpp: + /// индекс первой контрольной точки в общем плоском массиве точек и тип интерполяции. + /// Соседние сегменты кривой разделяют общую точку: BasePointIndex следующего сегмента + /// равен индексу последней точки предыдущего, а оценка кривой ищет сегмент по времени + /// первой точки СЛЕДУЮЩЕГО сегмента. + /// + internal struct MotionSegmentData + { + /// Индекс первой контрольной точки сегмента в CubismMotion._points. + public int BasePointIndex; + + /// Тип сегмента (Linear/Bezier/Stepped/InverseStepped). + public CubismMotionSegmentType SegmentType; + } + /// /// A time-value pair representing a keyframe or control point on a motion curve. /// diff --git a/source_code/Cubism/Framework/CubismMotionJson.cs b/source_code/Cubism/Framework/CubismMotionJson.cs index 3566daf..89769ab 100644 --- a/source_code/Cubism/Framework/CubismMotionJson.cs +++ b/source_code/Cubism/Framework/CubismMotionJson.cs @@ -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 . + /// segment-type markers and point data), decoded by CubismMotion.ParseSegmentsInternal. /// 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. /// 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; /// /// Motion-level fade-out time in seconds, or -1 if not specified. /// 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; /// /// 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. /// 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; /// /// Per-curve fade-out time in seconds, or -1 if not specified. /// 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; /// /// 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). Разбор: . /// public void GetCurveSegments(int index, out float[] segments) { diff --git a/source_code/Cubism/Framework/CubismMotionQueueEntry.cs b/source_code/Cubism/Framework/CubismMotionQueueEntry.cs index 8a6613c..42531da 100644 --- a/source_code/Cubism/Framework/CubismMotionQueueEntry.cs +++ b/source_code/Cubism/Framework/CubismMotionQueueEntry.cs @@ -1,33 +1,54 @@ +using System; +using System.Collections.Generic; + namespace Cthangover.Live2D.Cubism.Framework { /// /// 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 /// and consumed frame-by-frame in . /// public class CubismMotionQueueEntry { - /// Absolute user time when motion playback begins. - public float StartTime; + /// Absolute user time when motion playback begins. -1 until the first update. + public float StartTime = -1f; /// /// Absolute user time when playback ends. -1 for infinite (looping) motions. /// - public float EndTime; + public float EndTime = -1f; /// Absolute user time when fade-in begins (may differ from StartTime for offset motions). public float FadeInStartTime; /// Whether this entry should be processed during updates. - public bool IsAvailable; + public bool IsAvailable = true; /// Flag set when playback completes. Checked by the queue manager during cleanup. public bool IsFinished; + /// Flag set on the first update — порт CubismMotionQueueEntry::_started. + public bool IsStarted; + + /// Время последнего кадра (состояние), порт CubismMotionQueueEntry::SetState. + public float StateTime; + + /// Вес последнего кадра (состояние), порт CubismMotionQueueEntry::SetState. + public float StateWeight; + + /// Абсолютное время последней проверки событий UserData (сдвигается каждый кадр). + public float LastCheckEventTime; + + /// Длительность фейда-аута, назначенного при вытеснении новым моушеном. + public float FadeOutSeconds; + + /// Флаг вытеснения: фейд-аут начат, EndTime будет назначен при следующем обновлении. + public bool IsTriggeredFadeOut; + /// The motion instance driving parameter updates. public ACubismMotion Motion; @@ -45,29 +66,70 @@ namespace Cthangover.Live2D.Cubism.Framework { Motion = motion; AutoDelete = autoDelete; - IsAvailable = true; + } + + /// + /// Порт CubismMotionQueueEntry::SetFadeout: помечает запись для фейда-аута + /// (вызывается очередью, когда стартует новый моушен). + /// + public void SetFadeout(float fadeOutSeconds) + { + FadeOutSeconds = fadeOutSeconds; + IsTriggeredFadeOut = true; + } + + /// + /// Порт CubismMotionQueueEntry::StartFadeout: назначает EndTime, сокращая его, + /// если фейд-аут уже был запущен раньше и раньше должен закончиться. + /// + public void StartFadeout(float fadeOutSeconds, float userTimeSeconds) + { + float newEndTimeSeconds = userTimeSeconds + fadeOutSeconds; + IsTriggeredFadeOut = true; + + if (EndTime < 0f || newEndTimeSeconds < EndTime) + { + EndTime = newEndTimeSeconds; + } + } + + /// Порт CubismMotionQueueEntry::SetState — сохраняет время и вес последнего кадра. + public void SetState(float timeSeconds, float weight) + { + StateTime = timeSeconds; + StateWeight = weight; } } /// /// Manages a queue of 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. - /// - /// Two instances are used by : - /// 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. + /// Поддержка вытеснения по приоритету (ReserveMotion), параллельного проигрывания + /// и автосборки завершённых записей. Порт CubismMotionQueueManager из SDK 5-r.5: + /// + /// - StartMotion назначает всем прежним записям фейд-аут; + /// - каждый кадр DoUpdateMotion вызывает , + /// затем проверяет события UserData () и + /// удаляет finished-записи; + /// - колбэком завершения владеет сам моушен: очередь его НЕ дублирует. /// public class CubismMotionQueueManager { - private readonly System.Collections.Generic.List _entries = new(); + private readonly List _entries = new(); + private readonly List _firedEventValues = new(); private int _reservedPriority = -1; + /// + /// Событие, вызываемое для каждого сработавшего UserData-события за кадр + /// (порт _eventCallback из CubismMotionQueueManager). + /// + public event Action EventFired; + + /// + /// События, собранные за последний кадр DoUpdateMotion по всем записям очереди. + /// Список очищается в начале каждого обновления — читайте его сразу после вызова. + /// + public IReadOnlyList FiredEventValues => _firedEventValues; + /// /// Sets the reserved priority threshold. /// Motions with priority below this are rejected by . @@ -89,21 +151,27 @@ namespace Cthangover.Live2D.Cubism.Framework } /// - /// Creates and enqueues a motion for playback. - /// Instantiates a , calls - /// on it, and - /// adds it to the queue. Returns the entry for optional tracking. + /// Enqueues a motion for playback. Прежним записям назначается фейд-аут + /// (SetFadeout), после чего они плавно завершаются; сама запись инициализируется + /// при первом кадре . + /// Параметр userTimeSeconds оставлен для совместимости вызовов. /// /// The motion to play. /// Whether to auto-remove on finish. - /// Current user time for start time calculation. + /// Current user time (не используется, порт сигнатуры). 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; } /// - /// Per-frame update for all queued motions. - /// Iterates in reverse for safe removal. For each active entry: - /// computes fade weight, invokes , - /// and checks for completion. Finished auto-delete entries are removed. + /// Per-frame update — порт CubismMotionQueueManager::DoUpdateMotion. + /// Для каждой записи: UpdateParameters (фейд + применение значений + финализация + /// IsFinished самим моушеном), сборUserData-событий за кадр, затем удаление + /// finished-записей и старт отложенного фейда-аута вытесненных. + /// Обход прямой, как в SDK: моушены применяются от старых к новым. /// 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); + _entries.RemoveAt(i); continue; } - var weight = entry.Motion.UpdateFadeWeight(entry, userTimeSeconds); - entry.Motion.DoUpdateParameters(model, userTimeSeconds, weight, entry); - - if (entry.Motion.GetDuration() > 0f) + ACubismMotion motion = entry.Motion; + if (motion == null) { - if (userTimeSeconds >= entry.EndTime && !entry.Motion.IsLoop) - { - entry.IsFinished = true; - entry.Motion.OnFinishedMotion?.Invoke(entry.Motion); - } + _entries.RemoveAt(i); + continue; } + + // ------ Применение значений (финализация — внутри моушена/обёртки) ------ + 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) + { + _entries.RemoveAt(i); + continue; + } + + // Фейд-аут, запланированный при вытеснении, стартует после обновления. + if (entry.IsTriggeredFadeOut) + { + entry.StartFadeout(entry.FadeOutSeconds, userTimeSeconds); + } + + i++; } } } diff --git a/source_code/Cubism/Framework/CubismNativeModel.cs b/source_code/Cubism/Framework/CubismNativeModel.cs index 5558bb2..8cab8e5 100644 --- a/source_code/Cubism/Framework/CubismNativeModel.cs +++ b/source_code/Cubism/Framework/CubismNativeModel.cs @@ -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(); + } + + /// + /// Routes native Core log output (the only channel for native-side errors: + /// invalid moc3, unsupported version, alignment failures) into GameLogger. + /// + 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,38 +95,95 @@ namespace Cthangover.Live2D.Cubism.Framework /// 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); - 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); - - IntPtr moc = CubismCoreBindings.csmReviveMocInPlace( - (void*)alignedMocPtr, (uint)mocBytes.Length); - if (moc == IntPtr.Zero) + try { - GameLogger.Log("LIVE2D", "LoadMoc: csmReviveMocInPlace failed — .moc3 data is invalid or corrupted", LogLevel.Error); - throw new InvalidOperationException("csmReviveMocInPlace failed"); + 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, mocBufferSize); + if (moc == IntPtr.Zero) + { + GameLogger.Log("LIVE2D", "LoadMoc: csmReviveMocInPlace failed — .moc3 data is invalid or corrupted", LogLevel.Error); + throw new InvalidOperationException("csmReviveMocInPlace failed"); + } + + uint modelSize = CubismCoreBindings.csmGetSizeofModel(moc); + byte[] modelBuffer = new byte[modelSize + CubismCoreBindings.AlignofModel]; + _modelAlignedHandle = GCHandle.Alloc(modelBuffer, GCHandleType.Pinned); + IntPtr modelPtr = _modelAlignedHandle.AddrOfPinnedObject(); + long modelAlignedAddr = (modelPtr.ToInt64() + CubismCoreBindings.AlignofModel - 1) + / CubismCoreBindings.AlignofModel * CubismCoreBindings.AlignofModel; + var alignedModelPtr = new IntPtr(modelAlignedAddr); + + _model = CubismCoreBindings.csmInitializeModelInPlace( + moc, (void*)alignedModelPtr, modelSize); + if (_model == IntPtr.Zero) + { + GameLogger.Log("LIVE2D", "LoadMoc: csmInitializeModelInPlace failed — model memory allocation failed", LogLevel.Error); + throw new InvalidOperationException("csmInitializeModelInPlace failed"); + } } - - uint modelSize = CubismCoreBindings.csmGetSizeofModel(moc); - byte[] modelBuffer = new byte[modelSize + CubismCoreBindings.AlignofModel]; - _modelAlignedHandle = GCHandle.Alloc(modelBuffer, GCHandleType.Pinned); - IntPtr modelPtr = _modelAlignedHandle.AddrOfPinnedObject(); - long modelAlignedAddr = (modelPtr.ToInt64() + CubismCoreBindings.AlignofModel - 1) - / CubismCoreBindings.AlignofModel * CubismCoreBindings.AlignofModel; - var alignedModelPtr = new IntPtr(modelAlignedAddr); - - _model = CubismCoreBindings.csmInitializeModelInPlace( - moc, (void*)alignedModelPtr, modelSize); - if (_model == IntPtr.Zero) + catch { - GameLogger.Log("LIVE2D", "LoadMoc: csmInitializeModelInPlace failed — model memory allocation failed", LogLevel.Error); - throw new InvalidOperationException("csmInitializeModelInPlace failed"); + // 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); @@ -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; + + /// + /// Порт CubismModel::SaveParameters: сохраняет текущие значения параметров в снимок. + /// Вызывается сразу после фазы моушена, чтобы аддитивные слои (выражения, дыхание, + /// look-инъекция) каждый кадр стартовали от одного и того же базового состояния + /// и не накапливались кадр-за-кадром. + /// + 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]; + } + + /// + /// Порт CubismModel::LoadParameters: восстанавливает значения параметров из снимка. + /// До первого SaveParameters ничего не делает (как в SDK). + /// + 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]; + } + /// /// Frees pinned memory handles. The native model handle is set to zero; /// the underlying CubismCore memory was allocated inline and lives in diff --git a/source_code/Cubism/Framework/CubismPhysics.cs b/source_code/Cubism/Framework/CubismPhysics.cs index 442b524..2581a6a 100644 --- a/source_code/Cubism/Framework/CubismPhysics.cs +++ b/source_code/Cubism/Framework/CubismPhysics.cs @@ -7,8 +7,9 @@ namespace Cthangover.Live2D.Cubism.Framework /// Cubism physics simulation engine. Drives secondary motion (hair, cloth, /// accessories) using a chain of particles influenced by parameter inputs /// (X, Y, angle from model parameters), gravity, and wind. - /// - /// Architecture: + /// + /// Bit-exact port of Cubism SDK for Native 5-r.5 Physics/CubismPhysics.cpp. + /// Architecture (mirroring the native rig structures): /// /// holds the complete rig structure: /// sub-rigs, inputs, outputs, and particles @@ -19,26 +20,50 @@ namespace Cthangover.Live2D.Cubism.Framework /// Outputs map particle chain state back to model parameters after /// physics integration /// - /// - /// The simulation runs at the rig's configured FPS (from .physics3.json), - /// using sub-frame accumulation when the display delta time is smaller - /// than the physics step. Output values are interpolated between steps - /// for smooth visual results. + /// + /// The simulation runs at the rig's configured FPS (from .physics3.json, + /// Meta.Fps; 0 means "step once per display frame"). Pendulum results are + /// kept as current/previous rig outputs and the value written to the model + /// parameters is interpolated between them by the remaining sub-frame time. + /// The pure math core (normalization, particle update, output value update) + /// lives in internal static methods operating on plain data for testability. /// public class CubismPhysics { + // ------------------------------------------------------------------ + // Physics constants (SDK CubismPhysics.cpp anonymous namespace). + // ------------------------------------------------------------------ private const float AirResistance = 5f; private const float MaximumWeight = 100f; + private const float MovementThreshold = 0.001f; + private const float MaxDeltaTime = 5f; + + /// SDK CubismMath::Pi (identical to MathF.PI as a csmFloat32). + private const float Pi = MathF.PI; private readonly CubismPhysicsRig _rig = new(); - private readonly List _currentOutputs = new(); - private readonly List _previousOutputs = new(); + + // Runtime options (SDK _options). Native defaults: gravity (0, -1), wind (0, 0). + private float _optionsGravityX; + private float _optionsGravityY = -1f; + private float _optionsWindX; + private float _optionsWindY; + + // Parameter value caches used for sub-frame input interpolation + // (SDK _parameterCaches / _parameterInputCaches). + private float[] _parameterCaches = Array.Empty(); + private float[] _parameterInputCaches = Array.Empty(); + + // Pendulum output history (SDK _currentRigOutputs / _previousRigOutputs, + // flattened to one slot per absolute output index). + private float[] _currentRigOutputs = Array.Empty(); + private float[] _previousRigOutputs = Array.Empty(); + private float _currentRemainTime; - private float _physicsDeltaTime; /// /// Parses a .physics3.json byte buffer and builds the full physics rig - /// with sub-rigs, inputs, outputs, and particles. + /// with sub-rigs, inputs, outputs, and particles (SDK Parse + Initialize). /// public CubismPhysics(byte[] physics3JsonBytes) { @@ -47,319 +72,1058 @@ namespace Cthangover.Live2D.Cubism.Framework json.GetGravity(out _rig.GravityX, out _rig.GravityY); json.GetWind(out _rig.WindX, out _rig.WindY); _rig.Fps = json.GetFps(); - _physicsDeltaTime = 1f / _rig.Fps; - int inputOffset = 0, outputOffset = 0, particleOffset = 0; - for (int si = 0; si < json.GetSubRigCount(); si++) + int subRigCount = json.GetSubRigCount(); + var particles = new List(json.GetVertexCount()); + _rig.Inputs.Capacity = json.GetTotalInputCount(); + _rig.Outputs.Capacity = json.GetTotalOutputCount(); + + int inputIndex = 0, outputIndex = 0, particleIndex = 0; + for (int si = 0; si < subRigCount; si++) { var subRig = new CubismPhysicsSubRig { - InputCount = json.GetInputCount(si), - OutputCount = json.GetOutputCount(si), - ParticleCount = json.GetVertexCount(si), - BaseInputIndex = inputOffset, - BaseOutputIndex = outputOffset, - BaseParticleIndex = particleOffset + BaseInputIndex = inputIndex, + BaseOutputIndex = outputIndex, + BaseParticleIndex = particleIndex, }; json.GetNormalizationPosition(si, out subRig.NormalizationPosition); json.GetNormalizationAngle(si, out subRig.NormalizationAngle); - _rig.SubRigs.Add(subRig); - for (int ii = 0; ii < subRig.InputCount; ii++) + // Input + subRig.InputCount = json.GetInputCount(si); + for (int j = 0; j < subRig.InputCount; j++) { - json.GetInput(si, ii, out var pid, out var w, out var type, out var reflect); _rig.Inputs.Add(new CubismPhysicsInput { - ParameterId = pid, Weight = w, Type = type, Reflect = reflect + SourceParameterIndex = -1, + Weight = json.GetInputWeight(si, j), + Reflect = json.GetInputReflect(si, j), + Type = ParseSource(json.GetInputType(si, j)), + ParameterId = json.GetInputSourceId(si, j), }); } + inputIndex += subRig.InputCount; - for (int oi = 0; oi < subRig.OutputCount; oi++) + // Output + subRig.OutputCount = json.GetOutputCount(si); + for (int j = 0; j < subRig.OutputCount; j++) { - json.GetOutput(si, oi, out var pid, out var vi, out var sx, out var sy, - out var w, out var type, out var reflect); _rig.Outputs.Add(new CubismPhysicsOutput { - ParameterId = pid, VertexIndex = vi + particleOffset, - TranslationScaleX = sx, TranslationScaleY = sy, Weight = w, - Type = type, Reflect = reflect + DestinationParameterIndex = -1, + VertexIndex = json.GetOutputVertexIndex(si, j), + AngleScale = json.GetOutputAngleScale(si, j), + Weight = json.GetOutputWeight(si, j), + ParameterId = json.GetOutputsDestinationId(si, j), + Type = ParseSource(json.GetOutputType(si, j)), + Reflect = json.GetOutputReflect(si, j), + // TranslationScale stays (0, 0): native never loads a + // translation scale from physics3.json, so X/Y outputs + // scale by zero exactly like the native implementation. }); } + outputIndex += subRig.OutputCount; - int totalParticles = particleOffset + subRig.ParticleCount; - var newParticles = new CubismPhysicsParticle[totalParticles]; - if (_rig.Particles.Length > 0) - System.Array.Copy(_rig.Particles, newParticles, _rig.Particles.Length); - _rig.Particles = newParticles; - - for (int pi = 0; pi < subRig.ParticleCount; pi++) + // Particle + subRig.ParticleCount = json.GetParticleCount(si); + for (int j = 0; j < subRig.ParticleCount; j++) { - var pIdx = particleOffset + pi; - json.GetParticle(si, pi, out var mob, out var del, out var acc, out var rad, - out var px, out var py); - _rig.Particles[pIdx] = new CubismPhysicsParticle + json.GetParticlePosition(si, j, out var px, out var py); + particles.Add(new CubismPhysicsParticle { - InitialX = px, InitialY = py, - Mobility = mob, Delay = del, Acceleration = acc, Radius = rad, - PositionX = px, PositionY = py, - LastPositionX = px, LastPositionY = py - }; + InitialX = px, + InitialY = py, + Mobility = json.GetParticleMobility(si, j), + Delay = json.GetParticleDelay(si, j), + Acceleration = json.GetParticleAcceleration(si, j), + Radius = json.GetParticleRadius(si, j), + PositionX = px, + PositionY = py, + }); } + particleIndex += subRig.ParticleCount; - inputOffset += subRig.InputCount; - outputOffset += subRig.OutputCount; - particleOffset += subRig.ParticleCount; + _rig.SubRigs.Add(subRig); } - _currentOutputs.Capacity = _rig.Outputs.Count; - _previousOutputs.Capacity = _rig.Outputs.Count; - for (int i = 0; i < _rig.Outputs.Count; i++) + _rig.Particles = particles.ToArray(); + + _currentRigOutputs = new float[_rig.Outputs.Count]; + _previousRigOutputs = new float[_rig.Outputs.Count]; + + Initialize(); + + // SDK CubismPhysics::Create zeroes the rig gravity Y after parsing. + _rig.GravityY = 0f; + } + + /// + /// Lays out the particle chains at their rest positions + /// (SDK CubismPhysics::Initialize). + /// + private void Initialize() + { + foreach (var currentSetting in _rig.SubRigs) { - _currentOutputs.Add(0f); - _previousOutputs.Add(0f); + int baseParticleIndex = currentSetting.BaseParticleIndex; + + // Initialize the top of the strand. + _rig.Particles[baseParticleIndex].InitialX = 0f; + _rig.Particles[baseParticleIndex].InitialY = 0f; + _rig.Particles[baseParticleIndex].LastPositionX = 0f; + _rig.Particles[baseParticleIndex].LastPositionY = 0f; + _rig.Particles[baseParticleIndex].LastGravityX = 0f; + _rig.Particles[baseParticleIndex].LastGravityY = 1f; // (0, -1) with Y negated + _rig.Particles[baseParticleIndex].VelocityX = 0f; + _rig.Particles[baseParticleIndex].VelocityY = 0f; + _rig.Particles[baseParticleIndex].ForceX = 0f; + _rig.Particles[baseParticleIndex].ForceY = 0f; + + // Initialize the remaining particles along +Y by radius. + for (int i = 1; i < currentSetting.ParticleCount; ++i) + { + int idx = baseParticleIndex + i; + _rig.Particles[idx].InitialX = _rig.Particles[idx - 1].InitialX; + _rig.Particles[idx].InitialY = _rig.Particles[idx - 1].InitialY + _rig.Particles[idx].Radius; + _rig.Particles[idx].PositionX = _rig.Particles[idx].InitialX; + _rig.Particles[idx].PositionY = _rig.Particles[idx].InitialY; + _rig.Particles[idx].LastPositionX = _rig.Particles[idx].InitialX; + _rig.Particles[idx].LastPositionY = _rig.Particles[idx].InitialY; + _rig.Particles[idx].LastGravityX = 0f; + _rig.Particles[idx].LastGravityY = 1f; // (0, -1) with Y negated + _rig.Particles[idx].VelocityX = 0f; + _rig.Particles[idx].VelocityY = 0f; + _rig.Particles[idx].ForceX = 0f; + _rig.Particles[idx].ForceY = 0f; + } } } /// - /// Overrides gravity and wind at runtime. + /// Overrides gravity and wind at runtime (SDK SetOptions; these feed the + /// wind force during simulation and the parent gravity of angle outputs). /// public void SetOptions(float gravityX, float gravityY, float windX, float windY) { - _rig.GravityX = gravityX; - _rig.GravityY = gravityY; - _rig.WindX = windX; - _rig.WindY = windY; + _optionsGravityX = gravityX; + _optionsGravityY = gravityY; + _optionsWindX = windX; + _optionsWindY = windY; } /// - /// Forces the physics to stabilize immediately by running one update - /// cycle in stabilization mode. Particles snap to their rest positions - /// along the chain direction. + /// Forces the physics to stabilize immediately (SDK CubismPhysics::Stabilization). + /// Particles snap to their rest positions along the current gravity + /// direction and the resolved outputs are written into the model. /// - public void Stabilization(CubismNativeModel model) + public unsafe void Stabilization(CubismNativeModel model) { - UpdatePhysics(model, true); + int parameterCount = model.ParameterCount; + float* parameterValues = model.GetParameterValues(); + float* parameterMaximumValues = model.GetParameterMaximumValues(); + float* parameterMinimumValues = model.GetParameterMinimumValues(); + float* parameterDefaultValues = model.GetParameterDefaultValues(); + + if (_parameterCaches.Length < parameterCount) + { + _parameterCaches = new float[parameterCount]; + } + if (_parameterInputCaches.Length < parameterCount) + { + _parameterInputCaches = new float[parameterCount]; + } + + for (int j = 0; j < parameterCount; ++j) + { + _parameterCaches[j] = parameterValues[j]; + _parameterInputCaches[j] = parameterValues[j]; + } + + foreach (var currentSetting in _rig.SubRigs) + { + float totalAngle = 0f; + float totalTranslationX = 0f, totalTranslationY = 0f; + + // Load input parameters. + for (int i = 0; i < currentSetting.InputCount; ++i) + { + int inputIndex = currentSetting.BaseInputIndex + i; + var input = _rig.Inputs[inputIndex]; + float weight = input.Weight / MaximumWeight; + + if (input.SourceParameterIndex == -1) + { + input.SourceParameterIndex = ResolveParameterIndex(model, input.ParameterId); + } + if (input.SourceParameterIndex == -1) + { + continue; + } + _rig.Inputs[inputIndex] = input; + + int source = input.SourceParameterIndex; + switch (input.Type) + { + case CubismPhysicsSource.X: + GetInputTranslationXFromNormalizedParameterValue( + ref totalTranslationX, ref totalTranslationY, ref totalAngle, + parameterValues[source], + parameterMinimumValues[source], + parameterMaximumValues[source], + parameterDefaultValues[source], + currentSetting.NormalizationPosition, + currentSetting.NormalizationAngle, + input.Reflect, weight); + break; + case CubismPhysicsSource.Y: + GetInputTranslationYFromNormalizedParameterValue( + ref totalTranslationX, ref totalTranslationY, ref totalAngle, + parameterValues[source], + parameterMinimumValues[source], + parameterMaximumValues[source], + parameterDefaultValues[source], + currentSetting.NormalizationPosition, + currentSetting.NormalizationAngle, + input.Reflect, weight); + break; + case CubismPhysicsSource.Angle: + GetInputAngleFromNormalizedParameterValue( + ref totalTranslationX, ref totalTranslationY, ref totalAngle, + parameterValues[source], + parameterMinimumValues[source], + parameterMaximumValues[source], + parameterDefaultValues[source], + currentSetting.NormalizationPosition, + currentSetting.NormalizationAngle, + input.Reflect, weight); + break; + } + + _parameterCaches[source] = parameterValues[source]; + } + + float radAngle = DegreesToRadian(-totalAngle); + + // NOTE: native computes Y from the already-updated X + // (bug-compatible with CubismPhysics.cpp Stabilization). + totalTranslationX = totalTranslationX * CosF(radAngle) - totalTranslationY * SinF(radAngle); + totalTranslationY = totalTranslationX * SinF(radAngle) + totalTranslationY * CosF(radAngle); + + // Calculate particles position. + UpdateParticlesForStabilization( + _rig.Particles, currentSetting.BaseParticleIndex, currentSetting.ParticleCount, + totalTranslationX, totalTranslationY, totalAngle, + _optionsWindX, _optionsWindY, + MovementThreshold * currentSetting.NormalizationPosition.Maximum); + + // Update output parameters. + for (int i = 0; i < currentSetting.OutputCount; ++i) + { + int outputIndex = currentSetting.BaseOutputIndex + i; + var output = _rig.Outputs[outputIndex]; + int particleIndex = output.VertexIndex; + + if (output.DestinationParameterIndex == -1) + { + output.DestinationParameterIndex = ResolveParameterIndex(model, output.ParameterId); + } + + if (particleIndex < 1 || particleIndex >= currentSetting.ParticleCount) + { + _rig.Outputs[outputIndex] = output; + continue; + } + + int vertex = currentSetting.BaseParticleIndex + particleIndex; + float translationX = _rig.Particles[vertex].PositionX - _rig.Particles[vertex - 1].PositionX; + float translationY = _rig.Particles[vertex].PositionY - _rig.Particles[vertex - 1].PositionY; + + float outputValue = GetOutputValue( + output.Type, translationX, translationY, + _rig.Particles, currentSetting.BaseParticleIndex, particleIndex, + output.Reflect, _optionsGravityX, _optionsGravityY); + + _currentRigOutputs[outputIndex] = outputValue; + _previousRigOutputs[outputIndex] = outputValue; + + if (output.DestinationParameterIndex != -1) + { + int destination = output.DestinationParameterIndex; + UpdateOutputParameterValue( + ref parameterValues[destination], + parameterMinimumValues[destination], + parameterMaximumValues[destination], + outputValue, ref output); + + _rig.Outputs[outputIndex] = output; + _parameterCaches[destination] = parameterValues[destination]; + } + } + } } /// - /// Evaluates physics for one display frame. + /// Evaluates physics for one display frame (SDK CubismPhysics::Evaluate). /// Accumulates time and runs physics steps at the rig's native FPS. - /// Output values are linearly interpolated between the previous and - /// current step for smooth visual results. + /// Inputs are linearly interpolated between the cached and live + /// parameter values at each step, and the output written to the model + /// parameters is interpolated between the previous and current pendulum + /// results by the remaining sub-frame time. /// Called from every frame. /// public unsafe void Evaluate(CubismNativeModel model, float deltaTimeSeconds) { + if (0f >= deltaTimeSeconds) + { + return; + } + + int parameterCount = model.ParameterCount; + float* parameterValues = model.GetParameterValues(); + float* parameterMaximumValues = model.GetParameterMaximumValues(); + float* parameterMinimumValues = model.GetParameterMinimumValues(); + float* parameterDefaultValues = model.GetParameterDefaultValues(); + _currentRemainTime += deltaTimeSeconds; - var physicsDeltaCalc = _physicsDeltaTime > 0f ? _physicsDeltaTime : deltaTimeSeconds; - - var prevValues = model.GetParameterValues(); - var paramCache = stackalloc float[_rig.Inputs.Count]; - for (int i = 0; i < _rig.Inputs.Count; i++) - paramCache[i] = 0f; - - while (_currentRemainTime >= physicsDeltaCalc) + if (_currentRemainTime > MaxDeltaTime) { - _currentRemainTime -= physicsDeltaCalc; - - for (int i = 0; i < _rig.Outputs.Count; i++) - { - _previousOutputs[i] = _currentOutputs[i]; - _currentOutputs[i] = 0f; - } - - UpdatePhysics(model, false); - - for (int i = 0; i < _rig.Outputs.Count; i++) - _currentOutputs[i] = EvaluateOutputValue(i); + _currentRemainTime = 0f; } - var alpha = physicsDeltaCalc > 0f ? _currentRemainTime / physicsDeltaCalc : 0f; - var idsPtr = model.GetParameterIds(); - var values = model.GetParameterValues(); - var maxValues = model.GetParameterMaximumValues(); - var minValues = model.GetParameterMinimumValues(); - - for (int oi = 0; oi < _rig.Outputs.Count; oi++) + if (_parameterCaches.Length < parameterCount) { - var output = _rig.Outputs[oi]; - var interpolated = _previousOutputs[oi] * (1f - alpha) + _currentOutputs[oi] * alpha; - for (int pi = 0; pi < model.ParameterCount; pi++) + _parameterCaches = new float[parameterCount]; + } + if (_parameterInputCaches.Length < parameterCount) + { + _parameterInputCaches = new float[parameterCount]; + for (int j = 0; j < parameterCount; ++j) { - if (CubismNativeModel.ReadStringFromPtrArray(idsPtr, pi) == output.ParameterId) + _parameterInputCaches[j] = parameterValues[j]; + } + } + + float physicsDeltaTime = _rig.Fps > 0f ? 1f / _rig.Fps : deltaTimeSeconds; + if (!(physicsDeltaTime > 0f)) + { + physicsDeltaTime = deltaTimeSeconds; + } + + // After the MaxDeltaTime clamp the loop can consume at most + // ceil(MaxDeltaTime / physicsDeltaTime) steps; cap it defensively so + // pathological Fps values cannot spin (SDK relies on well-formed data). + int maxSteps = (int)(MaxDeltaTime / physicsDeltaTime) + 2; + int steps = 0; + + while (_currentRemainTime >= physicsDeltaTime) + { + if (++steps > maxSteps) + { + _currentRemainTime = 0f; + break; + } + + // copyRigOutputs: _currentRigOutputs -> _previousRigOutputs. + Array.Copy(_currentRigOutputs, _previousRigOutputs, _currentRigOutputs.Length); + + // Linear interpolation between the cached input values and the + // live parameter values at each UpdateParticles timing. + // _parameterCaches propagates values between groups, which is + // why it is kept separate from _parameterInputCaches. + float inputWeight = physicsDeltaTime / _currentRemainTime; + for (int j = 0; j < parameterCount; ++j) + { + _parameterCaches[j] = _parameterInputCaches[j] * (1f - inputWeight) + parameterValues[j] * inputWeight; + _parameterInputCaches[j] = _parameterCaches[j]; + } + + foreach (var currentSetting in _rig.SubRigs) + { + float totalAngle = 0f; + float totalTranslationX = 0f, totalTranslationY = 0f; + + // Load input parameters. + for (int i = 0; i < currentSetting.InputCount; ++i) { - var result = interpolated * MaximumWeight; - if (result < minValues[pi]) result = minValues[pi]; - if (result > maxValues[pi]) result = maxValues[pi]; - values[pi] = values[pi] * (1f - output.Weight) + result * output.Weight; - break; + int inputIndex = currentSetting.BaseInputIndex + i; + var input = _rig.Inputs[inputIndex]; + float weight = input.Weight / MaximumWeight; + + if (input.SourceParameterIndex == -1) + { + input.SourceParameterIndex = ResolveParameterIndex(model, input.ParameterId); + } + if (input.SourceParameterIndex == -1) + { + continue; + } + _rig.Inputs[inputIndex] = input; + + int source = input.SourceParameterIndex; + switch (input.Type) + { + case CubismPhysicsSource.X: + GetInputTranslationXFromNormalizedParameterValue( + ref totalTranslationX, ref totalTranslationY, ref totalAngle, + _parameterCaches[source], + parameterMinimumValues[source], + parameterMaximumValues[source], + parameterDefaultValues[source], + currentSetting.NormalizationPosition, + currentSetting.NormalizationAngle, + input.Reflect, weight); + break; + case CubismPhysicsSource.Y: + GetInputTranslationYFromNormalizedParameterValue( + ref totalTranslationX, ref totalTranslationY, ref totalAngle, + _parameterCaches[source], + parameterMinimumValues[source], + parameterMaximumValues[source], + parameterDefaultValues[source], + currentSetting.NormalizationPosition, + currentSetting.NormalizationAngle, + input.Reflect, weight); + break; + case CubismPhysicsSource.Angle: + GetInputAngleFromNormalizedParameterValue( + ref totalTranslationX, ref totalTranslationY, ref totalAngle, + _parameterCaches[source], + parameterMinimumValues[source], + parameterMaximumValues[source], + parameterDefaultValues[source], + currentSetting.NormalizationPosition, + currentSetting.NormalizationAngle, + input.Reflect, weight); + break; + } } + + float radAngle = DegreesToRadian(-totalAngle); + + // NOTE: native computes Y from the already-updated X + // (bug-compatible with CubismPhysics.cpp Evaluate). + totalTranslationX = totalTranslationX * CosF(radAngle) - totalTranslationY * SinF(radAngle); + totalTranslationY = totalTranslationX * SinF(radAngle) + totalTranslationY * CosF(radAngle); + + // Calculate particles position. + UpdateParticles( + _rig.Particles, currentSetting.BaseParticleIndex, currentSetting.ParticleCount, + totalTranslationX, totalTranslationY, totalAngle, + _optionsWindX, _optionsWindY, + MovementThreshold * currentSetting.NormalizationPosition.Maximum, + physicsDeltaTime, AirResistance); + + // Update output parameters. + for (int i = 0; i < currentSetting.OutputCount; ++i) + { + int outputIndex = currentSetting.BaseOutputIndex + i; + var output = _rig.Outputs[outputIndex]; + int particleIndex = output.VertexIndex; + + if (output.DestinationParameterIndex == -1) + { + output.DestinationParameterIndex = ResolveParameterIndex(model, output.ParameterId); + } + + if (particleIndex < 1 || particleIndex >= currentSetting.ParticleCount) + { + _rig.Outputs[outputIndex] = output; + continue; + } + + int vertex = currentSetting.BaseParticleIndex + particleIndex; + float translationX = _rig.Particles[vertex].PositionX - _rig.Particles[vertex - 1].PositionX; + float translationY = _rig.Particles[vertex].PositionY - _rig.Particles[vertex - 1].PositionY; + + float outputValue = GetOutputValue( + output.Type, translationX, translationY, + _rig.Particles, currentSetting.BaseParticleIndex, particleIndex, + output.Reflect, _optionsGravityX, _optionsGravityY); + + _currentRigOutputs[outputIndex] = outputValue; + + if (output.DestinationParameterIndex != -1) + { + int destination = output.DestinationParameterIndex; + UpdateOutputParameterValue( + ref _parameterCaches[destination], + parameterMinimumValues[destination], + parameterMaximumValues[destination], + outputValue, ref output); + } + + _rig.Outputs[outputIndex] = output; + } + } + + _currentRemainTime -= physicsDeltaTime; + } + + float alpha = _currentRemainTime / physicsDeltaTime; + Interpolate(parameterValues, parameterMinimumValues, parameterMaximumValues, alpha); + } + + /// + /// Writes the interpolated pendulum results into the model parameters + /// (SDK CubismPhysics::Interpolate). Outputs whose destination was never + /// resolved are skipped. + /// + private unsafe void Interpolate( + float* parameterValues, float* parameterMinimumValues, float* parameterMaximumValues, float weight) + { + foreach (var currentSetting in _rig.SubRigs) + { + for (int i = 0; i < currentSetting.OutputCount; ++i) + { + int outputIndex = currentSetting.BaseOutputIndex + i; + var output = _rig.Outputs[outputIndex]; + + if (output.DestinationParameterIndex == -1) + { + continue; + } + + int destination = output.DestinationParameterIndex; + float interpolated = + _previousRigOutputs[outputIndex] * (1 - weight) + _currentRigOutputs[outputIndex] * weight; + + UpdateOutputParameterValue( + ref parameterValues[destination], + parameterMinimumValues[destination], + parameterMaximumValues[destination], + interpolated, ref output); + + _rig.Outputs[outputIndex] = output; } } } - private unsafe void UpdatePhysics(CubismNativeModel model, bool stabilization) + // ================================================================== + // Pure math core (internal static, no CubismNativeModel dependency — + // unit-testable against the native SDK formulas). + // ================================================================== + + /// Dispatches to the SDK value getter for an output type. + internal static float GetOutputValue( + CubismPhysicsSource type, float translationX, float translationY, + CubismPhysicsParticle[] particles, int baseParticleIndex, int particleIndex, + bool isInverted, float parentGravityX, float parentGravityY) + { + switch (type) + { + case CubismPhysicsSource.X: + return GetOutputTranslationX(translationX, isInverted); + case CubismPhysicsSource.Y: + return GetOutputTranslationY(translationY, isInverted); + case CubismPhysicsSource.Angle: + return GetOutputAngle(translationX, translationY, particles, baseParticleIndex, particleIndex, + isInverted, parentGravityX, parentGravityY); + default: + return GetOutputTranslationX(translationX, isInverted); + } + } + + internal static float GetOutputTranslationX(float translationX, bool isInverted) + { + float outputValue = translationX; + + if (isInverted) + { + outputValue *= -1f; + } + + return outputValue; + } + + internal static float GetOutputTranslationY(float translationY, bool isInverted) + { + float outputValue = translationY; + + if (isInverted) + { + outputValue *= -1f; + } + + return outputValue; + } + + /// + /// SDK GetOutputAngle: the parent gravity is the direction between the + /// two previous particles once the pendulum has at least two segments, + /// otherwise the negated rig gravity. + /// + internal static float GetOutputAngle( + float translationX, float translationY, + CubismPhysicsParticle[] particles, int baseParticleIndex, int particleIndex, + bool isInverted, float parentGravityX, float parentGravityY) + { + float gravityX, gravityY; + if (particleIndex >= 2) + { + gravityX = particles[baseParticleIndex + particleIndex - 1].PositionX + - particles[baseParticleIndex + particleIndex - 2].PositionX; + gravityY = particles[baseParticleIndex + particleIndex - 1].PositionY + - particles[baseParticleIndex + particleIndex - 2].PositionY; + } + else + { + gravityX = -parentGravityX; + gravityY = -parentGravityY; + } + + float outputValue = DirectionToRadian(gravityX, gravityY, translationX, translationY); + + if (isInverted) + { + outputValue *= -1f; + } + + return outputValue; + } + + /// SDK scale getter dispatch: X/Y use the (never loaded, i.e. zero) translation scale; Angle uses AngleScale. + internal static float GetOutputScale(CubismPhysicsSource type, float translationScaleX, float translationScaleY, float angleScale) + { + switch (type) + { + case CubismPhysicsSource.X: + return translationScaleX; + case CubismPhysicsSource.Y: + return translationScaleY; + default: + return angleScale; + } + } + + /// + /// SDK UpdateOutputParameterValue: scale the translation, clamp to the + /// parameter range (recording below-minimum/exceeded-maximum extremes), + /// then blend into the parameter by Output.Weight / 100. + /// + internal static void UpdateOutputParameterValue( + ref float parameterValue, float parameterValueMinimum, float parameterValueMaximum, + float translation, ref CubismPhysicsOutput output) + { + float outputScale = GetOutputScale(output.Type, output.TranslationScaleX, output.TranslationScaleY, output.AngleScale); + + float value = translation * outputScale; + + if (value < parameterValueMinimum) + { + if (value < output.ValueBelowMinimum) + { + output.ValueBelowMinimum = value; + } + + value = parameterValueMinimum; + } + else if (value > parameterValueMaximum) + { + if (value > output.ValueExceededMaximum) + { + output.ValueExceededMaximum = value; + } + + value = parameterValueMaximum; + } + + float weight = output.Weight / MaximumWeight; + + if (weight >= 1f) + { + parameterValue = value; + } + else + { + value = parameterValue * (1f - weight) + value * weight; + parameterValue = value; + } + } + + /// SDK GetRangeValue. + internal static float GetRangeValue(float min, float max) + { + float maxValue = MaxF(min, max); + float minValue = MinF(min, max); + + return AbsF(maxValue - minValue); + } + + /// SDK Sign. + internal static int Sign(float value) + { + if (value > 0f) + { + return 1; + } + if (value < 0f) + { + return -1; + } + + return 0; + } + + /// SDK GetDefaultValue: the middle of the parameter range. + internal static float GetDefaultValue(float min, float max) + { + float minValue = MinF(min, max); + return minValue + GetRangeValue(min, max) / 2f; + } + + /// + /// SDK NormalizeParameterValue: clamp the value to its parameter range, + /// map it piecewise across the normalization range relative to the + /// default middle, and invert when the input is not reflected + /// (native applies the sign flip to non-inverted values). + /// + internal static float NormalizeParameterValue( + float value, + float parameterMinimum, + float parameterMaximum, + float parameterDefault, + float normalizedMinimum, + float normalizedMaximum, + float normalizedDefault, + bool isInverted) + { + float result = 0f; + + float maxValue = MaxF(parameterMaximum, parameterMinimum); + if (maxValue < value) + { + value = maxValue; + } + + float minValue = MinF(parameterMaximum, parameterMinimum); + if (minValue > value) + { + value = minValue; + } + + float minNormValue = MinF(normalizedMinimum, normalizedMaximum); + float maxNormValue = MaxF(normalizedMinimum, normalizedMaximum); + float middleNormValue = normalizedDefault; + + float middleValue = GetDefaultValue(minValue, maxValue); + float paramValue = value - middleValue; + + switch (Sign(paramValue)) + { + case 1: + { + float nLength = maxNormValue - middleNormValue; + float pLength = maxValue - middleValue; + if (pLength != 0f) + { + result = paramValue * (nLength / pLength); + result += middleNormValue; + } + + break; + } + case -1: + { + float nLength = minNormValue - middleNormValue; + float pLength = minValue - middleValue; + if (pLength != 0f) + { + result = paramValue * (nLength / pLength); + result += middleNormValue; + } + + break; + } + default: + { + result = middleNormValue; + + break; + } + } + + return isInverted + ? result + : result * -1f; + } + + /// SDK GetInputTranslationXFromNormalizedParameterValue. + internal static void GetInputTranslationXFromNormalizedParameterValue( + ref float targetTranslationX, ref float targetTranslationY, ref float targetAngle, + float value, float parameterMinimumValue, float parameterMaximumValue, float parameterDefaultValue, + CubismPhysicsNormalization normalizationPosition, CubismPhysicsNormalization normalizationAngle, + bool isInverted, float weight) + { + targetTranslationX += NormalizeParameterValue( + value, + parameterMinimumValue, + parameterMaximumValue, + parameterDefaultValue, + normalizationPosition.Minimum, + normalizationPosition.Maximum, + normalizationPosition.Default, + isInverted + ) * weight; + } + + /// SDK GetInputTranslationYFromNormalizedParameterValue. + internal static void GetInputTranslationYFromNormalizedParameterValue( + ref float targetTranslationX, ref float targetTranslationY, ref float targetAngle, + float value, float parameterMinimumValue, float parameterMaximumValue, float parameterDefaultValue, + CubismPhysicsNormalization normalizationPosition, CubismPhysicsNormalization normalizationAngle, + bool isInverted, float weight) + { + targetTranslationY += NormalizeParameterValue( + value, + parameterMinimumValue, + parameterMaximumValue, + parameterDefaultValue, + normalizationPosition.Minimum, + normalizationPosition.Maximum, + normalizationPosition.Default, + isInverted + ) * weight; + } + + /// SDK GetInputAngleFromNormalizedParameterValue. + internal static void GetInputAngleFromNormalizedParameterValue( + ref float targetTranslationX, ref float targetTranslationY, ref float targetAngle, + float value, float parameterMinimumValue, float parameterMaximumValue, float parameterDefaultValue, + CubismPhysicsNormalization normalizationPosition, CubismPhysicsNormalization normalizationAngle, + bool isInverted, float weight) + { + targetAngle += NormalizeParameterValue( + value, + parameterMinimumValue, + parameterMaximumValue, + parameterDefaultValue, + normalizationAngle.Minimum, + normalizationAngle.Maximum, + normalizationAngle.Default, + isInverted + ) * weight; + } + + /// + /// SDK UpdateParticles: one integration step of a particle strand. + /// The root is pinned to the total translation, each particle keeps its + /// radius distance from its predecessor, is rotated with the gravity + /// change (divided by air resistance), integrates velocity and force, + /// and stores velocity for the next step (only when delay != 0). + /// + internal static void UpdateParticles( + CubismPhysicsParticle[] strand, int baseParticleIndex, int strandCount, + float totalTranslationX, float totalTranslationY, float totalAngle, + float windX, float windY, float thresholdValue, float deltaTimeSeconds, float airResistance) + { + strand[baseParticleIndex].PositionX = totalTranslationX; + strand[baseParticleIndex].PositionY = totalTranslationY; + + float totalRadian = DegreesToRadian(totalAngle); + float currentGravityX = SinF(totalRadian); + float currentGravityY = CosF(totalRadian); + Normalize(ref currentGravityX, ref currentGravityY); + + for (int i = 1; i < strandCount; ++i) + { + int idx = baseParticleIndex + i; + + strand[idx].ForceX = currentGravityX * strand[idx].Acceleration + windX; + strand[idx].ForceY = currentGravityY * strand[idx].Acceleration + windY; + + strand[idx].LastPositionX = strand[idx].PositionX; + strand[idx].LastPositionY = strand[idx].PositionY; + + float delay = strand[idx].Delay * deltaTimeSeconds * 30f; + + float directionX = strand[idx].PositionX - strand[idx - 1].PositionX; + float directionY = strand[idx].PositionY - strand[idx - 1].PositionY; + + float radian = DirectionToRadian(strand[idx].LastGravityX, strand[idx].LastGravityY, + currentGravityX, currentGravityY) / airResistance; + + // NOTE: native computes Y from the already-updated X + // (bug-compatible with CubismPhysics.cpp UpdateParticles). + float newDirectionX = CosF(radian) * directionX - directionY * SinF(radian); + directionY = SinF(radian) * newDirectionX + directionY * CosF(radian); + directionX = newDirectionX; + + strand[idx].PositionX = strand[idx - 1].PositionX + directionX; + strand[idx].PositionY = strand[idx - 1].PositionY + directionY; + + float velocityX = strand[idx].VelocityX * delay; + float velocityY = strand[idx].VelocityY * delay; + float forceX = strand[idx].ForceX * delay * delay; + float forceY = strand[idx].ForceY * delay * delay; + + strand[idx].PositionX = strand[idx].PositionX + velocityX + forceX; + strand[idx].PositionY = strand[idx].PositionY + velocityY + forceY; + + float newDirectionX2 = strand[idx].PositionX - strand[idx - 1].PositionX; + float newDirectionY2 = strand[idx].PositionY - strand[idx - 1].PositionY; + Normalize(ref newDirectionX2, ref newDirectionY2); + + strand[idx].PositionX = strand[idx - 1].PositionX + newDirectionX2 * strand[idx].Radius; + strand[idx].PositionY = strand[idx - 1].PositionY + newDirectionY2 * strand[idx].Radius; + + if (AbsF(strand[idx].PositionX) < thresholdValue) + { + strand[idx].PositionX = 0f; + } + + if (delay != 0f) + { + strand[idx].VelocityX = strand[idx].PositionX - strand[idx].LastPositionX; + strand[idx].VelocityY = strand[idx].PositionY - strand[idx].LastPositionY; + strand[idx].VelocityX /= delay; + strand[idx].VelocityY /= delay; + strand[idx].VelocityX *= strand[idx].Mobility; + strand[idx].VelocityY *= strand[idx].Mobility; + } + + strand[idx].ForceX = 0f; + strand[idx].ForceY = 0f; + strand[idx].LastGravityX = currentGravityX; + strand[idx].LastGravityY = currentGravityY; + } + } + + /// + /// SDK UpdateParticlesForStabilization: velocity is zeroed and each + /// particle snaps to one normalized-force radius from its predecessor. + /// + internal static void UpdateParticlesForStabilization( + CubismPhysicsParticle[] strand, int baseParticleIndex, int strandCount, + float totalTranslationX, float totalTranslationY, float totalAngle, + float windX, float windY, float thresholdValue) + { + strand[baseParticleIndex].PositionX = totalTranslationX; + strand[baseParticleIndex].PositionY = totalTranslationY; + + float totalRadian = DegreesToRadian(totalAngle); + float currentGravityX = SinF(totalRadian); + float currentGravityY = CosF(totalRadian); + Normalize(ref currentGravityX, ref currentGravityY); + + for (int i = 1; i < strandCount; ++i) + { + int idx = baseParticleIndex + i; + + strand[idx].ForceX = currentGravityX * strand[idx].Acceleration + windX; + strand[idx].ForceY = currentGravityY * strand[idx].Acceleration + windY; + + strand[idx].LastPositionX = strand[idx].PositionX; + strand[idx].LastPositionY = strand[idx].PositionY; + + strand[idx].VelocityX = 0f; + strand[idx].VelocityY = 0f; + + float forceX = strand[idx].ForceX; + float forceY = strand[idx].ForceY; + Normalize(ref forceX, ref forceY); + + forceX *= strand[idx].Radius; + forceY *= strand[idx].Radius; + strand[idx].PositionX = strand[idx - 1].PositionX + forceX; + strand[idx].PositionY = strand[idx - 1].PositionY + forceY; + + if (AbsF(strand[idx].PositionX) < thresholdValue) + { + strand[idx].PositionX = 0f; + } + + strand[idx].ForceX = 0f; + strand[idx].ForceY = 0f; + strand[idx].LastGravityX = currentGravityX; + strand[idx].LastGravityY = currentGravityY; + } + } + + // ================================================================== + // SDK math equivalents (bit-exact formulas; do not "simplify"). + // ================================================================== + + /// SDK CubismMath::DegreesToRadian — note the divide-first order. + internal static float DegreesToRadian(float degrees) + { + return (degrees / 180f) * Pi; + } + + /// SDK CubismMath::DirectionToRadian — atan2 difference wrapped into [-Pi, Pi]. + internal static float DirectionToRadian(float fromX, float fromY, float toX, float toY) + { + float q1 = MathF.Atan2(toY, toX); + float q2 = MathF.Atan2(fromY, fromX); + + float ret = q1 - q2; + + while (ret < -Pi) + { + ret += Pi * 2f; + } + + while (ret > Pi) + { + ret -= Pi * 2f; + } + + return ret; + } + + /// SDK CubismMath::RadianToDirection — (sin, cos). + internal static void RadianToDirection(float totalAngle, out float x, out float y) + { + x = SinF(totalAngle); + y = CosF(totalAngle); + } + + /// + /// SDK CubismVector2::Normalize — note that native uses + /// powf(squareLength, 0.5f), not sqrtf, and does not guard zero length. + /// + internal static void Normalize(ref float x, ref float y) + { + float length = MathF.Pow(x * x + y * y, 0.5f); + + x = x / length; + y = y / length; + } + + private static float SinF(float x) => MathF.Sin(x); + + private static float CosF(float x) => MathF.Cos(x); + + private static float AbsF(float x) => MathF.Abs(x); + + private static float MinF(float l, float r) => (l > r) ? r : l; + + private static float MaxF(float l, float r) => (l > r) ? l : r; + + /// Resolves a parameter id to its index; -1 when not present (native asserts instead). + private static unsafe int ResolveParameterIndex(CubismNativeModel model, string parameterId) { var idsPtr = model.GetParameterIds(); - var values = model.GetParameterValues(); - var minValues = model.GetParameterMinimumValues(); - var maxValues = model.GetParameterMaximumValues(); - var defaultValues = model.GetParameterDefaultValues(); - - foreach (var subRig in _rig.SubRigs) + for (int i = 0; i < model.ParameterCount; i++) { - float totalTranslationX = 0f, totalTranslationY = 0f, totalAngle = 0f; - - for (int ii = 0; ii < subRig.InputCount; ii++) + if (CubismNativeModel.ReadStringFromPtrArray(idsPtr, i) == parameterId) { - var input = _rig.Inputs[subRig.BaseInputIndex + ii]; - var paramValue = GetParameterValue(idsPtr, values, model.ParameterCount, input.ParameterId); - - float tx = 0f, ty = 0f, angle = 0f; - switch (input.Type) - { - case CubismPhysicsSource.X: - tx = NormalizeParameterValue(paramValue, subRig.NormalizationPosition, input.Reflect) * input.Weight; - break; - case CubismPhysicsSource.Y: - ty = NormalizeParameterValue(paramValue, subRig.NormalizationPosition, input.Reflect) * input.Weight; - break; - case CubismPhysicsSource.Angle: - angle = NormalizeParameterValue(paramValue, subRig.NormalizationAngle, input.Reflect) * input.Weight; - break; - } - - totalTranslationX += tx; - totalTranslationY += ty; - totalAngle += angle; - } - - var particles = _rig.Particles; - int baseP = subRig.BaseParticleIndex; - - float prevRootX = particles[baseP].PositionX; - float prevRootY = particles[baseP].PositionY; - float smoothRate = MathF.Min(1f, _physicsDeltaTime * 12f); - particles[baseP].PositionX = prevRootX + (totalTranslationX - prevRootX) * smoothRate; - particles[baseP].PositionY = prevRootY + (totalTranslationY - prevRootY) * smoothRate; - - for (int pi = 1; pi < subRig.ParticleCount; pi++) - { - var pIdx = baseP + pi; - var prev = particles[pIdx - 1]; - - if (stabilization) - { - var dirX = particles[pIdx].InitialX - prev.InitialX; - var dirY = particles[pIdx].InitialY - prev.InitialY; - var dist = MathF.Sqrt(dirX * dirX + dirY * dirY); - if (dist > 0f) - { - dirX /= dist; - dirY /= dist; - } - particles[pIdx].PositionX = particles[pIdx - 1].PositionX + dirX * particles[pIdx].Radius; - particles[pIdx].PositionY = particles[pIdx - 1].PositionY + dirY * particles[pIdx].Radius; - } - else - { - var forceX = _rig.GravityX * particles[pIdx].Acceleration + _rig.WindX; - var forceY = _rig.GravityY * particles[pIdx].Acceleration + _rig.WindY; - - var delay = particles[pIdx].Delay * _physicsDeltaTime * 30f; - if (delay > 0f) - { - var delay2 = delay * delay; - var newX = particles[pIdx].PositionX + particles[pIdx].VelocityX * delay + 0.5f * forceX * delay2; - var newY = particles[pIdx].PositionY + particles[pIdx].VelocityY * delay + 0.5f * forceY * delay2; - - var radian = CubismMath.DirectionToRadian( - particles[pIdx].LastGravityX, particles[pIdx].LastGravityY, forceX, forceY); - radian /= AirResistance; - - var dirX = newX - particles[pIdx - 1].PositionX; - var dirY = newY - particles[pIdx - 1].PositionY; - var currentDist = MathF.Sqrt(dirX * dirX + dirY * dirY); - if (currentDist > 0f) - { - var ndirX = dirX / currentDist; - var ndirY = dirY / currentDist; - var cosR = MathF.Cos(radian); - var sinR = MathF.Sin(radian); - dirX = ndirX * cosR - ndirY * sinR; - dirY = ndirX * sinR + ndirY * cosR; - } - particles[pIdx].VelocityX = (newX - particles[pIdx].LastPositionX) / delay * particles[pIdx].Mobility; - particles[pIdx].VelocityY = (newY - particles[pIdx].LastPositionY) / delay * particles[pIdx].Mobility; - } - - particles[pIdx].LastGravityX = forceX; - particles[pIdx].LastGravityY = forceY; - particles[pIdx].ForceX = forceX; - particles[pIdx].ForceY = forceY; - - var curDirX = particles[pIdx].PositionX - particles[pIdx - 1].PositionX; - var curDirY = particles[pIdx].PositionY - particles[pIdx - 1].PositionY; - var curDist = MathF.Sqrt(curDirX * curDirX + curDirY * curDirY); - if (curDist > 0f) - { - curDirX = curDirX / curDist * particles[pIdx].Radius; - curDirY = curDirY / curDist * particles[pIdx].Radius; - } - particles[pIdx].PositionX = particles[pIdx - 1].PositionX + curDirX; - particles[pIdx].PositionY = particles[pIdx - 1].PositionY + curDirY; - particles[pIdx].LastPositionX = particles[pIdx].PositionX; - particles[pIdx].LastPositionY = particles[pIdx].PositionY; - } - } - - for (int oi = 0; oi < subRig.OutputCount; oi++) - { - var outIdx = subRig.BaseOutputIndex + oi; - var output = _rig.Outputs[outIdx]; - var particle = particles[output.VertexIndex]; - var value = EvaluateOutputValueStatic(particle, particles, output, baseP, _currentOutputs[outIdx]); - _currentOutputs[outIdx] = value * output.Weight; + return i; } } + + return -1; } - private float EvaluateOutputValue(int outputIndex) + /// Maps a physics3.json type tag to its source enum; unknown tags behave as X (enum value 0, like native). + private static CubismPhysicsSource ParseSource(string value) { - return _currentOutputs[outputIndex]; - } - - private static float EvaluateOutputValueStatic(CubismPhysicsParticle particle, - CubismPhysicsParticle[] particles, CubismPhysicsOutput output, int baseParticleIndex, float currentValue) - { - var pIdx = output.VertexIndex; - var p = particles[pIdx]; - var prev = pIdx > baseParticleIndex ? particles[pIdx - 1] : particles[pIdx]; - - return output.Type switch + switch (value) { - CubismPhysicsSource.X => p.PositionX - prev.PositionX, - CubismPhysicsSource.Y => p.PositionY - prev.PositionY, - CubismPhysicsSource.Angle => - MathF.Atan2(p.PositionY - prev.PositionY, p.PositionX - prev.PositionX) * 180f / MathF.PI, - _ => p.PositionX - }; - } - - private static unsafe float GetParameterValue(IntPtr idsPtr, float* values, int count, string id) - { - for (int i = 0; i < count; i++) - if (CubismNativeModel.ReadStringFromPtrArray(idsPtr, i) == id) - return values[i]; - return 0f; - } - - private static float NormalizeParameterValue(float value, CubismPhysicsNormalization norm, bool reflect) - { - var range = norm.Maximum - norm.Minimum; - if (MathF.Abs(range) < 1e-6f) return 0f; - var result = (value - norm.Default) / range; - return reflect ? -result : result; + case "Angle": return CubismPhysicsSource.Angle; + case "Y": return CubismPhysicsSource.Y; + default: return CubismPhysicsSource.X; + } } } } diff --git a/source_code/Cubism/Framework/CubismPhysicsJson.cs b/source_code/Cubism/Framework/CubismPhysicsJson.cs index f5220a1..6bf4566 100644 --- a/source_code/Cubism/Framework/CubismPhysicsJson.cs +++ b/source_code/Cubism/Framework/CubismPhysicsJson.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using System.Text.Json; @@ -17,7 +16,7 @@ namespace Cthangover.Live2D.Cubism.Framework /// /// 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. /// public struct CubismPhysicsParticle { @@ -61,8 +60,11 @@ namespace Cthangover.Live2D.Cubism.Framework /// /// 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. + /// 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 , so X/Y-typed outputs + /// scale by zero exactly like the native implementation. /// public struct CubismPhysicsOutput { @@ -95,146 +97,251 @@ namespace Cthangover.Live2D.Cubism.Framework } /// - /// 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(). /// public class CubismPhysicsJson { private readonly JsonDocument _json; - /// - /// Parses the given .physics3.json bytes. - /// + /// Parses the given .physics3.json bytes. public CubismPhysicsJson(byte[] jsonBytes) { _json = JsonDocument.Parse(jsonBytes); } - /// Physics simulation FPS from the Meta section, default 30. - public float GetFps() - { - var meta = _json.RootElement.GetProperty("Meta"); - return meta.TryGetProperty("Fps", out var v) ? v.GetSingle() : 30f; - } + // ------------------------------------------------------------------ + // Meta + // ------------------------------------------------------------------ - /// Number of sub-rig entries in PhysicsSettings. - public int GetSubRigCount() => - _json.RootElement.TryGetProperty("PhysicsSettings", out var arr) ? arr.GetArrayLength() : 0; - - /// Reads gravity vector from Meta.EffectiveForces.Gravity. Defaults to (0, -1). + /// Gravity vector from Meta.EffectiveForces.Gravity (missing values read as 0). 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"); } } - /// Reads wind vector from Meta.EffectiveForces.Wind. Defaults to (0, 0). + /// Wind vector from Meta.EffectiveForces.Wind (missing values read as 0). 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"); } } - /// Number of input entries in the given sub-rig. - public int GetInputCount(int subRigIndex) => - GetSubRig(subRigIndex).GetProperty("Input").GetArrayLength(); + /// Simulation FPS from Meta.Fps; 0 when absent (native behavior). + public float GetFps() + { + return _json.RootElement.TryGetProperty("Meta", out var meta) ? GetFloat(meta, "Fps") : 0f; + } - /// Number of output entries in the given sub-rig. - public int GetOutputCount(int subRigIndex) => - GetSubRig(subRigIndex).GetProperty("Output").GetArrayLength(); + /// Sub-rig count from Meta.PhysicsSettingCount. + public int GetSubRigCount() + { + if (_json.RootElement.TryGetProperty("Meta", out var meta) && + meta.TryGetProperty("PhysicsSettingCount", out var count)) + { + return count.GetInt32(); + } - /// Number of vertex/particle entries in the given sub-rig. - 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; + } - /// Reads position normalization (min, max, default) for the given sub-rig. + /// Total input count from Meta.TotalInputCount. + public int GetTotalInputCount() => GetMetaInt("TotalInputCount"); + + /// Total output count from Meta.TotalOutputCount. + public int GetTotalOutputCount() => GetMetaInt("TotalOutputCount"); + + /// Total vertex/particle count from Meta.VertexCount. + public int GetVertexCount() => GetMetaInt("VertexCount"); + + // ------------------------------------------------------------------ + // Normalization + // ------------------------------------------------------------------ + + /// Position normalization (Minimum/Maximum/Default) for a sub-rig; missing values read as 0. 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"); + } } - /// Reads angle normalization (min, max, default) for the given sub-rig. + /// Angle normalization (Minimum/Maximum/Default) for a sub-rig; missing values read as 0. 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"); + } } - /// Reads a single input entry: parameter ID, weight, type, reflect flag. - public void GetInput(int subRigIndex, int inputIndex, out string parameterId, out float weight, - out CubismPhysicsSource type, out bool reflect) + // ------------------------------------------------------------------ + // Input + // ------------------------------------------------------------------ + + /// Number of input entries in the given sub-rig. + public int GetInputCount(int subRigIndex) => GetEntriesLength(subRigIndex, "Input"); + + /// Input weight (Input[i].Weight). + public float GetInputWeight(int subRigIndex, int inputIndex) => + GetFloat(GetInputEntry(subRigIndex, inputIndex), "Weight"); + + /// Input reflect flag (Input[i].Reflect). + public bool GetInputReflect(int subRigIndex, int inputIndex) => + GetBool(GetInputEntry(subRigIndex, inputIndex), "Reflect"); + + /// Input type tag (Input[i].Type): "X", "Y" or "Angle". + public string GetInputType(int subRigIndex, int inputIndex) => + GetString(GetInputEntry(subRigIndex, inputIndex), "Type"); + + /// Source parameter id (Input[i].Source.Id). + 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; } - /// Reads a single output entry: destination parameter ID, vertex index, scales, type, reflect. - 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 + // ------------------------------------------------------------------ + + /// Number of output entries in the given sub-rig. + public int GetOutputCount(int subRigIndex) => GetEntriesLength(subRigIndex, "Output"); + + /// Pendulum vertex index (Output[i].VertexIndex), relative to the sub-rig base particle. + public int GetOutputVertexIndex(int subRigIndex, int outputIndex) => + GetInt(GetOutputEntry(subRigIndex, outputIndex), "VertexIndex"); + + /// Angle scale (Output[i].Scale). Native maps the single "Scale" value to the angle scale only. + public float GetOutputAngleScale(int subRigIndex, int outputIndex) => + GetFloat(GetOutputEntry(subRigIndex, outputIndex), "Scale"); + + /// Output weight (Output[i].Weight). + public float GetOutputWeight(int subRigIndex, int outputIndex) => + GetFloat(GetOutputEntry(subRigIndex, outputIndex), "Weight"); + + /// Destination parameter id (Output[i].Destination.Id). + 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; } - /// Reads a single particle/vertex: mobility, delay, acceleration, radius, position. - 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) + /// Output type tag (Output[i].Type): "X", "Y" or "Angle". + public string GetOutputType(int subRigIndex, int outputIndex) => + GetString(GetOutputEntry(subRigIndex, outputIndex), "Type"); + + /// Output reflect flag (Output[i].Reflect). + public bool GetOutputReflect(int subRigIndex, int outputIndex) => + GetBool(GetOutputEntry(subRigIndex, outputIndex), "Reflect"); + + // ------------------------------------------------------------------ + // Particle (Vertices) + // ------------------------------------------------------------------ + + /// Number of vertex/particle entries in the given sub-rig. + public int GetParticleCount(int subRigIndex) => GetEntriesLength(subRigIndex, "Vertices"); + + /// Particle mobility (Vertices[i].Mobility). + public float GetParticleMobility(int subRigIndex, int vertexIndex) => + GetFloat(GetVertexEntry(subRigIndex, vertexIndex), "Mobility"); + + /// Particle delay (Vertices[i].Delay). + public float GetParticleDelay(int subRigIndex, int vertexIndex) => + GetFloat(GetVertexEntry(subRigIndex, vertexIndex), "Delay"); + + /// Particle acceleration (Vertices[i].Acceleration). + public float GetParticleAcceleration(int subRigIndex, int vertexIndex) => + GetFloat(GetVertexEntry(subRigIndex, vertexIndex), "Acceleration"); + + /// Particle radius (Vertices[i].Radius). + public float GetParticleRadius(int subRigIndex, int vertexIndex) => + GetFloat(GetVertexEntry(subRigIndex, vertexIndex), "Radius"); + + /// Particle position (Vertices[i].Position.X/Y). + 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; } } diff --git a/source_code/Cubism/Framework/CubismPose.cs b/source_code/Cubism/Framework/CubismPose.cs index ed003bc..cd0aed3 100644 --- a/source_code/Cubism/Framework/CubismPose.cs +++ b/source_code/Cubism/Framework/CubismPose.cs @@ -6,44 +6,75 @@ namespace Cthangover.Live2D.Cubism.Framework { /// /// 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. - /// - /// How it works: + /// 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. + /// + /// This is a faithful port of CubismPose.cpp (Cubism SDK for Native 5-r.5): /// - /// 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 - /// Each frame, the currently visible part in each group - /// fades toward opacity 1 while all others fade toward 0 - /// Linked parts (defined by the "Link" array in pose data) - /// copy their parent's opacity, cascading visibility changes + /// Part groups are stored as a flattened list with per-group counts + /// (matching the SDK layout, so index arithmetic in Reset/DoFade is identical). + /// 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 (> 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. + /// 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. + /// 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. + /// CopyPartOpacities: parts with a "Link" list copy their current + /// part opacity to every linked part. /// - /// - /// 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. /// 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 Link; + public int PartIndex = -1; + public int ParameterIndex = -1; + public readonly List Link = new(); + + /// + /// SDK CubismPose::PartData::Initialize — resolves both indices for + /// this part id and initializes its pose parameter to 1. + /// + 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> _partGroups = new(); - private readonly float _fadeTimeSeconds; + private readonly List _partGroups = new(); + private readonly List _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 _notExistParameterValues = new(); + private readonly Dictionary _notExistPartOpacities = new(); + /// /// 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. /// 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(); + 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() + 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); } } /// - /// 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 after expressions. /// - 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); } + /// 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. 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 groupCount = _partGroupCounts[gi]; + + for (int j = beginIndex; j < beginIndex + groupCount; j++) { - var pd = group[i]; - pd.PartIndex = -1; - pd.ParameterIndex = -1; + var pd = _partGroups[j]; + pd.Initialize(model, this); - for (int pi = 0; pi < partCount; pi++) - { - if (CubismNativeModel.ReadStringFromPtrArray(idsPtr, pi) == pd.PartId) - { - pd.PartIndex = pi; - opacities[pi] = i == 0 ? 1f : 0f; - break; - } - } + if (pd.PartIndex < 0) + continue; - group[i] = pd; + 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; } } - private unsafe void DoFade(CubismNativeModel model, List group, float deltaTimeSeconds) + /// 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. + private unsafe void DoFade(CubismNativeModel model, float deltaTimeSeconds, int beginIndex, int partGroupCount) { - const float Phi = 0.5f; - const float BackOpacityThreshold = 0.15f; + var visiblePartIndex = -1; + var newOpacity = 1f; - 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++) + // 現在、表示状態になっているパーツを取得 + for (int i = beginIndex; i < beginIndex + partGroupCount; i++) { - var pd = group[i]; - if (pd.PartIndex < 0) continue; + var pd = _partGroups[i]; - if (opacities[pd.PartIndex] > 0.001f) + if (GetParameterValue(model, pd.ParameterIndex, pd.PartId) > Epsilon) { - visibleIndex = i; - break; + if (visiblePartIndex >= 0) + break; + + visiblePartIndex = i; + + if (_fadeTimeSeconds == 0f) + { + newOpacity = 1f; + continue; + } + + 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 pd = _partGroups[i]; + + if (visiblePartIndex == i) { - var fadeRate = deltaTimeSeconds / _fadeTimeSeconds; - newOpacity = MathF.Min(opacities[pd.PartIndex] + fadeRate, 1f); - opacities[pd.PartIndex] = newOpacity; + SetPartOpacity(model, pd.PartIndex, pd.PartId, newOpacity); // 先に設定 } else { - float a1; - if (newOpacity < Phi) - a1 = newOpacity * (Phi - 1f) / Phi + 1f; - else - a1 = (1f - newOpacity) * Phi / (1f - Phi); + var opacity = GetPartOpacity(model, pd.PartIndex, pd.PartId); + var a1 = ComputeHiddenOpacity(newOpacity); - var backOpacity = (1f - a1) * (1f - newOpacity); - if (backOpacity > BackOpacityThreshold) - a1 = 1f - BackOpacityThreshold / (1f - newOpacity); + if (opacity > a1) + opacity = a1; - opacities[pd.PartIndex] = MathF.Min(opacities[pd.PartIndex], a1); + SetPartOpacity(model, pd.PartIndex, pd.PartId, opacity); } } } + /// SDK CubismPose::CopyPartOpacities — every group part with a + /// Link list copies its current part opacity to all linked parts. private unsafe void CopyPartOpacities(CubismNativeModel model) { - var idsPtr = model.GetPartIds(); - var opacities = model.GetPartOpacities(); - int partCount = model.GetPartCount(); - - foreach (var group in _partGroups) + foreach (var partData in _partGroups) { - for (int gi = 0; gi < group.Count; gi++) - { - var pd = group[gi]; - if (pd.PartIndex < 0 || pd.Link == null) continue; + if (partData.Link.Count == 0) + 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; - } - } - } + 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); } } } + + /// Resolves a part id to a part index, or -1 when absent. + 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; + } + + /// Resolves a parameter id to a parameter index, or -1 when absent. + 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; + } + + /// + /// Pure helper (SDK DoFade, visible branch): the visible part's opacity + /// grows by deltaTimeSeconds / fadeTimeSeconds, clamped to 1. + /// Internal for unit tests. + /// + internal static float ComputeVisibleOpacity(float currentOpacity, float deltaTimeSeconds, float fadeTimeSeconds) + { + var newOpacity = currentOpacity + (deltaTimeSeconds / fadeTimeSeconds); + return newOpacity > 1f ? 1f : newOpacity; + } + + /// + /// 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. + /// + internal static float ComputeHiddenOpacity(float newOpacity) + { + float a1; + if (newOpacity < Phi) + a1 = newOpacity * (Phi - 1f) / Phi + 1f; // (0,1),(phi,phi)を通る直線式 + else + a1 = (1f - newOpacity) * Phi / (1f - Phi); // (1,0),(phi,phi)を通る直線式 + + var backOpacity = (1f - a1) * (1f - newOpacity); + if (backOpacity > BackOpacityThreshold) + a1 = 1f - BackOpacityThreshold / (1f - newOpacity); + + return a1; + } } } diff --git a/source_code/Cubism/Framework/CubismRenderer2D.cs b/source_code/Cubism/Framework/CubismRenderer2D.cs index 45d0d10..9d0470b 100644 --- a/source_code/Cubism/Framework/CubismRenderer2D.cs +++ b/source_code/Cubism/Framework/CubismRenderer2D.cs @@ -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,14 +482,17 @@ namespace Cthangover.Live2D.Cubism.Framework mi.ZIndex = renderOrders[di]; - var mc = multiplyColors[di]; - mat.SetShaderParameter("color_multiply", new Color(mc.X, mc.Y, mc.Z, mc.W)); + if (mat != null) + { + var mc = multiplyColors[di]; + mat.SetShaderParameter("color_multiply", new Color(mc.X, mc.Y, mc.Z, mc.W)); - var sc = screenColors[di]; - mat.SetShaderParameter("color_screen", new Color(sc.X, sc.Y, sc.Z, sc.W)); + var sc = screenColors[di]; + mat.SetShaderParameter("color_screen", new Color(sc.X, sc.Y, sc.Z, sc.W)); - if ((flags & CubismCoreBindings.OpacityDidChange) != 0) - mat.SetShaderParameter("color_base", new Color(1f, 1f, 1f, opacities[di])); + 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(); + } + + /// + /// 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. + /// + 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()?.Resolve(name); if (shader != null) { GameLogger.Log("LIVE2D", $"ResolveShader: '{name}' OK"); diff --git a/source_code/Cubism/Framework/CubismTargetPoint.cs b/source_code/Cubism/Framework/CubismTargetPoint.cs index a2c7976..7a0e4cd 100644 --- a/source_code/Cubism/Framework/CubismTargetPoint.cs +++ b/source_code/Cubism/Framework/CubismTargetPoint.cs @@ -6,21 +6,20 @@ namespace Cthangover.Live2D.Cubism.Framework /// Smooth target-point tracking for face/eye movement parameters. /// Drives the ParamAngleX/Y and ParamEyeBallX/Y model parameters in /// . - /// - /// 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. /// public class CubismTargetPoint { - private const float FaceParamMaxV = 40f; - private const float FrameToMaxSpeed = 4.5f; + /// Internal reference frame rate (SDK FrameRate). + private const int FrameRate = 30; + + /// Threshold below which no movement is applied (SDK Epsilon). + private const float Epsilon = 0.01f; private float _faceTargetX, _faceTargetY; private float _faceX, _faceY; @@ -45,54 +44,92 @@ namespace Cthangover.Live2D.Cubism.Framework } /// - /// 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 - /// 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. /// 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; } diff --git a/source_code/Cubism/Handlers/Live2DService.cs b/source_code/Cubism/Handlers/Live2DService.cs index f4dc3de..3c6d108 100644 --- a/source_code/Cubism/Handlers/Live2DService.cs +++ b/source_code/Cubism/Handlers/Live2DService.cs @@ -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; + + /// + /// 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. + /// + public bool EnableEyeBlink = true; } /// @@ -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); diff --git a/source_code/Properties/AssemblyInfo.cs b/source_code/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..efa1b26 --- /dev/null +++ b/source_code/Properties/AssemblyInfo.cs @@ -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")] diff --git a/src/AddLive2DModelAction.cs b/src/AddLive2DModelAction.cs index d1e00e8..f427e8a 100644 --- a/src/AddLive2DModelAction.cs +++ b/src/AddLive2DModelAction.cs @@ -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