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