upd: sync local changes

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

No files matched your search

+257 -46
View File
@@ -33,117 +33,258 @@
} }
} }
}, },
"Microsoft.CodeAnalysis.Common/4.11.0": { "Humanizer.Core/2.2.0": {
"runtime": { "runtime": {
"lib/net8.0/Microsoft.CodeAnalysis.dll": { "lib/netstandard1.0/Humanizer.dll": {
"assemblyVersion": "4.11.0.0", "assemblyVersion": "2.2.0.0",
"fileVersion": "4.1100.24.37604" "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": { "resources": {
"lib/net8.0/cs/Microsoft.CodeAnalysis.resources.dll": { "lib/netcoreapp3.1/cs/Microsoft.CodeAnalysis.resources.dll": {
"locale": "cs" "locale": "cs"
}, },
"lib/net8.0/de/Microsoft.CodeAnalysis.resources.dll": { "lib/netcoreapp3.1/de/Microsoft.CodeAnalysis.resources.dll": {
"locale": "de" "locale": "de"
}, },
"lib/net8.0/es/Microsoft.CodeAnalysis.resources.dll": { "lib/netcoreapp3.1/es/Microsoft.CodeAnalysis.resources.dll": {
"locale": "es" "locale": "es"
}, },
"lib/net8.0/fr/Microsoft.CodeAnalysis.resources.dll": { "lib/netcoreapp3.1/fr/Microsoft.CodeAnalysis.resources.dll": {
"locale": "fr" "locale": "fr"
}, },
"lib/net8.0/it/Microsoft.CodeAnalysis.resources.dll": { "lib/netcoreapp3.1/it/Microsoft.CodeAnalysis.resources.dll": {
"locale": "it" "locale": "it"
}, },
"lib/net8.0/ja/Microsoft.CodeAnalysis.resources.dll": { "lib/netcoreapp3.1/ja/Microsoft.CodeAnalysis.resources.dll": {
"locale": "ja" "locale": "ja"
}, },
"lib/net8.0/ko/Microsoft.CodeAnalysis.resources.dll": { "lib/netcoreapp3.1/ko/Microsoft.CodeAnalysis.resources.dll": {
"locale": "ko" "locale": "ko"
}, },
"lib/net8.0/pl/Microsoft.CodeAnalysis.resources.dll": { "lib/netcoreapp3.1/pl/Microsoft.CodeAnalysis.resources.dll": {
"locale": "pl" "locale": "pl"
}, },
"lib/net8.0/pt-BR/Microsoft.CodeAnalysis.resources.dll": { "lib/netcoreapp3.1/pt-BR/Microsoft.CodeAnalysis.resources.dll": {
"locale": "pt-BR" "locale": "pt-BR"
}, },
"lib/net8.0/ru/Microsoft.CodeAnalysis.resources.dll": { "lib/netcoreapp3.1/ru/Microsoft.CodeAnalysis.resources.dll": {
"locale": "ru" "locale": "ru"
}, },
"lib/net8.0/tr/Microsoft.CodeAnalysis.resources.dll": { "lib/netcoreapp3.1/tr/Microsoft.CodeAnalysis.resources.dll": {
"locale": "tr" "locale": "tr"
}, },
"lib/net8.0/zh-Hans/Microsoft.CodeAnalysis.resources.dll": { "lib/netcoreapp3.1/zh-Hans/Microsoft.CodeAnalysis.resources.dll": {
"locale": "zh-Hans" "locale": "zh-Hans"
}, },
"lib/net8.0/zh-Hant/Microsoft.CodeAnalysis.resources.dll": { "lib/netcoreapp3.1/zh-Hant/Microsoft.CodeAnalysis.resources.dll": {
"locale": "zh-Hant" "locale": "zh-Hant"
} }
} }
}, },
"Microsoft.CodeAnalysis.CSharp/4.11.0": { "Microsoft.CodeAnalysis.CSharp/3.11.0": {
"dependencies": { "dependencies": {
"Microsoft.CodeAnalysis.Common": "4.11.0" "Microsoft.CodeAnalysis.Common": "3.11.0"
}, },
"runtime": { "runtime": {
"lib/net8.0/Microsoft.CodeAnalysis.CSharp.dll": { "lib/netcoreapp3.1/Microsoft.CodeAnalysis.CSharp.dll": {
"assemblyVersion": "4.11.0.0", "assemblyVersion": "3.11.0.0",
"fileVersion": "4.1100.24.37604" "fileVersion": "3.1100.21.40306"
} }
}, },
"resources": { "resources": {
"lib/net8.0/cs/Microsoft.CodeAnalysis.CSharp.resources.dll": { "lib/netcoreapp3.1/cs/Microsoft.CodeAnalysis.CSharp.resources.dll": {
"locale": "cs" "locale": "cs"
}, },
"lib/net8.0/de/Microsoft.CodeAnalysis.CSharp.resources.dll": { "lib/netcoreapp3.1/de/Microsoft.CodeAnalysis.CSharp.resources.dll": {
"locale": "de" "locale": "de"
}, },
"lib/net8.0/es/Microsoft.CodeAnalysis.CSharp.resources.dll": { "lib/netcoreapp3.1/es/Microsoft.CodeAnalysis.CSharp.resources.dll": {
"locale": "es" "locale": "es"
}, },
"lib/net8.0/fr/Microsoft.CodeAnalysis.CSharp.resources.dll": { "lib/netcoreapp3.1/fr/Microsoft.CodeAnalysis.CSharp.resources.dll": {
"locale": "fr" "locale": "fr"
}, },
"lib/net8.0/it/Microsoft.CodeAnalysis.CSharp.resources.dll": { "lib/netcoreapp3.1/it/Microsoft.CodeAnalysis.CSharp.resources.dll": {
"locale": "it" "locale": "it"
}, },
"lib/net8.0/ja/Microsoft.CodeAnalysis.CSharp.resources.dll": { "lib/netcoreapp3.1/ja/Microsoft.CodeAnalysis.CSharp.resources.dll": {
"locale": "ja" "locale": "ja"
}, },
"lib/net8.0/ko/Microsoft.CodeAnalysis.CSharp.resources.dll": { "lib/netcoreapp3.1/ko/Microsoft.CodeAnalysis.CSharp.resources.dll": {
"locale": "ko" "locale": "ko"
}, },
"lib/net8.0/pl/Microsoft.CodeAnalysis.CSharp.resources.dll": { "lib/netcoreapp3.1/pl/Microsoft.CodeAnalysis.CSharp.resources.dll": {
"locale": "pl" "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" "locale": "pt-BR"
}, },
"lib/net8.0/ru/Microsoft.CodeAnalysis.CSharp.resources.dll": { "lib/netcoreapp3.1/ru/Microsoft.CodeAnalysis.CSharp.resources.dll": {
"locale": "ru" "locale": "ru"
}, },
"lib/net8.0/tr/Microsoft.CodeAnalysis.CSharp.resources.dll": { "lib/netcoreapp3.1/tr/Microsoft.CodeAnalysis.CSharp.resources.dll": {
"locale": "tr" "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" "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" "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": { "Cthangover/1.0.0": {
"dependencies": { "dependencies": {
"GodotSharp": "4.6.3", "GodotSharp": "4.6.3",
"GodotSharpEditor": "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": { "runtime": {
"Cthangover.dll": { "Cthangover.dll": {
"assemblyVersion": "0.0.0.0", "assemblyVersion": "1.0.0.0",
"fileVersion": "0.0.0.0" "fileVersion": "1.0.0.0"
} }
} }
} }
@@ -169,19 +310,89 @@
"path": "godotsharpeditor/4.6.3", "path": "godotsharpeditor/4.6.3",
"hashPath": "godotsharpeditor.4.6.3.nupkg.sha512" "hashPath": "godotsharpeditor.4.6.3.nupkg.sha512"
}, },
"Microsoft.CodeAnalysis.Common/4.11.0": { "Humanizer.Core/2.2.0": {
"type": "package", "type": "package",
"serviceable": true, "serviceable": true,
"sha512": "sha512-djf8ujmqYImFgB04UGtcsEhHrzVqzHowS+EEl/Yunc5LdrYrZhGBWUTXoCF0NzYXJxtfuD+UVQarWpvrNc94Qg==", "sha512": "sha512-rsYXB7+iUPP8AHgQ8JP2UZI2xK2KhjcdGr9E6zX3CsZaTLCaw8M35vaAJRo1rfxeaZEVMuXeaquLVCkZ7JcZ5Q==",
"path": "microsoft.codeanalysis.common/4.11.0", "path": "humanizer.core/2.2.0",
"hashPath": "microsoft.codeanalysis.common.4.11.0.nupkg.sha512" "hashPath": "humanizer.core.2.2.0.nupkg.sha512"
}, },
"Microsoft.CodeAnalysis.CSharp/4.11.0": { "Microsoft.Bcl.AsyncInterfaces/5.0.0": {
"type": "package", "type": "package",
"serviceable": true, "serviceable": true,
"sha512": "sha512-6XYi2EusI8JT4y2l/F3VVVS+ISoIX9nqHsZRaG6W5aFeJ5BEuBosHfT/ABb73FN0RZ1Z3cj2j7cL28SToJPXOw==", "sha512": "sha512-W8DPQjkMScOMTtJbPwmPyj9c3zYSFGawDW3jwlBOOsnY+EzZFLgNQ/UMkK35JmkNOVPdCyPr2Tw7Vv9N+KA3ZQ==",
"path": "microsoft.codeanalysis.csharp/4.11.0", "path": "microsoft.bcl.asyncinterfaces/5.0.0",
"hashPath": "microsoft.codeanalysis.csharp.4.11.0.nupkg.sha512" "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": { "Cthangover/1.0.0": {
"type": "project", "type": "project",
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+2
View File
@@ -0,0 +1,2 @@
mod_live2d_name = Live2D Cubism
mod_live2d_desc = Live2D Cubism C# runtime: model loading, rendering, physics, animation, effects.
+2
View File
@@ -0,0 +1,2 @@
mod_live2d_name = Live2D Cubism
mod_live2d_desc = C#-рантайм Live2D Cubism: загрузка и отрисовка моделей, физика, анимации и эффекты.
+2 -3
View File
@@ -5,7 +5,6 @@
"author": "ct", "author": "ct",
"description": "Live2D Cubism C# runtime: model loading, rendering, physics, animation, effects", "description": "Live2D Cubism C# runtime: model loading, rendering, physics, animation, effects",
"category": "library, visual", "category": "library, visual",
"depends": ["visual_novel_core>=1"], "depends": ["visual_novel_core>=1", "interface>=1"],
"depends_native": ["native/*.dll"], "depends_native": ["native/*.dll", "native/*.so"]
"sources": ["src/*.cs"]
} }
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

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