init
This commit is contained in:
+37
@@ -0,0 +1,37 @@
|
||||
# Godot 4.x
|
||||
.godot/
|
||||
.idea/
|
||||
sdk/
|
||||
bin/
|
||||
obj/
|
||||
|
||||
#ai
|
||||
.omo/
|
||||
.opencode/
|
||||
|
||||
gen/
|
||||
obj/
|
||||
bin/
|
||||
|
||||
*.tres~
|
||||
*.tscn~
|
||||
|
||||
# GDScript LSP
|
||||
.godot/
|
||||
|
||||
# Export
|
||||
*.exe
|
||||
*.dmg
|
||||
*.apk
|
||||
*.aab
|
||||
*.pck
|
||||
*.zip
|
||||
*.log
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editor
|
||||
*.import
|
||||
*.godot.uid
|
||||
@@ -0,0 +1,192 @@
|
||||
{
|
||||
"runtimeTarget": {
|
||||
"name": ".NETCoreApp,Version=v8.0",
|
||||
"signature": ""
|
||||
},
|
||||
"compilationOptions": {},
|
||||
"targets": {
|
||||
".NETCoreApp,Version=v8.0": {
|
||||
"Live2D/1.0.0": {
|
||||
"dependencies": {
|
||||
"Cthangover": "1.0.0"
|
||||
},
|
||||
"runtime": {
|
||||
"Live2D.dll": {}
|
||||
}
|
||||
},
|
||||
"GodotSharp/4.6.3": {
|
||||
"runtime": {
|
||||
"lib/net8.0/GodotSharp.dll": {
|
||||
"assemblyVersion": "4.6.3.0",
|
||||
"fileVersion": "4.6.3.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"GodotSharpEditor/4.6.3": {
|
||||
"dependencies": {
|
||||
"GodotSharp": "4.6.3"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net8.0/GodotSharpEditor.dll": {
|
||||
"assemblyVersion": "4.6.3.0",
|
||||
"fileVersion": "4.6.3.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Microsoft.CodeAnalysis.Common/4.11.0": {
|
||||
"runtime": {
|
||||
"lib/net8.0/Microsoft.CodeAnalysis.dll": {
|
||||
"assemblyVersion": "4.11.0.0",
|
||||
"fileVersion": "4.1100.24.37604"
|
||||
}
|
||||
},
|
||||
"resources": {
|
||||
"lib/net8.0/cs/Microsoft.CodeAnalysis.resources.dll": {
|
||||
"locale": "cs"
|
||||
},
|
||||
"lib/net8.0/de/Microsoft.CodeAnalysis.resources.dll": {
|
||||
"locale": "de"
|
||||
},
|
||||
"lib/net8.0/es/Microsoft.CodeAnalysis.resources.dll": {
|
||||
"locale": "es"
|
||||
},
|
||||
"lib/net8.0/fr/Microsoft.CodeAnalysis.resources.dll": {
|
||||
"locale": "fr"
|
||||
},
|
||||
"lib/net8.0/it/Microsoft.CodeAnalysis.resources.dll": {
|
||||
"locale": "it"
|
||||
},
|
||||
"lib/net8.0/ja/Microsoft.CodeAnalysis.resources.dll": {
|
||||
"locale": "ja"
|
||||
},
|
||||
"lib/net8.0/ko/Microsoft.CodeAnalysis.resources.dll": {
|
||||
"locale": "ko"
|
||||
},
|
||||
"lib/net8.0/pl/Microsoft.CodeAnalysis.resources.dll": {
|
||||
"locale": "pl"
|
||||
},
|
||||
"lib/net8.0/pt-BR/Microsoft.CodeAnalysis.resources.dll": {
|
||||
"locale": "pt-BR"
|
||||
},
|
||||
"lib/net8.0/ru/Microsoft.CodeAnalysis.resources.dll": {
|
||||
"locale": "ru"
|
||||
},
|
||||
"lib/net8.0/tr/Microsoft.CodeAnalysis.resources.dll": {
|
||||
"locale": "tr"
|
||||
},
|
||||
"lib/net8.0/zh-Hans/Microsoft.CodeAnalysis.resources.dll": {
|
||||
"locale": "zh-Hans"
|
||||
},
|
||||
"lib/net8.0/zh-Hant/Microsoft.CodeAnalysis.resources.dll": {
|
||||
"locale": "zh-Hant"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Microsoft.CodeAnalysis.CSharp/4.11.0": {
|
||||
"dependencies": {
|
||||
"Microsoft.CodeAnalysis.Common": "4.11.0"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net8.0/Microsoft.CodeAnalysis.CSharp.dll": {
|
||||
"assemblyVersion": "4.11.0.0",
|
||||
"fileVersion": "4.1100.24.37604"
|
||||
}
|
||||
},
|
||||
"resources": {
|
||||
"lib/net8.0/cs/Microsoft.CodeAnalysis.CSharp.resources.dll": {
|
||||
"locale": "cs"
|
||||
},
|
||||
"lib/net8.0/de/Microsoft.CodeAnalysis.CSharp.resources.dll": {
|
||||
"locale": "de"
|
||||
},
|
||||
"lib/net8.0/es/Microsoft.CodeAnalysis.CSharp.resources.dll": {
|
||||
"locale": "es"
|
||||
},
|
||||
"lib/net8.0/fr/Microsoft.CodeAnalysis.CSharp.resources.dll": {
|
||||
"locale": "fr"
|
||||
},
|
||||
"lib/net8.0/it/Microsoft.CodeAnalysis.CSharp.resources.dll": {
|
||||
"locale": "it"
|
||||
},
|
||||
"lib/net8.0/ja/Microsoft.CodeAnalysis.CSharp.resources.dll": {
|
||||
"locale": "ja"
|
||||
},
|
||||
"lib/net8.0/ko/Microsoft.CodeAnalysis.CSharp.resources.dll": {
|
||||
"locale": "ko"
|
||||
},
|
||||
"lib/net8.0/pl/Microsoft.CodeAnalysis.CSharp.resources.dll": {
|
||||
"locale": "pl"
|
||||
},
|
||||
"lib/net8.0/pt-BR/Microsoft.CodeAnalysis.CSharp.resources.dll": {
|
||||
"locale": "pt-BR"
|
||||
},
|
||||
"lib/net8.0/ru/Microsoft.CodeAnalysis.CSharp.resources.dll": {
|
||||
"locale": "ru"
|
||||
},
|
||||
"lib/net8.0/tr/Microsoft.CodeAnalysis.CSharp.resources.dll": {
|
||||
"locale": "tr"
|
||||
},
|
||||
"lib/net8.0/zh-Hans/Microsoft.CodeAnalysis.CSharp.resources.dll": {
|
||||
"locale": "zh-Hans"
|
||||
},
|
||||
"lib/net8.0/zh-Hant/Microsoft.CodeAnalysis.CSharp.resources.dll": {
|
||||
"locale": "zh-Hant"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Cthangover/1.0.0": {
|
||||
"dependencies": {
|
||||
"GodotSharp": "4.6.3",
|
||||
"GodotSharpEditor": "4.6.3",
|
||||
"Microsoft.CodeAnalysis.CSharp": "4.11.0"
|
||||
},
|
||||
"runtime": {
|
||||
"Cthangover.dll": {
|
||||
"assemblyVersion": "0.0.0.0",
|
||||
"fileVersion": "0.0.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"libraries": {
|
||||
"Live2D/1.0.0": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"GodotSharp/4.6.3": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-2gLsonAxGdDGm9/yYDy4A1U+db8JFGvlzt6ZuzX14cqrkCa7noikZDHcqtujcVAjXLOyxm+TuMdsmihYLZq1RA==",
|
||||
"path": "godotsharp/4.6.3",
|
||||
"hashPath": "godotsharp.4.6.3.nupkg.sha512"
|
||||
},
|
||||
"GodotSharpEditor/4.6.3": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-ytyJqJVpB5/7ZXKO9ahKzK24wlengf1FdNFHoIopa72irgon/HpizIcuzh/9lfGytfZywKi97o/LIQF9j7eHNA==",
|
||||
"path": "godotsharpeditor/4.6.3",
|
||||
"hashPath": "godotsharpeditor.4.6.3.nupkg.sha512"
|
||||
},
|
||||
"Microsoft.CodeAnalysis.Common/4.11.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-djf8ujmqYImFgB04UGtcsEhHrzVqzHowS+EEl/Yunc5LdrYrZhGBWUTXoCF0NzYXJxtfuD+UVQarWpvrNc94Qg==",
|
||||
"path": "microsoft.codeanalysis.common/4.11.0",
|
||||
"hashPath": "microsoft.codeanalysis.common.4.11.0.nupkg.sha512"
|
||||
},
|
||||
"Microsoft.CodeAnalysis.CSharp/4.11.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-6XYi2EusI8JT4y2l/F3VVVS+ISoIX9nqHsZRaG6W5aFeJ5BEuBosHfT/ABb73FN0RZ1Z3cj2j7cL28SToJPXOw==",
|
||||
"path": "microsoft.codeanalysis.csharp/4.11.0",
|
||||
"hashPath": "microsoft.codeanalysis.csharp.4.11.0.nupkg.sha512"
|
||||
},
|
||||
"Cthangover/1.0.0": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "live2d",
|
||||
"name": "Live2D Cubism",
|
||||
"version": "1.0.0",
|
||||
"author": "ct",
|
||||
"description": "Live2D Cubism C# runtime: model loading, rendering, physics, animation, effects",
|
||||
"depends": ["core>=1"],
|
||||
"depends_native": ["native/*.dll"],
|
||||
"sources": ["src/*.cs"]
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,206 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Cthangover.Live2D.Cubism.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// P/Invoke bindings for the Live2D CubismCore native library (Live2DCubismCore.dll).
|
||||
/// Provides direct access to model loading, updating, and data querying at the C level.
|
||||
///
|
||||
/// All methods are unsafe and return raw pointers into CubismCore's internal arrays.
|
||||
/// <see cref="CubismNativeModel"/> wraps this layer into a managed API with proper
|
||||
/// memory alignment and lifetime management.
|
||||
/// </summary>
|
||||
public static class CubismCoreBindings
|
||||
{
|
||||
/// <summary>Name of the native DLL loaded at runtime.</summary>
|
||||
public const string DllName = "Live2DCubismCore";
|
||||
|
||||
/// <summary>Required byte alignment for .moc3 data buffers before revival.</summary>
|
||||
public const int AlignofMoc = 64;
|
||||
|
||||
/// <summary>Required byte alignment for model instance memory.</summary>
|
||||
public const int AlignofModel = 16;
|
||||
|
||||
/// <summary>Flag: drawable uses additive blending.</summary>
|
||||
public const byte BlendAdditive = 1 << 0;
|
||||
|
||||
/// <summary>Flag: drawable uses multiplicative blending.</summary>
|
||||
public const byte BlendMultiplicative = 1 << 1;
|
||||
|
||||
/// <summary>Flag: drawable is double-sided (no back-face culling).</summary>
|
||||
public const byte IsDoubleSided = 1 << 2;
|
||||
|
||||
/// <summary>Flag: drawable's mask is inverted.</summary>
|
||||
public const byte IsInvertedMask = 1 << 3;
|
||||
|
||||
/// <summary>Dynamic flag: drawable is currently visible.</summary>
|
||||
public const byte IsVisible = 1 << 0;
|
||||
|
||||
/// <summary>Dynamic flag: drawable visibility changed this frame.</summary>
|
||||
public const byte VisibilityDidChange = 1 << 1;
|
||||
|
||||
/// <summary>Dynamic flag: drawable opacity changed this frame.</summary>
|
||||
public const byte OpacityDidChange = 1 << 2;
|
||||
|
||||
/// <summary>Dynamic flag: drawable draw order changed this frame.</summary>
|
||||
public const byte DrawOrderDidChange = 1 << 3;
|
||||
|
||||
/// <summary>Dynamic flag: drawable render order changed this frame.</summary>
|
||||
public const byte RenderOrderDidChange = 1 << 4;
|
||||
|
||||
/// <summary>Dynamic flag: vertex positions changed this frame.</summary>
|
||||
public const byte VertexPositionsDidChange = 1 << 5;
|
||||
|
||||
/// <summary>Dynamic flag: blend color changed this frame.</summary>
|
||||
public const byte BlendColorDidChange = 1 << 6;
|
||||
|
||||
/// <summary>2D vector used in CubismCore struct layouts.</summary>
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct csmVector2 { public float X; public float Y; }
|
||||
|
||||
/// <summary>4D vector used in CubismCore color struct layouts.</summary>
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct csmVector4 { public float X; public float Y; public float Z; public float W; }
|
||||
|
||||
/// <summary>Returns the CubismCore SDK version number.</summary>
|
||||
[DllImport(DllName, CallingConvention = CallingConvention.StdCall)]
|
||||
public static extern uint csmGetVersion();
|
||||
|
||||
/// <summary>Revives a .moc3 binary from an aligned memory buffer. Returns a moc handle or IntPtr.Zero.</summary>
|
||||
[DllImport(DllName, CallingConvention = CallingConvention.StdCall)]
|
||||
public static extern unsafe IntPtr csmReviveMocInPlace(void* address, uint size);
|
||||
|
||||
/// <summary>Queries the memory size needed to initialize a model from the given moc.</summary>
|
||||
[DllImport(DllName, CallingConvention = CallingConvention.StdCall)]
|
||||
public static extern unsafe uint csmGetSizeofModel(IntPtr moc);
|
||||
|
||||
/// <summary>Creates a model instance inside the provided aligned buffer. Returns model handle or IntPtr.Zero.</summary>
|
||||
[DllImport(DllName, CallingConvention = CallingConvention.StdCall)]
|
||||
public static extern unsafe IntPtr csmInitializeModelInPlace(IntPtr moc, void* address, uint size);
|
||||
|
||||
/// <summary>Advance the model by one frame — updates physics, parameter-to-vertex mapping, etc.</summary>
|
||||
[DllImport(DllName, CallingConvention = CallingConvention.StdCall)]
|
||||
public static extern unsafe void csmUpdateModel(IntPtr model);
|
||||
|
||||
/// <summary>Reads the model's canvas dimensions and pixels-per-unit scale.</summary>
|
||||
[DllImport(DllName, CallingConvention = CallingConvention.StdCall)]
|
||||
public static extern unsafe void csmReadCanvasInfo(IntPtr model, csmVector2* outSizeInPixels, csmVector2* outOriginInPixels, float* outPixelsPerUnit);
|
||||
|
||||
/// <summary>Returns the number of parameters in the model.</summary>
|
||||
[DllImport(DllName, CallingConvention = CallingConvention.StdCall)]
|
||||
public static extern unsafe int csmGetParameterCount(IntPtr model);
|
||||
|
||||
/// <summary>Returns a pointer array of null-terminated parameter ID strings.</summary>
|
||||
[DllImport(DllName, CallingConvention = CallingConvention.StdCall)]
|
||||
public static extern unsafe IntPtr csmGetParameterIds(IntPtr model);
|
||||
|
||||
/// <summary>Returns a float array of current parameter values.</summary>
|
||||
[DllImport(DllName, CallingConvention = CallingConvention.StdCall)]
|
||||
public static extern unsafe float* csmGetParameterValues(IntPtr model);
|
||||
|
||||
/// <summary>Returns a float array of parameter minimum bounds.</summary>
|
||||
[DllImport(DllName, CallingConvention = CallingConvention.StdCall)]
|
||||
public static extern unsafe float* csmGetParameterMinimumValues(IntPtr model);
|
||||
|
||||
/// <summary>Returns a float array of parameter maximum bounds.</summary>
|
||||
[DllImport(DllName, CallingConvention = CallingConvention.StdCall)]
|
||||
public static extern unsafe float* csmGetParameterMaximumValues(IntPtr model);
|
||||
|
||||
/// <summary>Returns a float array of parameter default values.</summary>
|
||||
[DllImport(DllName, CallingConvention = CallingConvention.StdCall)]
|
||||
public static extern unsafe float* csmGetParameterDefaultValues(IntPtr model);
|
||||
|
||||
/// <summary>Returns the number of drawables (renderable mesh parts) in the model.</summary>
|
||||
[DllImport(DllName, CallingConvention = CallingConvention.StdCall)]
|
||||
public static extern unsafe int csmGetDrawableCount(IntPtr model);
|
||||
|
||||
/// <summary>Returns byte flags array for constant (immutable) drawable properties.</summary>
|
||||
[DllImport(DllName, CallingConvention = CallingConvention.StdCall)]
|
||||
public static extern unsafe byte* csmGetDrawableConstantFlags(IntPtr model);
|
||||
|
||||
/// <summary>Returns byte flags array for per-frame dynamic drawable state.</summary>
|
||||
[DllImport(DllName, CallingConvention = CallingConvention.StdCall)]
|
||||
public static extern unsafe byte* csmGetDrawableDynamicFlags(IntPtr model);
|
||||
|
||||
/// <summary>Returns an int array mapping each drawable to a texture index.</summary>
|
||||
[DllImport(DllName, CallingConvention = CallingConvention.StdCall)]
|
||||
public static extern unsafe int* csmGetDrawableTextureIndices(IntPtr model);
|
||||
|
||||
/// <summary>Returns an int array of vertex counts per drawable.</summary>
|
||||
[DllImport(DllName, CallingConvention = CallingConvention.StdCall)]
|
||||
public static extern unsafe int* csmGetDrawableVertexCounts(IntPtr model);
|
||||
|
||||
/// <summary>Returns a pointer array of csmVector2 arrays (vertex positions per drawable).</summary>
|
||||
[DllImport(DllName, CallingConvention = CallingConvention.StdCall)]
|
||||
public static extern unsafe csmVector2** csmGetDrawableVertexPositions(IntPtr model);
|
||||
|
||||
/// <summary>Returns a pointer array of csmVector2 arrays (UVs per drawable).</summary>
|
||||
[DllImport(DllName, CallingConvention = CallingConvention.StdCall)]
|
||||
public static extern unsafe csmVector2** csmGetDrawableVertexUvs(IntPtr model);
|
||||
|
||||
/// <summary>Returns an int array of index counts per drawable.</summary>
|
||||
[DllImport(DllName, CallingConvention = CallingConvention.StdCall)]
|
||||
public static extern unsafe int* csmGetDrawableIndexCounts(IntPtr model);
|
||||
|
||||
/// <summary>Returns a pointer array of ushort index arrays per drawable.</summary>
|
||||
[DllImport(DllName, CallingConvention = CallingConvention.StdCall)]
|
||||
public static extern unsafe ushort** csmGetDrawableIndices(IntPtr model);
|
||||
|
||||
/// <summary>Returns a float array of per-drawable opacities.</summary>
|
||||
[DllImport(DllName, CallingConvention = CallingConvention.StdCall)]
|
||||
public static extern unsafe float* csmGetDrawableOpacities(IntPtr model);
|
||||
|
||||
/// <summary>Returns draw order indices — maps render order slot to drawable index.</summary>
|
||||
[DllImport(DllName, CallingConvention = CallingConvention.StdCall)]
|
||||
public static extern unsafe int* csmGetDrawableDrawOrders(IntPtr model);
|
||||
|
||||
/// <summary>Returns render order indices (newer Cubism format).</summary>
|
||||
[DllImport(DllName, CallingConvention = CallingConvention.StdCall)]
|
||||
public static extern unsafe int* csmGetRenderOrders(IntPtr model);
|
||||
|
||||
/// <summary>Resets dynamic flags for all drawables, preparing for the next frame.</summary>
|
||||
[DllImport(DllName, CallingConvention = CallingConvention.StdCall)]
|
||||
public static extern unsafe void csmResetDrawableDynamicFlags(IntPtr model);
|
||||
|
||||
/// <summary>Returns an int array of mask counts per drawable.</summary>
|
||||
[DllImport(DllName, CallingConvention = CallingConvention.StdCall)]
|
||||
public static extern unsafe int* csmGetDrawableMaskCounts(IntPtr model);
|
||||
|
||||
/// <summary>Returns a pointer array of int arrays (mask drawable indices per drawable).</summary>
|
||||
[DllImport(DllName, CallingConvention = CallingConvention.StdCall)]
|
||||
public static extern unsafe int** csmGetDrawableMasks(IntPtr model);
|
||||
|
||||
/// <summary>Returns a csmVector4 array of per-drawable multiply colors.</summary>
|
||||
[DllImport(DllName, CallingConvention = CallingConvention.StdCall)]
|
||||
public static extern unsafe csmVector4* csmGetDrawableMultiplyColors(IntPtr model);
|
||||
|
||||
/// <summary>Returns a csmVector4 array of per-drawable screen colors.</summary>
|
||||
[DllImport(DllName, CallingConvention = CallingConvention.StdCall)]
|
||||
public static extern unsafe csmVector4* csmGetDrawableScreenColors(IntPtr model);
|
||||
|
||||
/// <summary>Returns the number of parts in the model.</summary>
|
||||
[DllImport(DllName, CallingConvention = CallingConvention.StdCall)]
|
||||
public static extern unsafe int csmGetPartCount(IntPtr model);
|
||||
|
||||
/// <summary>Returns a pointer array of part ID strings.</summary>
|
||||
[DllImport(DllName, CallingConvention = CallingConvention.StdCall)]
|
||||
public static extern unsafe IntPtr csmGetPartIds(IntPtr model);
|
||||
|
||||
/// <summary>Returns a float array of per-part opacities.</summary>
|
||||
[DllImport(DllName, CallingConvention = CallingConvention.StdCall)]
|
||||
public static extern unsafe float* csmGetPartOpacities(IntPtr model);
|
||||
|
||||
/// <summary>Checks if the given .moc3 data passes consistency validation.</summary>
|
||||
[DllImport(DllName, CallingConvention = CallingConvention.StdCall)]
|
||||
public static extern unsafe int csmHasMocConsistency(void* address, uint size);
|
||||
|
||||
/// <summary>Returns the latest .moc3 format version supported by the DLL.</summary>
|
||||
[DllImport(DllName, CallingConvention = CallingConvention.StdCall)]
|
||||
public static extern unsafe uint csmGetLatestMocVersion();
|
||||
|
||||
/// <summary>Returns the .moc3 format version of the given binary data.</summary>
|
||||
[DllImport(DllName, CallingConvention = CallingConvention.StdCall)]
|
||||
public static extern unsafe uint csmGetMocVersion(void* address, uint size);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
using System;
|
||||
|
||||
namespace Cthangover.Live2D.Cubism.Framework
|
||||
{
|
||||
/// <summary>
|
||||
/// Abstract base for all Live2D Cubism motions (animations and expressions).
|
||||
/// Provides fade-in/fade-out timing, looping, weight blending, and event
|
||||
/// callbacks shared across <see cref="CubismMotion"/> and
|
||||
/// <see cref="CubismExpressionMotion"/>.
|
||||
///
|
||||
/// Motions are managed by <see cref="CubismMotionQueueManager"/>, which
|
||||
/// calls <see cref="SetupMotionQueueEntry"/> on start, then
|
||||
/// <see cref="UpdateFadeWeight"/> and <see cref="DoUpdateParameters"/>
|
||||
/// every frame.
|
||||
/// </summary>
|
||||
public abstract class ACubismMotion
|
||||
{
|
||||
/// <summary>
|
||||
/// Callback invoked when the motion finishes (non-looping only).
|
||||
/// The finished motion is passed as the argument.
|
||||
/// </summary>
|
||||
public delegate void FinishedMotionCallback(ACubismMotion motion);
|
||||
|
||||
/// <summary>
|
||||
/// Callback invoked when the motion begins playback.
|
||||
/// Called from <see cref="SetupMotionQueueEntry"/> right after
|
||||
/// the entry's times are configured.
|
||||
/// </summary>
|
||||
public delegate void BeganMotionCallback(ACubismMotion motion);
|
||||
|
||||
protected float _fadeInSeconds = -1f;
|
||||
protected float _fadeOutSeconds = -1f;
|
||||
protected float _weight = 1f;
|
||||
protected float _offsetSeconds;
|
||||
protected bool _isLoop;
|
||||
protected bool _isLoopFadeIn = true;
|
||||
protected bool _previousLoopState;
|
||||
|
||||
/// <summary>
|
||||
/// Duration of the fade-in transition in seconds.
|
||||
/// A negative value means the default (1 second) will be used
|
||||
/// by <see cref="SetupMotionQueueEntry"/> and <see cref="UpdateFadeWeight"/>.
|
||||
/// </summary>
|
||||
public float FadeInSeconds { get => _fadeInSeconds; set => _fadeInSeconds = value; }
|
||||
|
||||
/// <summary>
|
||||
/// Duration of the fade-out transition in seconds.
|
||||
/// A negative value means the default (1 second) will be used.
|
||||
/// </summary>
|
||||
public float FadeOutSeconds { get => _fadeOutSeconds; set => _fadeOutSeconds = value; }
|
||||
|
||||
/// <summary>
|
||||
/// Overall motion weight (0..1). Multiplied with the fade-in/fade-out
|
||||
/// factors in <see cref="UpdateFadeWeight"/> to produce the final blend weight.
|
||||
/// </summary>
|
||||
public float Weight { get => _weight; set => _weight = value; }
|
||||
|
||||
/// <summary>
|
||||
/// Time offset in seconds applied to the motion's start time.
|
||||
/// Allows starting playback from an arbitrary point.
|
||||
/// </summary>
|
||||
public float OffsetSeconds { get => _offsetSeconds; set => _offsetSeconds = value; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the motion loops. When enabled, <see cref="CubismMotion"/>
|
||||
/// wraps timeOffset back to the beginning after the duration elapses.
|
||||
/// </summary>
|
||||
public bool IsLoop { get => _isLoop; set => _isLoop = value; }
|
||||
|
||||
/// <summary>
|
||||
/// When true and looping, the motion performs a fade-in at each loop
|
||||
/// restart. When false, the fade-in is skipped on subsequent loops.
|
||||
/// </summary>
|
||||
public bool IsLoopFadeIn { get => _isLoopFadeIn; set => _isLoopFadeIn = value; }
|
||||
|
||||
/// <summary>
|
||||
/// Invoked when the motion finishes (non-looping only).
|
||||
/// Automatically called by <see cref="CubismMotion.DoUpdateParameters"/>
|
||||
/// when timeOffset exceeds duration.
|
||||
/// </summary>
|
||||
public FinishedMotionCallback OnFinishedMotion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Invoked when the motion begins. Fires at the start of
|
||||
/// <see cref="SetupMotionQueueEntry"/> after the entry is initialized.
|
||||
/// </summary>
|
||||
public BeganMotionCallback OnBeganMotion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns the total motion duration in seconds.
|
||||
/// Override in <see cref="CubismMotion"/> to return the motion3.json
|
||||
/// duration, or -1 for looping motions.
|
||||
/// </summary>
|
||||
public virtual float GetDuration() => -1f;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the loop cycle duration in seconds.
|
||||
/// Override in <see cref="CubismMotion"/> to return the full duration
|
||||
/// even when looping is active.
|
||||
/// </summary>
|
||||
public virtual float GetLoopDuration() => -1f;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a queue entry with start/end times based on current
|
||||
/// <see cref="FadeInSeconds"/>, <see cref="OffsetSeconds"/>,
|
||||
/// and <see cref="GetDuration"/>. Fires <see cref="OnBeganMotion"/>.
|
||||
/// </summary>
|
||||
public void SetupMotionQueueEntry(CubismMotionQueueEntry entry, float userTimeSeconds)
|
||||
{
|
||||
var fadeIn = _fadeInSeconds >= 0f ? _fadeInSeconds : 1f;
|
||||
entry.StartTime = userTimeSeconds - _offsetSeconds;
|
||||
entry.FadeInStartTime = userTimeSeconds;
|
||||
var duration = GetDuration();
|
||||
UpdateEndTime(entry, duration);
|
||||
OnBeganMotion?.Invoke(this);
|
||||
}
|
||||
|
||||
/// <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;
|
||||
UpdateEndTime(entry, duration);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies motion parameter values to the model for the current frame.
|
||||
/// Called every frame by <see cref="CubismMotionQueueManager.DoUpdateMotion"/>.
|
||||
/// </summary>
|
||||
/// <param name="model">The native Cubism model to modify.</param>
|
||||
/// <param name="userTimeSeconds">Elapsed user time in seconds.</param>
|
||||
/// <param name="weight">Blend weight already multiplied by fade factors.</param>
|
||||
/// <param name="entry">The queue entry containing start/end timing.</param>
|
||||
public abstract void DoUpdateParameters(
|
||||
CubismNativeModel model,
|
||||
float userTimeSeconds,
|
||||
float weight,
|
||||
CubismMotionQueueEntry entry);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Cthangover.Live2D.Cubism.Framework
|
||||
{
|
||||
/// <summary>
|
||||
/// Defines a sinusoidal breathing parameter. Each breath parameter
|
||||
/// oscillates a model parameter value around an offset with a given
|
||||
/// peak amplitude, cycle period, and weight.
|
||||
/// </summary>
|
||||
public struct BreathParameterData
|
||||
{
|
||||
/// <summary>Model parameter ID to apply the breath to.</summary>
|
||||
public string ParameterId;
|
||||
|
||||
/// <summary>Base offset added to the sinusoidal value.</summary>
|
||||
public float Offset;
|
||||
|
||||
/// <summary>Amplitude of the sine wave.</summary>
|
||||
public float Peak;
|
||||
|
||||
/// <summary>Full cycle duration in seconds.</summary>
|
||||
public float Cycle;
|
||||
|
||||
/// <summary>Multiplier applied to the final breath value.</summary>
|
||||
public float Weight;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies sinusoidal breathing animation to model parameters.
|
||||
/// Operates additively on top of existing parameter values —
|
||||
/// the breath oscillation is multiplied by <see cref="BreathParameterData.Weight"/>
|
||||
/// and added to the current parameter value every frame.
|
||||
///
|
||||
/// Configured with a list of <see cref="BreathParameterData"/> entries,
|
||||
/// each targeting a specific parameter ID. Called from
|
||||
/// <see cref="CubismModelNode.OnUpdate"/> alongside other effect layers
|
||||
/// (eye blink, pose, physics).
|
||||
/// </summary>
|
||||
public class CubismBreath
|
||||
{
|
||||
private readonly List<BreathParameterData> _parameters = new();
|
||||
private float _currentTime;
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the current breath parameter set.
|
||||
/// Previously configured parameters are cleared.
|
||||
/// </summary>
|
||||
public void SetParameters(List<BreathParameterData> parameters)
|
||||
{
|
||||
_parameters.Clear();
|
||||
_parameters.AddRange(parameters);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the breath oscillation to matching model parameters.
|
||||
/// Accumulates internal time and evaluates a sine wave per parameter:
|
||||
/// <c>offset + peak * sin(2 * pi * time / cycle)</c>, then adds
|
||||
/// the weighted result to the native parameter value.
|
||||
/// </summary>
|
||||
public unsafe void UpdateParameters(CubismNativeModel model, float deltaTimeSeconds)
|
||||
{
|
||||
_currentTime += deltaTimeSeconds;
|
||||
|
||||
var idsPtr = model.GetParameterIds();
|
||||
var values = model.GetParameterValues();
|
||||
|
||||
for (int i = 0; i < model.ParameterCount; i++)
|
||||
{
|
||||
var id = CubismNativeModel.ReadStringFromPtrArray(idsPtr, i);
|
||||
foreach (var bp in _parameters)
|
||||
{
|
||||
if (bp.ParameterId != id) continue;
|
||||
var value = bp.Offset + bp.Peak * MathF.Sin(2f * MathF.PI * _currentTime / bp.Cycle);
|
||||
values[i] += bp.Weight * value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Cthangover.Live2D.Cubism.Framework
|
||||
{
|
||||
/// <summary>
|
||||
/// Blend mode for expression parameters.
|
||||
/// Determines how the expression value is combined with the existing
|
||||
/// parameter value during animation.
|
||||
/// </summary>
|
||||
public enum ExpressionBlendType { Additive = 0, Multiply = 1, Overwrite = 2 }
|
||||
|
||||
/// <summary>
|
||||
/// A single parameter entry in an expression motion.
|
||||
/// Targets a model parameter by ID with a specific blend type and value.
|
||||
/// </summary>
|
||||
public struct ExpressionParameter
|
||||
{
|
||||
/// <summary>Target model parameter ID.</summary>
|
||||
public string ParameterId;
|
||||
|
||||
/// <summary>How to combine this value with existing parameter data.</summary>
|
||||
public ExpressionBlendType BlendType;
|
||||
|
||||
/// <summary>The target value (interpretation depends on <see cref="BlendType"/>).</summary>
|
||||
public float Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Expression motion, parsed from a Cubism .exp3.json file.
|
||||
/// Inherits fade and weight management from <see cref="ACubismMotion"/>.
|
||||
///
|
||||
/// Expressions are single-frame poses that transition between values using
|
||||
/// one of three blend modes per parameter:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="ExpressionBlendType.Additive"/> — adds <c>value * weight</c></item>
|
||||
/// <item><see cref="ExpressionBlendType.Multiply"/> — multiplies by <c>1 + value * weight</c></item>
|
||||
/// <item><see cref="ExpressionBlendType.Overwrite"/> — lerps from current to target using weight</item>
|
||||
/// </list>
|
||||
///
|
||||
/// Loaded and started by <see cref="CubismModelNode.StartExpression"/>.
|
||||
/// </summary>
|
||||
public class CubismExpressionMotion : ACubismMotion
|
||||
{
|
||||
private readonly List<ExpressionParameter> _parameters = new();
|
||||
|
||||
/// <summary>
|
||||
/// Parses an .exp3.json byte buffer into an expression motion.
|
||||
/// Reads fade-in/out times and the list of target parameters with
|
||||
/// their blend types and values.
|
||||
/// </summary>
|
||||
public CubismExpressionMotion(byte[] jsonBytes)
|
||||
{
|
||||
var json = JsonDocument.Parse(jsonBytes);
|
||||
var root = json.RootElement;
|
||||
|
||||
if (root.TryGetProperty("FadeInTime", out var fi))
|
||||
_fadeInSeconds = fi.GetSingle();
|
||||
if (root.TryGetProperty("FadeOutTime", out var fo))
|
||||
_fadeOutSeconds = fo.GetSingle();
|
||||
|
||||
if (root.TryGetProperty("Parameters", out var paramsArr))
|
||||
{
|
||||
foreach (var p in paramsArr.EnumerateArray())
|
||||
{
|
||||
var blend = p.TryGetProperty("Blend", out var b)
|
||||
? ParseBlendType(b.GetString())
|
||||
: ExpressionBlendType.Additive;
|
||||
|
||||
_parameters.Add(new ExpressionParameter
|
||||
{
|
||||
ParameterId = p.GetProperty("Id").GetString(),
|
||||
Value = p.GetProperty("Value").GetSingle(),
|
||||
BlendType = blend
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static ExpressionBlendType ParseBlendType(string value) => value switch
|
||||
{
|
||||
"Multiply" => ExpressionBlendType.Multiply,
|
||||
"Overwrite" => ExpressionBlendType.Overwrite,
|
||||
_ => ExpressionBlendType.Additive
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Applies all expression parameters to the model using their
|
||||
/// respective blend modes. Called every frame by the queue manager.
|
||||
/// </summary>
|
||||
public override unsafe void DoUpdateParameters(
|
||||
CubismNativeModel model, float userTimeSeconds, float weight, CubismMotionQueueEntry entry)
|
||||
{
|
||||
var idsPtr = model.GetParameterIds();
|
||||
var values = model.GetParameterValues();
|
||||
var minValues = model.GetParameterMinimumValues();
|
||||
var maxValues = model.GetParameterMaximumValues();
|
||||
var defaultValues = model.GetParameterDefaultValues();
|
||||
|
||||
for (int pi = 0; pi < model.ParameterCount; pi++)
|
||||
{
|
||||
var pid = CubismNativeModel.ReadStringFromPtrArray(idsPtr, pi);
|
||||
foreach (var ep in _parameters)
|
||||
{
|
||||
if (ep.ParameterId != pid) continue;
|
||||
|
||||
switch (ep.BlendType)
|
||||
{
|
||||
case ExpressionBlendType.Additive:
|
||||
values[pi] = values[pi] + ep.Value * weight;
|
||||
break;
|
||||
case ExpressionBlendType.Multiply:
|
||||
values[pi] = values[pi] * (1f + ep.Value * weight);
|
||||
break;
|
||||
case ExpressionBlendType.Overwrite:
|
||||
values[pi] = values[pi] * (1f - weight) + ep.Value * weight;
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Cthangover.Live2D.Cubism.Framework
|
||||
{
|
||||
/// <summary>
|
||||
/// Drives a state-machine based eye blink animation on target model parameters.
|
||||
/// Cycles through five states: First -> Interval -> Closing -> Closed -> Opening,
|
||||
/// modulating the specified parameter values from 1 (open) to 0 (closed) and back.
|
||||
///
|
||||
/// Blink timing is randomized per cycle: each interval between blinks is a random
|
||||
/// value in <c>(0, 2 * BlinkIntervalSeconds)</c>. The actual blink consists of
|
||||
/// configurable closing, closed, and opening phase durations.
|
||||
///
|
||||
/// Used by <see cref="CubismModelNode"/> when <see cref="CubismModelNode.EnableEyeBlink"/>
|
||||
/// is true and the model3.json defines EyeBlink parameter group.
|
||||
/// </summary>
|
||||
public class CubismEyeBlink
|
||||
{
|
||||
private enum EyeState { First, Interval, Closing, Closed, Opening }
|
||||
|
||||
private EyeState _state = EyeState.First;
|
||||
private float _nextBlinkTime;
|
||||
private float _stateStartTime;
|
||||
private float _userTimeSeconds;
|
||||
|
||||
private readonly List<string> _parameterIds = new();
|
||||
|
||||
/// <summary>
|
||||
/// Average interval between successive blinks, in seconds.
|
||||
/// Actual interval is randomized in range (0, 2 * this value).
|
||||
/// Default is 4 seconds.
|
||||
/// </summary>
|
||||
public float BlinkIntervalSeconds { get; set; } = 4f;
|
||||
|
||||
/// <summary>
|
||||
/// Duration of the eyelid closing transition, in seconds. Default 0.1.
|
||||
/// </summary>
|
||||
public float ClosingSeconds { get; set; } = 0.1f;
|
||||
|
||||
/// <summary>
|
||||
/// Duration the eyes stay fully closed, in seconds. Default 0.05.
|
||||
/// </summary>
|
||||
public float ClosedSeconds { get; set; } = 0.05f;
|
||||
|
||||
/// <summary>
|
||||
/// Duration of the eyelid opening transition, in seconds. Default 0.15.
|
||||
/// </summary>
|
||||
public float OpeningSeconds { get; set; } = 0.15f;
|
||||
|
||||
/// <summary>
|
||||
/// Sets the parameter IDs that represent eye open/close.
|
||||
/// Typically sourced from the model3.json EyeBlink group.
|
||||
/// Clears any previously set IDs.
|
||||
/// </summary>
|
||||
public void SetParameterIds(IEnumerable<string> ids)
|
||||
{
|
||||
_parameterIds.Clear();
|
||||
_parameterIds.AddRange(ids);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advances the blink state machine and writes eye openness to model parameters.
|
||||
/// 1.0 = fully open, 0.0 = fully closed. Linear interpolation between phases.
|
||||
/// Call every frame from <see cref="CubismModelNode.OnUpdate"/>.
|
||||
/// </summary>
|
||||
public void UpdateParameters(CubismNativeModel model, float deltaTimeSeconds)
|
||||
{
|
||||
_userTimeSeconds += deltaTimeSeconds;
|
||||
|
||||
switch (_state)
|
||||
{
|
||||
case EyeState.First:
|
||||
_state = EyeState.Interval;
|
||||
_nextBlinkTime = _userTimeSeconds + Random.Shared.NextSingle() * (2f * BlinkIntervalSeconds - 1f);
|
||||
break;
|
||||
|
||||
case EyeState.Interval:
|
||||
SetParameterValues(model, 1f);
|
||||
if (_userTimeSeconds >= _nextBlinkTime)
|
||||
{
|
||||
_state = EyeState.Closing;
|
||||
_stateStartTime = _userTimeSeconds;
|
||||
}
|
||||
break;
|
||||
|
||||
case EyeState.Closing:
|
||||
{
|
||||
var elapsed = _userTimeSeconds - _stateStartTime;
|
||||
var value = 1f - Math.Clamp(elapsed / ClosingSeconds, 0f, 1f);
|
||||
SetParameterValues(model, value);
|
||||
if (elapsed >= ClosingSeconds)
|
||||
{
|
||||
_state = EyeState.Closed;
|
||||
_stateStartTime = _userTimeSeconds;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case EyeState.Closed:
|
||||
SetParameterValues(model, 0f);
|
||||
if (_userTimeSeconds - _stateStartTime >= ClosedSeconds)
|
||||
{
|
||||
_state = EyeState.Opening;
|
||||
_stateStartTime = _userTimeSeconds;
|
||||
}
|
||||
break;
|
||||
|
||||
case EyeState.Opening:
|
||||
{
|
||||
var elapsed = _userTimeSeconds - _stateStartTime;
|
||||
var value = Math.Clamp(elapsed / OpeningSeconds, 0f, 1f);
|
||||
SetParameterValues(model, value);
|
||||
if (elapsed >= OpeningSeconds)
|
||||
{
|
||||
_state = EyeState.Interval;
|
||||
_nextBlinkTime = _userTimeSeconds + Random.Shared.NextSingle() * (2f * BlinkIntervalSeconds - 1f);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private unsafe void SetParameterValues(CubismNativeModel model, float value)
|
||||
{
|
||||
var idsPtr = model.GetParameterIds();
|
||||
var values = model.GetParameterValues();
|
||||
for (int i = 0; i < model.ParameterCount; i++)
|
||||
{
|
||||
var id = CubismNativeModel.ReadStringFromPtrArray(idsPtr, i);
|
||||
if (_parameterIds.Contains(id))
|
||||
values[i] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
using System;
|
||||
|
||||
namespace Cthangover.Live2D.Cubism.Framework
|
||||
{
|
||||
/// <summary>
|
||||
/// Math utilities shared across the Cubism animation pipeline.
|
||||
/// Includes easing functions (sine), angle utilities, linear
|
||||
/// interpolation helpers, and bezier curve evaluators with both
|
||||
/// analytic (Cardano) and binary search approaches.
|
||||
///
|
||||
/// The bezier methods handle Cubism's segmented animation curves:
|
||||
/// <see cref="BezierEvaluate"/> calculates a value at a given time,
|
||||
/// <see cref="CardanoAlgorithmForBezier"/> solves for the exact
|
||||
/// bezier parameter t, and <see cref="BezierEvaluateBinarySearch"/>
|
||||
/// is a fallback when beziers are restricted (non-standard).
|
||||
/// </summary>
|
||||
public static class CubismMath
|
||||
{
|
||||
/// <summary>
|
||||
/// Sine-based easing function mapping [0,1] -> [0,1] with smooth
|
||||
/// acceleration and deceleration. Used for fade-in/fade-out curves
|
||||
/// in motion transitions.
|
||||
/// <c>0.5 - 0.5 * cos(pi * t)</c>, clamped to [0,1].
|
||||
/// </summary>
|
||||
public static float GetEasingSine(float t)
|
||||
{
|
||||
if (t < 0f) return 0f;
|
||||
if (t > 1f) return 1f;
|
||||
return 0.5f - 0.5f * MathF.Cos(MathF.PI * t);
|
||||
}
|
||||
|
||||
/// <summary>Converts degrees to radians.</summary>
|
||||
public static float DegreesToRadian(float degrees)
|
||||
{
|
||||
return degrees * (MathF.PI / 180f);
|
||||
}
|
||||
|
||||
/// <summary>Converts radians to degrees.</summary>
|
||||
public static float RadianToDegrees(float radian)
|
||||
{
|
||||
return radian * (180f / MathF.PI);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the signed angle in radians from vector (fromX,fromY) to vector (toX,toY).
|
||||
/// Uses Atan2(det, dot) for the angle between two vectors.
|
||||
/// </summary>
|
||||
public static float DirectionToRadian(float fromX, float fromY, float toX, float toY)
|
||||
{
|
||||
var dot = fromX * toX + fromY * toY;
|
||||
var det = fromX * toY - fromY * toX;
|
||||
return MathF.Atan2(det, dot);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a radian angle to a unit direction vector (sin, cos).
|
||||
/// </summary>
|
||||
public static void RadianToDirection(float radian, out float x, out float y)
|
||||
{
|
||||
x = MathF.Sin(radian);
|
||||
y = MathF.Cos(radian);
|
||||
}
|
||||
|
||||
/// <summary>Standard linear interpolation between two float values.</summary>
|
||||
public static float LinearEvaluation(float startValue, float endValue, float time)
|
||||
{
|
||||
return startValue + (endValue - startValue) * time;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Linear interpolation between two <see cref="CubismMotionPoint"/> values,
|
||||
/// using their stored Time properties to compute the interpolation factor.
|
||||
/// </summary>
|
||||
public static float LinearEvaluation(ref CubismMotionPoint p0, ref CubismMotionPoint p1, float time)
|
||||
{
|
||||
var t = (time - p0.Time) / (p1.Time - p0.Time);
|
||||
return p0.Value + (p1.Value - p0.Value) * t;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// De Casteljau cubic bezier evaluation.
|
||||
/// Given 4 control points (cp0..cp3), each with a time and value,
|
||||
/// returns the bezier value at the specified time.
|
||||
/// </summary>
|
||||
public static float BezierEvaluate(
|
||||
float cp0Time, float cp0Value,
|
||||
float cp1Time, float cp1Value,
|
||||
float cp2Time, float cp2Value,
|
||||
float cp3Time, float cp3Value,
|
||||
float time)
|
||||
{
|
||||
var t = (time - cp0Time) / (cp3Time - cp0Time);
|
||||
var t1 = 1f - t;
|
||||
var t2 = t * t;
|
||||
var t12 = t1 * t1;
|
||||
|
||||
var value =
|
||||
t12 * t1 * cp0Value +
|
||||
3f * t12 * t * cp1Value +
|
||||
3f * t1 * t2 * cp2Value +
|
||||
t2 * t * cp3Value;
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cardano cubic formula to solve for the exact bezier parameter t
|
||||
/// given a target time. Used when beziers are not restricted —
|
||||
/// the analytic solution is faster than binary search.
|
||||
///
|
||||
/// Returns the t value that, when applied to the bezier equation,
|
||||
/// produces the targetTime (or very close to it).
|
||||
/// </summary>
|
||||
public static float CardanoAlgorithmForBezier(
|
||||
float cp0Time, float cp1Time, float cp2Time, float cp3Time,
|
||||
float targetTime)
|
||||
{
|
||||
var dx = cp0Time - targetTime;
|
||||
var cx = cp1Time - cp0Time;
|
||||
var bx = cp2Time - cp1Time;
|
||||
var ax = cp3Time - cp2Time;
|
||||
var a2 = ax - cx;
|
||||
var ab = 2f * (ax - 2f * bx + cx);
|
||||
var a0 = 3f * (bx - cx);
|
||||
var ad = -cx;
|
||||
var a = a2 / dx;
|
||||
var b = ab / dx;
|
||||
var c = a0 / dx;
|
||||
var d = ad / dx;
|
||||
var r = (-2f * b * b * b + 9f * a * b * c - 27f * a * a * d) / (54f * a * a * a);
|
||||
var q = (b * b - 3f * a * c) / (9f * a * a);
|
||||
var q3 = q * q * q;
|
||||
var floorTerm = r * r - q3;
|
||||
if (MathF.Abs(floorTerm) < 1e-7f) floorTerm = 0f;
|
||||
if (floorTerm < 0f)
|
||||
{
|
||||
var theta = MathF.Acos(r / MathF.Sqrt(q3));
|
||||
var sqrtQ = MathF.Sqrt(q);
|
||||
return -2f * sqrtQ * MathF.Cos(theta / 3f) - b / (3f * a);
|
||||
}
|
||||
var sqrtTerm = MathF.Sqrt(floorTerm);
|
||||
var aTerm = MathF.Cbrt(MathF.Abs(r + sqrtTerm));
|
||||
var bTerm = r > 0f
|
||||
? MathF.Cbrt(MathF.Abs(r - sqrtTerm))
|
||||
: -MathF.Cbrt(MathF.Abs(r - sqrtTerm));
|
||||
return aTerm - bTerm - b / (3f * a);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Binary search bezier evaluation — fallback for when
|
||||
/// <see cref="CubismMotionJson.AreBeziersRestricted"/> is true.
|
||||
/// Iteratively narrows t in [0,1] until the bezier time at t
|
||||
/// is within epsilon of the target time, then evaluates the value.
|
||||
/// Capped at 20 iterations with 1e-5 tolerance.
|
||||
/// </summary>
|
||||
public static float BezierEvaluateBinarySearch(
|
||||
float cp0Time, float cp1Time, float cp2Time, float cp3Time,
|
||||
float cp0Value, float cp1Value, float cp2Value, float cp3Value,
|
||||
float targetTime)
|
||||
{
|
||||
const int maxIterations = 20;
|
||||
const float epsilon = 1e-5f;
|
||||
var tMin = 0f;
|
||||
var tMax = 1f;
|
||||
|
||||
for (var i = 0; i < maxIterations; i++)
|
||||
{
|
||||
var t = (tMin + tMax) * 0.5f;
|
||||
var t1 = 1f - t;
|
||||
var t2 = t * t;
|
||||
var t12 = t1 * t1;
|
||||
var timeAtT =
|
||||
t12 * t1 * cp0Time +
|
||||
3f * t12 * t * cp1Time +
|
||||
3f * t1 * t2 * cp2Time +
|
||||
t2 * t * cp3Time;
|
||||
|
||||
if (MathF.Abs(timeAtT - targetTime) < epsilon)
|
||||
return
|
||||
t12 * t1 * cp0Value +
|
||||
3f * t12 * t * cp1Value +
|
||||
3f * t1 * t2 * cp2Value +
|
||||
t2 * t * cp3Value;
|
||||
|
||||
if (timeAtT < targetTime)
|
||||
tMin = t;
|
||||
else
|
||||
tMax = t;
|
||||
}
|
||||
|
||||
var tf = (tMin + tMax) * 0.5f;
|
||||
var tf1 = 1f - tf;
|
||||
var tf2 = tf * tf;
|
||||
var tf12 = tf1 * tf1;
|
||||
return
|
||||
tf12 * tf1 * cp0Value +
|
||||
3f * tf12 * tf * cp1Value +
|
||||
3f * tf1 * tf2 * cp2Value +
|
||||
tf2 * tf * cp3Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.Live2D.Cubism.Framework
|
||||
{
|
||||
/// <summary>
|
||||
/// Manages the 2D transformation (scale, translation, alignment) for a
|
||||
/// Cubism model rendered inside a Godot <see cref="Node2D"/>.
|
||||
///
|
||||
/// Works with two coordinate systems:
|
||||
/// <list type="bullet">
|
||||
/// <item>Cubism canvas coordinates (the model's native coordinate space)</item>
|
||||
/// <item>Godot node local coordinates (where the model is placed)</item>
|
||||
/// </list>
|
||||
///
|
||||
/// The scale is computed relative to the canvas dimensions stored at construction.
|
||||
/// <see cref="SetWidth"/> and <see cref="SetHeight"/> set a uniform scale based
|
||||
/// on the ratio of target size to canvas size. <see cref="SetupFromLayout"/> reads
|
||||
/// positioning keys from a Cubism .model3.json "Layout" section.
|
||||
/// <see cref="ApplyToNode"/> writes the final transform to a Godot Node2D.
|
||||
/// </summary>
|
||||
public class CubismModelMatrix
|
||||
{
|
||||
private float _width;
|
||||
private float _height;
|
||||
private float _scaleX = 1f;
|
||||
private float _scaleY = 1f;
|
||||
private float _translateX;
|
||||
private float _translateY;
|
||||
|
||||
/// <summary>Creates an identity matrix with no known canvas size.</summary>
|
||||
public CubismModelMatrix() { }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a matrix referenced to the given canvas pixel dimensions.
|
||||
/// Subsequent calls to <see cref="SetWidth"/> and <see cref="SetHeight"/>
|
||||
/// will compute scale as target / canvas.
|
||||
/// </summary>
|
||||
public CubismModelMatrix(float width, float height)
|
||||
{
|
||||
_width = width;
|
||||
_height = height;
|
||||
}
|
||||
|
||||
/// <summary>Current uniform scale on the X axis.</summary>
|
||||
public float ScaleX => _scaleX;
|
||||
|
||||
/// <summary>Current uniform scale on the Y axis.</summary>
|
||||
public float ScaleY => _scaleY;
|
||||
|
||||
/// <summary>Current X translation offset in Godot local coordinates.</summary>
|
||||
public float TranslateX => _translateX;
|
||||
|
||||
/// <summary>Current Y translation offset in Godot local coordinates.</summary>
|
||||
public float TranslateY => _translateY;
|
||||
|
||||
/// <summary>
|
||||
/// Sets uniform scale to match the given width. Scale = targetWidth / canvasWidth.
|
||||
/// Does nothing if canvas width is zero or unknown.
|
||||
/// </summary>
|
||||
public void SetWidth(float w)
|
||||
{
|
||||
if (_width > 0f)
|
||||
{
|
||||
var scale = w / _width;
|
||||
_scaleX = scale;
|
||||
_scaleY = scale;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets uniform scale to match the given height. Scale = targetHeight / canvasHeight.
|
||||
/// Does nothing if canvas height is zero or unknown.
|
||||
/// </summary>
|
||||
public void SetHeight(float h)
|
||||
{
|
||||
if (_height > 0f)
|
||||
{
|
||||
var scale = h / _height;
|
||||
_scaleX = scale;
|
||||
_scaleY = scale;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Sets absolute translation position.</summary>
|
||||
public void SetPosition(float x, float y)
|
||||
{
|
||||
_translateX = x;
|
||||
_translateY = y;
|
||||
}
|
||||
|
||||
/// <summary>Sets absolute X translation.</summary>
|
||||
public void SetX(float x) => _translateX = x;
|
||||
|
||||
/// <summary>Sets absolute Y translation.</summary>
|
||||
public void SetY(float y) => _translateY = y;
|
||||
|
||||
/// <summary>
|
||||
/// Centers the model horizontally and vertically at the given point.
|
||||
/// Equivalent to calling <see cref="CenterX"/> then <see cref="CenterY"/>.
|
||||
/// </summary>
|
||||
public void SetCenterPosition(float x, float y)
|
||||
{
|
||||
CenterX(x);
|
||||
CenterY(y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Centers horizontally: translateX = x - (width * scaleX) / 2.
|
||||
/// </summary>
|
||||
public void CenterX(float x) => _translateX = x - (_width * _scaleX) / 2f;
|
||||
|
||||
/// <summary>
|
||||
/// Centers vertically: translateY = y - (height * scaleY) / 2.
|
||||
/// </summary>
|
||||
public void CenterY(float y) => _translateY = y - (_height * _scaleY) / 2f;
|
||||
|
||||
/// <summary>Aligns the top edge of the model to y.</summary>
|
||||
public void Top(float y) => _translateY = y;
|
||||
|
||||
/// <summary>Aligns the bottom edge of the model to y.</summary>
|
||||
public void Bottom(float y) => _translateY = y - _height * _scaleY;
|
||||
|
||||
/// <summary>Aligns the left edge of the model to x.</summary>
|
||||
public void Left(float x) => _translateX = x;
|
||||
|
||||
/// <summary>Aligns the right edge of the model to x.</summary>
|
||||
public void Right(float x) => _translateX = x - _width * _scaleX;
|
||||
|
||||
/// <summary>
|
||||
/// Configures scale and position from a layout dictionary.
|
||||
/// Supported keys: "width", "height", "x", "y", "center_x", "center_y",
|
||||
/// "top", "bottom", "left", "right".
|
||||
/// Width/height are applied first (since they affect scale),
|
||||
/// then position keys are applied.
|
||||
/// </summary>
|
||||
public void SetupFromLayout(Dictionary<string, float> layout)
|
||||
{
|
||||
foreach (var kvp in layout)
|
||||
{
|
||||
switch (kvp.Key)
|
||||
{
|
||||
case "width": SetWidth(kvp.Value); break;
|
||||
case "height": SetHeight(kvp.Value); break;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var kvp in layout)
|
||||
{
|
||||
switch (kvp.Key)
|
||||
{
|
||||
case "x": SetX(kvp.Value); break;
|
||||
case "y": SetY(kvp.Value); break;
|
||||
case "center_x": CenterX(kvp.Value); break;
|
||||
case "center_y": CenterY(kvp.Value); break;
|
||||
case "top": Top(kvp.Value); break;
|
||||
case "bottom": Bottom(kvp.Value); break;
|
||||
case "left": Left(kvp.Value); break;
|
||||
case "right": Right(kvp.Value); break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the current scale and translation to a Godot <see cref="Node2D"/>.
|
||||
/// Sets both <c>Scale</c> and <c>Position</c> properties.
|
||||
/// </summary>
|
||||
public void ApplyToNode(Node2D node)
|
||||
{
|
||||
node.Scale = new Vector2(_scaleX, _scaleY);
|
||||
node.Position = new Vector2(_translateX, _translateY);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
using Cthangover.Core.Mods;
|
||||
using Cthangover.Core.UI.Event;
|
||||
using Cthangover.Core.Utils;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.Live2D.Cubism.Framework
|
||||
{
|
||||
/// <summary>
|
||||
/// Defines how the Live2D model scales to fit its parent container.
|
||||
/// </summary>
|
||||
public enum FitMode { None, FitWidth, FitHeight, FitAuto }
|
||||
|
||||
/// <summary>
|
||||
/// The main Godot node that loads, updates, and renders a Live2D Cubism model.
|
||||
/// Represents a single character/avatar in the scene tree.
|
||||
///
|
||||
/// Orchestrates the full pipeline:
|
||||
/// <list type="number">
|
||||
/// <item>Loads .model3.json and .moc3 via <see cref="CubismNativeModel"/></item>
|
||||
/// <item>Sets up <see cref="CubismParameter"/> and <see cref="CubismPartOpacity"/> arrays</item>
|
||||
/// <item>Creates optional effect layers: <see cref="CubismEyeBlink"/>, <see cref="CubismBreath"/>,
|
||||
/// <see cref="CubismPose"/>, <see cref="CubismPhysics"/></item>
|
||||
/// <item>Builds 2D meshes through <see cref="CubismRenderer2D"/></item>
|
||||
/// <item>Every frame (<see cref="OnUpdate"/>): target point -> parameter injection ->
|
||||
/// motion/expression evaluation -> effect layers -> model update -> renderer update</item>
|
||||
/// </list>
|
||||
///
|
||||
/// The update order matters: motion values are applied first, then expression values
|
||||
/// blend on top, and finally effect layers (eye blink, breath, pose, physics) modify
|
||||
/// the result. The target point drives face-tracking parameters (ParamAngleX/Y, ParamEyeBallX/Y).
|
||||
/// </summary>
|
||||
public partial class CubismModelNode : Node2D, IOnUpdateEvent
|
||||
{
|
||||
public int Priority => 0;
|
||||
private CubismNativeModel _nativeModel;
|
||||
private CubismRenderer2D _renderer;
|
||||
private CubismModelSettingJson _modelSetting;
|
||||
|
||||
private CubismEyeBlink _eyeBlink;
|
||||
private CubismBreath _breath;
|
||||
private CubismPose _pose;
|
||||
private CubismPhysics _physics;
|
||||
private CubismTargetPoint _targetPoint = new();
|
||||
|
||||
private Vector2 _contentSize;
|
||||
private Vector2 _contentCenter;
|
||||
private bool _contentBoundsReady;
|
||||
|
||||
private CubismMotionQueueManager _motionManager = new();
|
||||
private CubismMotionQueueManager _expressionManager = new();
|
||||
|
||||
private float _userTimeSeconds;
|
||||
private string _modelDir;
|
||||
private IModFileProvider _fileProvider;
|
||||
private bool _layoutLogged;
|
||||
private readonly System.Collections.Generic.HashSet<string> _manualParameters = new();
|
||||
|
||||
/// <summary>
|
||||
/// All model parameters with their IDs, bounds, and current values.
|
||||
/// Directly references the native parameter arrays — modifying
|
||||
/// <see cref="CubismParameter.Value"/> and calling
|
||||
/// <see cref="CubismParameter.ApplyToModel"/> writes back to CubismCore.
|
||||
/// </summary>
|
||||
public CubismParameter[] Parameters { get; private set; } = System.Array.Empty<CubismParameter>();
|
||||
|
||||
/// <summary>
|
||||
/// All model part opacities, indexed by part index.
|
||||
/// Useful for showing/hiding parts (clothing layers, accessories, etc.).
|
||||
/// </summary>
|
||||
public CubismPartOpacity[] PartOpacities { get; private set; } = System.Array.Empty<CubismPartOpacity>();
|
||||
|
||||
/// <summary>Canvas pixel width from the model's .moc3 data. Available after loading.</summary>
|
||||
public float CanvasWidth => _nativeModel?.CanvasWidth ?? 0f;
|
||||
|
||||
/// <summary>Canvas pixel height from the model's .moc3 data. Available after loading.</summary>
|
||||
public float CanvasHeight => _nativeModel?.CanvasHeight ?? 0f;
|
||||
|
||||
/// <summary>
|
||||
/// Horizontal anchor point for layout positioning, 0..1.
|
||||
/// 0 = left edge, 0.5 = center, 1 = right edge. Default 0.5.
|
||||
/// Serialized as a Godot export property.
|
||||
/// </summary>
|
||||
[Export] public float AnchorX { get; set; } = 0.5f;
|
||||
|
||||
/// <summary>
|
||||
/// Vertical anchor point for layout positioning, 0..1.
|
||||
/// 0 = top edge, 0.5 = center, 1 = bottom edge. Default 0.5.
|
||||
/// </summary>
|
||||
[Export] public float AnchorY { get; set; } = 0.5f;
|
||||
|
||||
/// <summary>
|
||||
/// How the model scales to fit its parent container. Default <see cref="FitMode.FitAuto"/>.
|
||||
/// Used by <see cref="DeferredApplyLayout"/> on the first frame with a valid parent size.
|
||||
/// </summary>
|
||||
[Export] public FitMode ScaleFit { get; set; } = FitMode.FitAuto;
|
||||
|
||||
/// <summary>
|
||||
/// Enables automatic eye blink animation. Requires EyeBlink parameter groups
|
||||
/// in the model3.json. Default true.
|
||||
/// </summary>
|
||||
[Export] public bool EnableEyeBlink { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Enables breathing animation. Default false.
|
||||
/// Note: breath parameters are not currently loaded from model settings —
|
||||
/// enable this only if parameters are set externally via <see cref="CubismBreath.SetParameters"/>.
|
||||
/// </summary>
|
||||
[Export] public bool EnableBreath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Enables physics simulation (hair, cloth, etc.) from the .physics3.json file.
|
||||
/// Default true.
|
||||
/// </summary>
|
||||
[Export] public bool EnablePhysics { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Enables pose-based part opacity management from the .pose3.json file.
|
||||
/// Default true.
|
||||
/// </summary>
|
||||
[Export] public bool EnablePose { get; set; } = true;
|
||||
|
||||
private bool _loaded;
|
||||
|
||||
/// <summary>
|
||||
/// Loads a Cubism model from within a registered mod.
|
||||
/// Reads the .model3.json file, resolves the .moc3 and textures, then
|
||||
/// calls <see cref="FinishSetup"/> to build parameters, parts, effects, and meshes.
|
||||
/// When <paramref name="modId"/> is <c>null</c>, all mods are searched in priority
|
||||
/// order for the first match.
|
||||
/// </summary>
|
||||
/// <param name="relativePath">Path to .model3.json relative to the mod root.</param>
|
||||
/// <param name="modId">Optional registered mod ID containing the model assets.</param>
|
||||
public void LoadModelFromMod(string relativePath, string modId = null)
|
||||
{
|
||||
var mods = ModManager.Instance?.Mods;
|
||||
if (mods == null)
|
||||
{
|
||||
GameLogger.Log("LIVE2D", $"LoadModelFromMod: no mods loaded", LogLevel.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
IModInfo modInfo = null;
|
||||
|
||||
if (modId != null)
|
||||
{
|
||||
mods.TryGetValue(modId, out modInfo);
|
||||
}
|
||||
else
|
||||
{
|
||||
var orderedIds = ModRegistry.Instance.GetOrderedModIds();
|
||||
foreach (var id in orderedIds)
|
||||
{
|
||||
if (mods.TryGetValue(id, out var candidate) && candidate.FileProvider?.FileExists(relativePath) == true)
|
||||
{
|
||||
modInfo = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (modInfo == null)
|
||||
{
|
||||
GameLogger.Log("LIVE2D", $"LoadModelFromMod: file '{relativePath}' not found in any mod", LogLevel.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
_fileProvider = modInfo.FileProvider;
|
||||
var jsonBytes = _fileProvider.ReadFileBinary(relativePath);
|
||||
if (jsonBytes == null)
|
||||
{
|
||||
GameLogger.Log("LIVE2D", $"LoadModelFromMod: file not found '{relativePath}' in mod '{modId ?? "?"}'", LogLevel.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
_modelDir = relativePath.Contains('/')
|
||||
? relativePath.Substring(0, relativePath.LastIndexOf('/'))
|
||||
: "";
|
||||
|
||||
_nativeModel = new CubismNativeModel();
|
||||
_nativeModel.LoadFromProvider(jsonBytes, _modelDir, _fileProvider);
|
||||
FinishSetup(jsonBytes);
|
||||
}
|
||||
|
||||
private void FinishSetup(byte[] model3JsonBytes)
|
||||
{
|
||||
_modelSetting = new CubismModelSettingJson(model3JsonBytes);
|
||||
SetupParameters();
|
||||
SetupParts();
|
||||
SetupEffects();
|
||||
|
||||
_renderer = new CubismRenderer2D(this);
|
||||
_nativeModel.UpdateModel();
|
||||
_renderer.BuildModel(_nativeModel);
|
||||
|
||||
var bounds = _renderer.GetContentBounds();
|
||||
_contentSize = new Vector2(bounds.Size.X, bounds.Size.Y);
|
||||
_contentCenter = new Vector2(bounds.GetCenter().X, bounds.GetCenter().Y);
|
||||
_contentBoundsReady = true;
|
||||
GameLogger.Log("LIVE2D", $"contentBounds: center={_contentCenter} size={_contentSize}");
|
||||
|
||||
_loaded = true;
|
||||
|
||||
var ec = GetNodeOrNull<SceneEventController>("/root/EventController");
|
||||
if (ec != null)
|
||||
ec.AddUpdateEventListener(this);
|
||||
|
||||
GameLogger.Log("LIVE2D", $"Model loaded: {_nativeModel.ParameterCount} params, {_nativeModel.DrawableCount} drawables, canvas={_nativeModel.CanvasWidth}x{_nativeModel.CanvasHeight}");
|
||||
}
|
||||
|
||||
private ulong _lastTickMsec;
|
||||
|
||||
/// <summary>
|
||||
/// Per-frame update called by the SceneEventController.
|
||||
/// Execution order:
|
||||
/// <list type="number">
|
||||
/// <item>Apply deferred layout (first valid frame only)</item>
|
||||
/// <item>Update <see cref="_targetPoint"/> for face tracking</item>
|
||||
/// <item>Inject face-tracking parameters (ParamAngleX/Y, ParamEyeBallX/Y)
|
||||
/// into the native model</item>
|
||||
/// <item>Evaluate motion queue (character animations)</item>
|
||||
/// <item>Evaluate expression queue (facial expressions)</item>
|
||||
/// <item>Run effect layers in order: breath, eye blink, pose, physics</item>
|
||||
/// <item>Call <c>csmUpdateModel</c> on the native model</item>
|
||||
/// <item>Update the renderer (visibility, opacity, vertex positions)</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public void OnUpdate()
|
||||
{
|
||||
if (!_loaded) return;
|
||||
|
||||
var now = Time.GetTicksMsec();
|
||||
var dt = _lastTickMsec == 0 ? 0.016f : (now - _lastTickMsec) / 1000f;
|
||||
_lastTickMsec = now;
|
||||
|
||||
DeferredApplyLayout();
|
||||
|
||||
_userTimeSeconds += dt;
|
||||
|
||||
_targetPoint.Update(dt);
|
||||
var tx = _targetPoint.X;
|
||||
var ty = _targetPoint.Y;
|
||||
|
||||
unsafe
|
||||
{
|
||||
var values = _nativeModel.GetParameterValues();
|
||||
var idsPtr = _nativeModel.GetParameterIds();
|
||||
|
||||
for (int i = 0; i < _nativeModel.ParameterCount; i++)
|
||||
{
|
||||
var id = CubismNativeModel.ReadStringFromPtrArray(idsPtr, i);
|
||||
if (_manualParameters.Contains(id)) continue;
|
||||
switch (id)
|
||||
{
|
||||
case "ParamAngleX": values[i] = tx * 30f; break;
|
||||
case "ParamAngleY": values[i] = ty * 30f; break;
|
||||
case "ParamEyeBallX": values[i] = tx; break;
|
||||
case "ParamEyeBallY": values[i] = ty; break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_motionManager.DoUpdateMotion(_nativeModel, _userTimeSeconds);
|
||||
_expressionManager.DoUpdateMotion(_nativeModel, _userTimeSeconds);
|
||||
|
||||
_breath?.UpdateParameters(_nativeModel, dt);
|
||||
_eyeBlink?.UpdateParameters(_nativeModel, dt);
|
||||
_pose?.UpdateParameters(_nativeModel, dt);
|
||||
_physics?.Evaluate(_nativeModel, dt);
|
||||
|
||||
_nativeModel.UpdateModel();
|
||||
_renderer.UpdateModel();
|
||||
}
|
||||
|
||||
private void DeferredApplyLayout()
|
||||
{
|
||||
if (_layoutLogged) return;
|
||||
if (!_contentBoundsReady) return;
|
||||
|
||||
var parentSize = GetParentSize();
|
||||
if (parentSize.X <= 0f || parentSize.Y <= 0f) return;
|
||||
|
||||
var cw = _contentSize.X;
|
||||
var ch = _contentSize.Y;
|
||||
if (cw <= 0f || ch <= 0f) return;
|
||||
|
||||
float scale = 1f;
|
||||
switch (ScaleFit)
|
||||
{
|
||||
case FitMode.FitWidth: scale = parentSize.X / cw; break;
|
||||
case FitMode.FitHeight: scale = parentSize.Y / ch; break;
|
||||
case FitMode.FitAuto: scale = Mathf.Min(parentSize.X / cw, parentSize.Y / ch); break;
|
||||
}
|
||||
|
||||
Scale = new Vector2(scale, scale);
|
||||
Position = new Vector2(
|
||||
parentSize.X * AnchorX - _contentCenter.X * scale,
|
||||
parentSize.Y * AnchorY - _contentCenter.Y * scale);
|
||||
|
||||
_layoutLogged = true;
|
||||
GameLogger.Log("LIVE2D", $"ApplyLayout: parentSize={parentSize} contentSize={cw}x{ch} contentCenter={_contentCenter} scale={scale} pos={Position}");
|
||||
}
|
||||
|
||||
private Vector2 GetParentSize()
|
||||
{
|
||||
var parent = GetParent();
|
||||
if (parent is Control c && c.Size.X > 0f && c.Size.Y > 0f)
|
||||
return c.Size;
|
||||
return GetViewportRect().Size;
|
||||
}
|
||||
|
||||
private unsafe void SetupParameters()
|
||||
{
|
||||
var count = _nativeModel.ParameterCount;
|
||||
Parameters = new CubismParameter[count];
|
||||
var idsPtr = _nativeModel.GetParameterIds();
|
||||
var mins = _nativeModel.GetParameterMinimumValues();
|
||||
var maxs = _nativeModel.GetParameterMaximumValues();
|
||||
var defaults = _nativeModel.GetParameterDefaultValues();
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
Parameters[i] = new CubismParameter(_nativeModel)
|
||||
{
|
||||
Id = CubismNativeModel.ReadStringFromPtrArray(idsPtr, i),
|
||||
Index = i,
|
||||
MinimumValue = mins[i],
|
||||
MaximumValue = maxs[i],
|
||||
DefaultValue = defaults[i],
|
||||
Value = defaults[i]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private unsafe void SetupParts()
|
||||
{
|
||||
var count = _nativeModel.GetPartCount();
|
||||
PartOpacities = new CubismPartOpacity[count];
|
||||
var idsPtr = _nativeModel.GetPartIds();
|
||||
var opacities = _nativeModel.GetPartOpacities();
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
PartOpacities[i] = new CubismPartOpacity(_nativeModel)
|
||||
{
|
||||
Id = CubismNativeModel.ReadStringFromPtrArray(idsPtr, i),
|
||||
Index = i,
|
||||
Value = opacities[i]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private void SetupEffects()
|
||||
{
|
||||
if (EnableEyeBlink && _modelSetting.GetEyeBlinkParameterCount() > 0)
|
||||
{
|
||||
_eyeBlink = new CubismEyeBlink();
|
||||
var ids = new System.Collections.Generic.List<string>();
|
||||
for (int i = 0; i < _modelSetting.GetEyeBlinkParameterCount(); i++)
|
||||
ids.Add(_modelSetting.GetEyeBlinkParameterId(i));
|
||||
_eyeBlink.SetParameterIds(ids);
|
||||
}
|
||||
|
||||
var physicsPath = _modelSetting.GetPhysicsFileName();
|
||||
if (EnablePhysics && !string.IsNullOrEmpty(physicsPath))
|
||||
{
|
||||
var physBytes = ReadModFile(physicsPath);
|
||||
if (physBytes != null)
|
||||
_physics = new CubismPhysics(physBytes);
|
||||
else
|
||||
GameLogger.Log("LIVE2D", $"SetupEffects: failed to read physics '{physicsPath}'", LogLevel.Warning);
|
||||
}
|
||||
|
||||
var posePath = _modelSetting.GetPoseFileName();
|
||||
if (EnablePose && !string.IsNullOrEmpty(posePath))
|
||||
{
|
||||
var poseBytes = ReadModFile(posePath);
|
||||
if (poseBytes != null)
|
||||
_pose = new CubismPose(poseBytes);
|
||||
else
|
||||
GameLogger.Log("LIVE2D", $"SetupEffects: failed to read pose '{posePath}'", LogLevel.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] ReadModFile(string relativePath)
|
||||
{
|
||||
var normalized = relativePath.Replace('\\', '/');
|
||||
var combined = string.IsNullOrEmpty(_modelDir) ? normalized : $"{_modelDir}/{normalized}";
|
||||
var result = _fileProvider.ReadFileBinary(combined);
|
||||
if (result == null)
|
||||
GameLogger.Log("LIVE2D", $"ReadModFile: provider returned null for '{combined}'", LogLevel.Warning);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Sets the face-tracking target. Drives eye/head movement parameters
|
||||
/// (ParamAngleX/Y, ParamEyeBallX/Y) through a smooth damped approach
|
||||
/// in <see cref="CubismTargetPoint"/>.
|
||||
/// </summary>
|
||||
public void SetTargetPoint(float x, float y) => _targetPoint.Set(x, y);
|
||||
|
||||
/// <summary>
|
||||
/// Finds a parameter by its Cubism ID string (e.g. "ParamAngleX").
|
||||
/// Returns null if not found.
|
||||
/// </summary>
|
||||
public CubismParameter FindParameter(string id)
|
||||
{
|
||||
foreach (var p in Parameters)
|
||||
if (p.Id == id) return p;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds a part by its Cubism ID string. Returns null if not found.
|
||||
/// </summary>
|
||||
public CubismPartOpacity FindPart(string id)
|
||||
{
|
||||
foreach (var p in PartOpacities)
|
||||
if (p.Id == id) return p;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets a parameter value directly in the native model, bypassing the per-frame
|
||||
/// face-tracking injection in <see cref="OnUpdate"/>. Once a parameter is set
|
||||
/// via this method, <see cref="OnUpdate"/> stops overwriting it — the caller
|
||||
/// takes full control of that parameter's value.
|
||||
/// Safe to call before the model is loaded — silently ignored.
|
||||
/// </summary>
|
||||
/// <param name="id">Cubism parameter ID (e.g. "ParamAngleX").</param>
|
||||
/// <param name="value">Raw parameter value. Caller should clamp to the parameter's bounds.</param>
|
||||
public void SetLiveParameter(string id, float value)
|
||||
{
|
||||
if (!_loaded)
|
||||
{
|
||||
GameLogger.Log("LIVE2D", $"SetLiveParameter: model not loaded, ignoring id='{id}'", LogLevel.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
unsafe
|
||||
{
|
||||
var values = _nativeModel.GetParameterValues();
|
||||
var idsPtr = _nativeModel.GetParameterIds();
|
||||
|
||||
for (int i = 0; i < _nativeModel.ParameterCount; i++)
|
||||
{
|
||||
var paramId = CubismNativeModel.ReadStringFromPtrArray(idsPtr, i);
|
||||
if (paramId != id) continue;
|
||||
|
||||
values[i] = value;
|
||||
_manualParameters.Add(id);
|
||||
|
||||
if (i < Parameters.Length)
|
||||
Parameters[i].Value = value;
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
GameLogger.Log("LIVE2D", $"SetLiveParameter: parameter '{id}' not found", LogLevel.Warning);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts a motion animation by group name and index.
|
||||
/// Loads the motion3.json from the model directory, applies the
|
||||
/// model-specified fade-in/fade-out times, and enqueues it.
|
||||
/// Safe to call before the model is loaded — the call is silently ignored.
|
||||
/// </summary>
|
||||
/// <param name="group">Motion group name from model3.json (e.g. "Idle", "TapBody").</param>
|
||||
/// <param name="no">Zero-based index within the group.</param>
|
||||
public void StartMotion(string group, int no)
|
||||
{
|
||||
if (!_loaded) return;
|
||||
if (no < 0 || no >= _modelSetting.GetMotionCount(group)) return;
|
||||
|
||||
var motionFileName = _modelSetting.GetMotionFileName(group, no);
|
||||
var motionBytes = ReadModFile(motionFileName);
|
||||
if (motionBytes == null) return;
|
||||
|
||||
var motion = new CubismMotion(motionBytes);
|
||||
|
||||
var fadeIn = _modelSetting.GetMotionFadeInTimeValue(group, no);
|
||||
var fadeOut = _modelSetting.GetMotionFadeOutTimeValue(group, no);
|
||||
if (fadeIn >= 0f) motion.FadeInSeconds = fadeIn;
|
||||
if (fadeOut >= 0f) motion.FadeOutSeconds = fadeOut;
|
||||
|
||||
_motionManager.StartMotion(motion, true, _userTimeSeconds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts an expression by its name (as defined in the model3.json
|
||||
/// Expressions array). Looks up the expression file, loads the
|
||||
/// .exp3.json, and enqueues it in the expression manager.
|
||||
/// Safe to call before model loaded — silently ignored.
|
||||
/// </summary>
|
||||
public void StartExpression(string expressionId)
|
||||
{
|
||||
if (!_loaded) return;
|
||||
|
||||
for (int i = 0; i < _modelSetting.GetExpressionCount(); i++)
|
||||
{
|
||||
if (_modelSetting.GetExpressionName(i) == expressionId)
|
||||
{
|
||||
var expBytes = ReadModFile(_modelSetting.GetExpressionFileName(i));
|
||||
if (expBytes == null) return;
|
||||
|
||||
var expr = new CubismExpressionMotion(expBytes);
|
||||
_expressionManager.StartMotion(expr, true, _userTimeSeconds);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Stops all currently playing motion animations.</summary>
|
||||
public void StopMotion() => _motionManager.StopAllMotions();
|
||||
|
||||
/// <summary>Stops all currently playing expression animations.</summary>
|
||||
public void StopExpression() => _expressionManager.StopAllMotions();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Cthangover.Live2D.Cubism.Framework
|
||||
{
|
||||
/// <summary>
|
||||
/// Interface for reading a Cubism model3.json setting file.
|
||||
/// All accessors return default/empty values when the corresponding
|
||||
/// data is absent from the JSON.
|
||||
/// </summary>
|
||||
public interface ICubismModelSetting
|
||||
{
|
||||
/// <summary>Returns the relative path to the .moc3 file.</summary>
|
||||
string GetModelFileName();
|
||||
|
||||
/// <summary>Returns the number of texture references in the model.</summary>
|
||||
int GetTextureCount();
|
||||
|
||||
/// <summary>Returns the relative path to the texture at the given index.</summary>
|
||||
string GetTextureFileName(int index);
|
||||
|
||||
/// <summary>Returns the relative path to the physics3.json file, or null.</summary>
|
||||
string GetPhysicsFileName();
|
||||
|
||||
/// <summary>Returns the relative path to the pose3.json file, or null.</summary>
|
||||
string GetPoseFileName();
|
||||
|
||||
/// <summary>Returns the number of expressions defined in the model.</summary>
|
||||
int GetExpressionCount();
|
||||
|
||||
/// <summary>Returns the display name of the expression at the given index.</summary>
|
||||
string GetExpressionName(int index);
|
||||
|
||||
/// <summary>Returns the relative path to the .exp3.json file for the expression at the given index.</summary>
|
||||
string GetExpressionFileName(int index);
|
||||
|
||||
/// <summary>Returns the number of motion groups.</summary>
|
||||
int GetMotionGroupCount();
|
||||
|
||||
/// <summary>Returns the name of the motion group at the given index.</summary>
|
||||
string GetMotionGroupName(int index);
|
||||
|
||||
/// <summary>Returns the number of motions within the named group.</summary>
|
||||
int GetMotionCount(string groupName);
|
||||
|
||||
/// <summary>Returns the relative path to the motion3.json file for the given group and index.</summary>
|
||||
string GetMotionFileName(string groupName, int index);
|
||||
|
||||
/// <summary>Returns the relative path to the associated sound file, or null.</summary>
|
||||
string GetMotionSoundFileName(string groupName, int index);
|
||||
|
||||
/// <summary>Returns the fade-in time in seconds for the motion, or -1 if not specified.</summary>
|
||||
float GetMotionFadeInTimeValue(string groupName, int index);
|
||||
|
||||
/// <summary>Returns the fade-out time in seconds for the motion, or -1 if not specified.</summary>
|
||||
float GetMotionFadeOutTimeValue(string groupName, int index);
|
||||
|
||||
/// <summary>Returns the number of parameters in the EyeBlink group.</summary>
|
||||
int GetEyeBlinkParameterCount();
|
||||
|
||||
/// <summary>Returns the parameter ID at the given index within the EyeBlink group.</summary>
|
||||
string GetEyeBlinkParameterId(int index);
|
||||
|
||||
/// <summary>Returns the number of parameters in the LipSync group.</summary>
|
||||
int GetLipSyncParameterCount();
|
||||
|
||||
/// <summary>Returns the parameter ID at the given index within the LipSync group.</summary>
|
||||
string GetLipSyncParameterId(int index);
|
||||
|
||||
/// <summary>Returns the number of hit area definitions.</summary>
|
||||
int GetHitAreasCount();
|
||||
|
||||
/// <summary>Returns the hit area ID at the given index.</summary>
|
||||
string GetHitAreaId(int index);
|
||||
|
||||
/// <summary>Returns the hit area display name at the given index.</summary>
|
||||
string GetHitAreaName(int index);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the Layout section as a dictionary of lowercased key -> float value.
|
||||
/// Supported keys: width, height, x, y, center_x, center_y, top, bottom, left, right.
|
||||
/// </summary>
|
||||
Dictionary<string, float> GetLayoutMap();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// JSON parser for the Cubism .model3.json format (Live2D model settings).
|
||||
/// Implements <see cref="ICubismModelSetting"/>.
|
||||
///
|
||||
/// Provides typed access to all sections: FileReferences (moc, textures,
|
||||
/// expressions, motions), Groups (EyeBlink, LipSync), HitAreas, and Layout.
|
||||
/// All string path values are returned as-is (relative to the model directory);
|
||||
/// it is the caller's responsibility to resolve them through a file provider.
|
||||
///
|
||||
/// Instances hold a <see cref="JsonDocument"/> and should be disposed
|
||||
/// via <see cref="Dispose"/> when no longer needed.
|
||||
/// </summary>
|
||||
public class CubismModelSettingJson : ICubismModelSetting
|
||||
{
|
||||
private readonly JsonDocument _json;
|
||||
private readonly JsonElement _root;
|
||||
private readonly JsonElement? _fileRefs;
|
||||
private readonly JsonElement? _groups;
|
||||
private readonly JsonElement? _hitAreas;
|
||||
private readonly JsonElement? _layout;
|
||||
|
||||
/// <summary>
|
||||
/// Parses the given .model3.json bytes.
|
||||
/// </summary>
|
||||
public CubismModelSettingJson(byte[] jsonBytes)
|
||||
{
|
||||
_json = JsonDocument.Parse(jsonBytes);
|
||||
_root = _json.RootElement;
|
||||
if (_root.TryGetProperty("FileReferences", out var fr))
|
||||
_fileRefs = fr;
|
||||
if (_root.TryGetProperty("Groups", out var gr))
|
||||
_groups = gr;
|
||||
if (_root.TryGetProperty("HitAreas", out var ha))
|
||||
_hitAreas = ha;
|
||||
if (_root.TryGetProperty("Layout", out var lo))
|
||||
_layout = lo;
|
||||
}
|
||||
|
||||
public string GetModelFileName() =>
|
||||
_fileRefs?.GetProperty("Moc").GetString();
|
||||
|
||||
public int GetTextureCount() =>
|
||||
_fileRefs?.TryGetProperty("Textures", out var arr) == true ? arr.GetArrayLength() : 0;
|
||||
|
||||
public string GetTextureFileName(int index) =>
|
||||
_fileRefs?.GetProperty("Textures")[index].GetString();
|
||||
|
||||
public string GetPhysicsFileName() =>
|
||||
TryGetString(_fileRefs, "Physics");
|
||||
|
||||
public string GetPoseFileName() =>
|
||||
TryGetString(_fileRefs, "Pose");
|
||||
|
||||
public int GetExpressionCount() =>
|
||||
_fileRefs?.TryGetProperty("Expressions", out var arr) == true ? arr.GetArrayLength() : 0;
|
||||
|
||||
public string GetExpressionName(int index) =>
|
||||
_fileRefs?.GetProperty("Expressions")[index].GetProperty("Name").GetString();
|
||||
|
||||
public string GetExpressionFileName(int index) =>
|
||||
_fileRefs?.GetProperty("Expressions")[index].GetProperty("File").GetString();
|
||||
|
||||
public int GetMotionGroupCount() =>
|
||||
_fileRefs?.TryGetProperty("Motions", out var motions) == true ? motions.EnumerateObject().Count() : 0;
|
||||
|
||||
public string GetMotionGroupName(int index)
|
||||
{
|
||||
int i = 0;
|
||||
foreach (var prop in _fileRefs?.GetProperty("Motions").EnumerateObject() ?? default)
|
||||
if (i++ == index) return prop.Name;
|
||||
return null;
|
||||
}
|
||||
|
||||
public int GetMotionCount(string groupName) =>
|
||||
_fileRefs?.TryGetProperty("Motions", out var m) == true &&
|
||||
m.TryGetProperty(groupName, out var grp) ? grp.GetArrayLength() : 0;
|
||||
|
||||
public string GetMotionFileName(string groupName, int index) =>
|
||||
_fileRefs?.GetProperty("Motions").GetProperty(groupName)[index].GetProperty("File").GetString();
|
||||
|
||||
public string GetMotionSoundFileName(string groupName, int index) =>
|
||||
TryGetString(_fileRefs?.GetProperty("Motions").GetProperty(groupName)[index], "Sound");
|
||||
|
||||
public float GetMotionFadeInTimeValue(string groupName, int index) =>
|
||||
TryGetFloat(_fileRefs?.GetProperty("Motions").GetProperty(groupName)[index], "FadeInTime");
|
||||
|
||||
public float GetMotionFadeOutTimeValue(string groupName, int index) =>
|
||||
TryGetFloat(_fileRefs?.GetProperty("Motions").GetProperty(groupName)[index], "FadeOutTime");
|
||||
|
||||
public int GetEyeBlinkParameterCount()
|
||||
{
|
||||
foreach (var group in _groups?.EnumerateArray() ?? default)
|
||||
if (group.GetProperty("Name").GetString() == "EyeBlink")
|
||||
return group.GetProperty("Ids").GetArrayLength();
|
||||
return 0;
|
||||
}
|
||||
|
||||
public string GetEyeBlinkParameterId(int index)
|
||||
{
|
||||
foreach (var group in _groups?.EnumerateArray() ?? default)
|
||||
if (group.GetProperty("Name").GetString() == "EyeBlink")
|
||||
return group.GetProperty("Ids")[index].GetString();
|
||||
return null;
|
||||
}
|
||||
|
||||
public int GetLipSyncParameterCount()
|
||||
{
|
||||
foreach (var group in _groups?.EnumerateArray() ?? default)
|
||||
if (group.GetProperty("Name").GetString() == "LipSync")
|
||||
return group.GetProperty("Ids").GetArrayLength();
|
||||
return 0;
|
||||
}
|
||||
|
||||
public string GetLipSyncParameterId(int index)
|
||||
{
|
||||
foreach (var group in _groups?.EnumerateArray() ?? default)
|
||||
if (group.GetProperty("Name").GetString() == "LipSync")
|
||||
return group.GetProperty("Ids")[index].GetString();
|
||||
return null;
|
||||
}
|
||||
|
||||
public int GetHitAreasCount() =>
|
||||
_hitAreas?.GetArrayLength() ?? 0;
|
||||
|
||||
public string GetHitAreaId(int index) =>
|
||||
_hitAreas?[index].GetProperty("Id").GetString();
|
||||
|
||||
public string GetHitAreaName(int index) =>
|
||||
_hitAreas?[index].GetProperty("Name").GetString();
|
||||
|
||||
public Dictionary<string, float> GetLayoutMap()
|
||||
{
|
||||
var map = new Dictionary<string, float>();
|
||||
if (_layout == null) return map;
|
||||
foreach (var prop in _layout.Value.EnumerateObject())
|
||||
if (prop.Value.ValueKind == JsonValueKind.Number)
|
||||
map[prop.Name.ToLowerInvariant()] = prop.Value.GetSingle();
|
||||
return map;
|
||||
}
|
||||
|
||||
private static string TryGetString(JsonElement? parent, string key) =>
|
||||
parent?.TryGetProperty(key, out var val) == true ? val.GetString() : null;
|
||||
|
||||
private static float TryGetFloat(JsonElement? parent, string key) =>
|
||||
parent?.TryGetProperty(key, out var val) == true && val.ValueKind == JsonValueKind.Number
|
||||
? val.GetSingle() : -1f;
|
||||
|
||||
/// <summary>
|
||||
/// Releases the underlying <see cref="JsonDocument"/>.
|
||||
/// </summary>
|
||||
public void Dispose() => _json?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Cthangover.Live2D.Cubism.Framework
|
||||
{
|
||||
public delegate float MotionSegmentFunction(float[] segments, int baseIndex, float time);
|
||||
|
||||
/// <summary>
|
||||
/// Parses and plays a Cubism .motion3.json animation.
|
||||
/// Manages three categories of curves:
|
||||
/// <list type="bullet">
|
||||
/// <item><b>Model curves</b> — eye blink, lip sync, and opacity</item>
|
||||
/// <item><b>Parameter curves</b> — arbitrary model parameter animations</item>
|
||||
/// <item><b>Part opacity curves</b> — visibility/opacity of model parts</item>
|
||||
/// </list>
|
||||
///
|
||||
/// Each curve is a sequence of segments (linear, stepped, bezier) stored in
|
||||
/// flat arrays for performance. Evaluation uses a segment-finding pass
|
||||
/// followed by per-segment interpolation — linear, stepped, or bezier
|
||||
/// (analytic via Cardano formula or binary search for restricted beziers).
|
||||
///
|
||||
/// The four-pass update cycle (<see cref="DoUpdateParameters"/>):
|
||||
/// <list type="number">
|
||||
/// <item>Evaluate model curves (eye blink, lip sync, opacity)</item>
|
||||
/// <item>Evaluate parameter curves, applying eye blink/lip sync modulation</item>
|
||||
/// <item>Apply eye blink/lip sync to remaining (non-animated) parameters</item>
|
||||
/// <item>Evaluate part opacity curves</item>
|
||||
/// </list>
|
||||
///
|
||||
/// Parameter fade-in/out is handled per-curve (overriding the motion-level
|
||||
/// fade settings). Curve targets and IDs come from the motion3.json metadata.
|
||||
/// </summary>
|
||||
public class CubismMotion : ACubismMotion
|
||||
{
|
||||
private const string ModelCurveIdEyeBlink = "EyeBlink";
|
||||
private const string ModelCurveIdLipSync = "LipSync";
|
||||
private const string ModelCurveIdOpacity = "Opacity";
|
||||
|
||||
private readonly float _sourceFrameRate;
|
||||
private float _lastWeight;
|
||||
private CubismMotionJson _motionJson;
|
||||
|
||||
private readonly List<MotionCurveData> _modelCurves = new();
|
||||
private readonly List<MotionCurveData> _parameterCurves = new();
|
||||
private readonly List<MotionCurveData> _partOpacityCurves = new();
|
||||
|
||||
private readonly List<string> _eyeBlinkParameterIds = new();
|
||||
private readonly List<string> _lipSyncParameterIds = new();
|
||||
|
||||
private readonly List<CubismMotionEvent> _events = new();
|
||||
private readonly List<string> _firedEventValues = new();
|
||||
|
||||
private float _modelOpacity = 1f;
|
||||
private bool _areBeziersRestricted;
|
||||
|
||||
/// <summary>
|
||||
/// Parses a .motion3.json byte buffer.
|
||||
/// Reads the meta section (duration, loop, fps, fade times, bezier flag)
|
||||
/// and delegates curve/event parsing to private helpers.
|
||||
/// </summary>
|
||||
public CubismMotion(byte[] motion3JsonBytes)
|
||||
{
|
||||
_motionJson = new CubismMotionJson(motion3JsonBytes);
|
||||
_sourceFrameRate = _motionJson.GetFps();
|
||||
_areBeziersRestricted = _motionJson.AreBeziersRestricted();
|
||||
_isLoop = _motionJson.IsLoop();
|
||||
|
||||
if (_motionJson.GetFadeInTime() >= 0f) _fadeInSeconds = _motionJson.GetFadeInTime();
|
||||
if (_motionJson.GetFadeOutTime() >= 0f) _fadeOutSeconds = _motionJson.GetFadeOutTime();
|
||||
|
||||
ParseCurves();
|
||||
ParseEvents();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures which parameters are affected by eye blink and lip sync
|
||||
/// modulation. These lists typically come from the model3.json Groups.
|
||||
/// When a parameter curve targets one of these IDs, its value is
|
||||
/// multiplied by the eye blink curve value (for blink) or added to
|
||||
/// the lip sync curve value (for lip sync) in Pass 2.
|
||||
/// </summary>
|
||||
public void SetEffectIds(List<string> eyeBlinkIds, List<string> lipSyncIds)
|
||||
{
|
||||
_eyeBlinkParameterIds.Clear();
|
||||
_lipSyncParameterIds.Clear();
|
||||
if (eyeBlinkIds != null) _eyeBlinkParameterIds.AddRange(eyeBlinkIds);
|
||||
if (lipSyncIds != null) _lipSyncParameterIds.AddRange(lipSyncIds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the total duration from the motion3.json, or -1 for looping motions.
|
||||
/// </summary>
|
||||
public override float GetDuration() => _isLoop ? -1f : _motionJson.GetDuration();
|
||||
|
||||
/// <summary>
|
||||
/// Returns the full duration even when looping is active — used for computing
|
||||
/// loop cycle length.
|
||||
/// </summary>
|
||||
public override float GetLoopDuration() => _motionJson.GetDuration();
|
||||
|
||||
/// <summary>Whether the model's overall opacity is actively controlled by this motion.</summary>
|
||||
public bool IsExistModelOpacity() => _modelOpacity > 0f;
|
||||
|
||||
/// <summary>Current model opacity value from the Opacity model curve, clamped [0,1].</summary>
|
||||
public float GetModelOpacity() => _modelOpacity;
|
||||
|
||||
/// <summary>
|
||||
/// Collects event values that fired between two time points.
|
||||
/// Clears the internal fired-event list on each call. (Currently returns 0f;
|
||||
/// the fired values list is populated as a side effect.)
|
||||
/// </summary>
|
||||
public float GetFiredEventValue(float beforeTime, float motionTime)
|
||||
{
|
||||
_firedEventValues.Clear();
|
||||
foreach (var evt in _events)
|
||||
if (evt.FireTime > beforeTime && evt.FireTime <= motionTime)
|
||||
_firedEventValues.Add(evt.Value);
|
||||
return 0f;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Four-pass parameter update. Called by <see cref="CubismMotionQueueManager"/>
|
||||
/// every frame.
|
||||
/// </summary>
|
||||
public override unsafe void DoUpdateParameters(
|
||||
CubismNativeModel model, float userTimeSeconds, float weight, CubismMotionQueueEntry entry)
|
||||
{
|
||||
if (model == null || entry == null) return;
|
||||
|
||||
AdjustEndTime(entry, _motionJson.GetDuration());
|
||||
|
||||
var timeOffset = userTimeSeconds - entry.StartTime;
|
||||
var duration = _motionJson.GetDuration();
|
||||
if (_isLoop && duration > 0f)
|
||||
{
|
||||
while (timeOffset > duration)
|
||||
timeOffset -= duration;
|
||||
}
|
||||
|
||||
var fadeWeight = weight;
|
||||
|
||||
int paramCount = model.ParameterCount;
|
||||
var idsPtr = model.GetParameterIds();
|
||||
var values = model.GetParameterValues();
|
||||
var minValues = model.GetParameterMinimumValues();
|
||||
var maxValues = model.GetParameterMaximumValues();
|
||||
var defaultValues = model.GetParameterDefaultValues();
|
||||
|
||||
float eyeBlinkValue = 0f;
|
||||
float lipSyncValue = 0f;
|
||||
_modelOpacity = 1f;
|
||||
var modifiedFlags = new bool[paramCount];
|
||||
|
||||
// Pass 1 — model curves
|
||||
foreach (var curve in _modelCurves)
|
||||
{
|
||||
var val = EvaluateCurve(curve, timeOffset, fadeWeight);
|
||||
if (curve.Id == ModelCurveIdEyeBlink) eyeBlinkValue = val;
|
||||
else if (curve.Id == ModelCurveIdLipSync) lipSyncValue = val;
|
||||
else if (curve.Id == ModelCurveIdOpacity) _modelOpacity = Math.Clamp(val, 0f, 1f);
|
||||
}
|
||||
|
||||
// Pass 2 — parameter curves
|
||||
foreach (var curve in _parameterCurves)
|
||||
{
|
||||
var curveValue = EvaluateCurve(curve, timeOffset, fadeWeight);
|
||||
|
||||
for (int pi = 0; pi < paramCount; pi++)
|
||||
{
|
||||
var pid = CubismNativeModel.ReadStringFromPtrArray(idsPtr, pi);
|
||||
if (pid != curve.Id) continue;
|
||||
|
||||
var modulatedValue = curveValue;
|
||||
if (_eyeBlinkParameterIds.Contains(pid))
|
||||
modulatedValue *= eyeBlinkValue;
|
||||
if (_lipSyncParameterIds.Contains(pid))
|
||||
modulatedValue += lipSyncValue;
|
||||
|
||||
var paramFadeWeight = fadeWeight;
|
||||
if (curve.ParamFadeIn >= 0f || curve.ParamFadeOut >= 0f)
|
||||
{
|
||||
var fi = curve.ParamFadeIn >= 0f ? curve.ParamFadeIn : 1f;
|
||||
var fo = curve.ParamFadeOut >= 0f ? curve.ParamFadeOut : 1f;
|
||||
var paramFadeIn = CubismMath.GetEasingSine(
|
||||
(userTimeSeconds - entry.FadeInStartTime) / fi);
|
||||
var paramFadeOut = CubismMath.GetEasingSine(
|
||||
(entry.EndTime > 0f ? entry.EndTime - userTimeSeconds : 1f) / fo);
|
||||
paramFadeWeight = _weight * paramFadeIn * paramFadeOut;
|
||||
}
|
||||
|
||||
values[pi] = values[pi] + (modulatedValue - values[pi]) * paramFadeWeight;
|
||||
ClampParameter(pi, values, minValues, maxValues);
|
||||
modifiedFlags[pi] = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 3 — remaining eye blink / lip sync (non-modulated params)
|
||||
for (int pi = 0; pi < paramCount; pi++)
|
||||
{
|
||||
if (modifiedFlags[pi]) continue;
|
||||
var pid = CubismNativeModel.ReadStringFromPtrArray(idsPtr, pi);
|
||||
if (_eyeBlinkParameterIds.Contains(pid))
|
||||
values[pi] = eyeBlinkValue > 0f ? values[pi] * eyeBlinkValue : values[pi];
|
||||
if (_lipSyncParameterIds.Contains(pid))
|
||||
values[pi] += lipSyncValue;
|
||||
}
|
||||
|
||||
// Pass 4 — part opacity curves
|
||||
var partOpacities = model.GetPartOpacities();
|
||||
var partIdsPtr = model.GetPartIds();
|
||||
int partCount = model.GetPartCount();
|
||||
foreach (var curve in _partOpacityCurves)
|
||||
{
|
||||
var val = EvaluateCurve(curve, timeOffset, fadeWeight);
|
||||
for (int pi = 0; pi < partCount; pi++)
|
||||
{
|
||||
var pid = CubismNativeModel.ReadStringFromPtrArray(partIdsPtr, pi);
|
||||
if (pid == curve.Id)
|
||||
{
|
||||
partOpacities[pi] = val;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// End-of-motion
|
||||
if (!_isLoop && timeOffset >= duration)
|
||||
{
|
||||
entry.IsFinished = true;
|
||||
OnFinishedMotion?.Invoke(this);
|
||||
}
|
||||
else if (_isLoop && timeOffset >= duration)
|
||||
{
|
||||
entry.StartTime = userTimeSeconds - (timeOffset - duration);
|
||||
if (!_isLoopFadeIn)
|
||||
entry.FadeInStartTime = userTimeSeconds;
|
||||
}
|
||||
}
|
||||
|
||||
private float EvaluateCurve(MotionCurveData curve, float time, float weight)
|
||||
{
|
||||
var segIdx = FindSegmentIndex(curve, time);
|
||||
if (segIdx < 0 || segIdx >= curve.SegmentCount) return 0f;
|
||||
return EvaluateSegment(curve, segIdx, time);
|
||||
}
|
||||
|
||||
private int FindSegmentIndex(MotionCurveData curve, float time)
|
||||
{
|
||||
for (int i = 0; i < curve.SegmentCount; i++)
|
||||
{
|
||||
var nextIdx = curve.BaseSegmentIndex + i + 1;
|
||||
if (nextIdx < curve.Points.Length)
|
||||
{
|
||||
var nextPoint = curve.Points[nextIdx];
|
||||
if (time < nextPoint.Time)
|
||||
return i;
|
||||
}
|
||||
}
|
||||
var lastPt = curve.Points[curve.BaseSegmentIndex + curve.SegmentCount];
|
||||
if (time <= lastPt.Time)
|
||||
return curve.SegmentCount - 1;
|
||||
return -1;
|
||||
}
|
||||
|
||||
private float EvaluateSegment(MotionCurveData curve, int segIndex, float time)
|
||||
{
|
||||
var basePtIdx = curve.BaseSegmentIndex + segIndex;
|
||||
var p0 = curve.Points[basePtIdx];
|
||||
var p1 = curve.Points[basePtIdx + 1];
|
||||
|
||||
var segType = curve.SegmentTypes != null && segIndex < curve.SegmentTypes.Length
|
||||
? curve.SegmentTypes[segIndex] : CubismMotionSegmentType.Linear;
|
||||
|
||||
switch (segType)
|
||||
{
|
||||
case CubismMotionSegmentType.Linear:
|
||||
return CubismMath.LinearEvaluation(ref p0, ref p1, time);
|
||||
|
||||
case CubismMotionSegmentType.Stepped:
|
||||
return p0.Value;
|
||||
|
||||
case CubismMotionSegmentType.InverseStepped:
|
||||
return p1.Value;
|
||||
|
||||
case CubismMotionSegmentType.Bezier:
|
||||
var cp1 = curve.Points[basePtIdx + 1];
|
||||
var cp2 = curve.Points[basePtIdx + 2];
|
||||
var cp3 = curve.Points[basePtIdx + 3];
|
||||
|
||||
if (_areBeziersRestricted)
|
||||
return CubismMath.BezierEvaluateBinarySearch(
|
||||
p0.Time, cp1.Time, cp2.Time, cp3.Time,
|
||||
p0.Value, cp1.Value, cp2.Value, cp3.Value, time);
|
||||
else
|
||||
{
|
||||
var t = CubismMath.CardanoAlgorithmForBezier(
|
||||
p0.Time, cp1.Time, cp2.Time, cp3.Time, time);
|
||||
t = Math.Clamp(t, 0f, 1f);
|
||||
var t1 = 1f - t;
|
||||
var t2 = t * t;
|
||||
var t12 = t1 * t1;
|
||||
return t12 * t1 * p0.Value + 3f * t12 * t * cp1.Value +
|
||||
3f * t1 * t2 * cp2.Value + t2 * t * cp3.Value;
|
||||
}
|
||||
|
||||
default:
|
||||
return CubismMath.LinearEvaluation(ref p0, ref p1, time);
|
||||
}
|
||||
}
|
||||
|
||||
private static unsafe void ClampParameter(int index, float* values, float* min, float* max)
|
||||
{
|
||||
if (values[index] < min[index]) values[index] = min[index];
|
||||
if (values[index] > max[index]) values[index] = max[index];
|
||||
}
|
||||
|
||||
private void ParseCurves()
|
||||
{
|
||||
var segsFlat = new float[_motionJson.GetTotalSegmentCount() * 2];
|
||||
var segTypesFlat = new CubismMotionSegmentType[_motionJson.GetTotalSegmentCount()];
|
||||
var allPoints = new List<CubismMotionPoint>();
|
||||
int segOffset = 0;
|
||||
|
||||
for (int ci = 0; ci < _motionJson.GetCurveCount(); ci++)
|
||||
{
|
||||
var target = _motionJson.GetCurveTarget(ci);
|
||||
var id = _motionJson.GetCurveId(ci);
|
||||
var fadeIn = _motionJson.GetCurveFadeInTime(ci);
|
||||
var fadeOut = _motionJson.GetCurveFadeOutTime(ci);
|
||||
_motionJson.GetCurveSegments(ci, out var rawSegments);
|
||||
|
||||
var segmentCount = ParseSegments(rawSegments, segsFlat, segTypesFlat, segOffset, allPoints);
|
||||
var curveTarget = ParseTarget(target);
|
||||
|
||||
var curveData = new MotionCurveData
|
||||
{
|
||||
Type = curveTarget,
|
||||
Id = id,
|
||||
SegmentCount = segmentCount,
|
||||
BaseSegmentIndex = segOffset,
|
||||
ParamFadeIn = fadeIn,
|
||||
ParamFadeOut = fadeOut,
|
||||
Points = allPoints.ToArray(),
|
||||
SegmentTypes = segTypesFlat
|
||||
};
|
||||
|
||||
segOffset += segmentCount;
|
||||
|
||||
switch (curveTarget)
|
||||
{
|
||||
case CubismMotionCurveTarget.Model:
|
||||
_modelCurves.Add(curveData); break;
|
||||
case CubismMotionCurveTarget.Parameter:
|
||||
_parameterCurves.Add(curveData); break;
|
||||
case CubismMotionCurveTarget.PartOpacity:
|
||||
_partOpacityCurves.Add(curveData); break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private int ParseSegments(float[] raw, float[] segsFlat, CubismMotionSegmentType[] segTypesFlat,
|
||||
int segOffset, List<CubismMotionPoint> points)
|
||||
{
|
||||
int count = 0;
|
||||
int i = 0;
|
||||
while (i < raw.Length)
|
||||
{
|
||||
var segType = (CubismMotionSegmentType)(int)raw[i++];
|
||||
|
||||
if (count == 0)
|
||||
{
|
||||
// First "segment" is actually the starting point
|
||||
points.Add(new CubismMotionPoint { Time = raw[i++], Value = raw[i++] });
|
||||
continue;
|
||||
}
|
||||
|
||||
segTypesFlat[segOffset + count - 1] = segType;
|
||||
|
||||
switch (segType)
|
||||
{
|
||||
case CubismMotionSegmentType.Linear:
|
||||
case CubismMotionSegmentType.Stepped:
|
||||
case CubismMotionSegmentType.InverseStepped:
|
||||
points.Add(new CubismMotionPoint { Time = raw[i++], Value = raw[i++] });
|
||||
break;
|
||||
|
||||
case CubismMotionSegmentType.Bezier:
|
||||
points.Add(new CubismMotionPoint { Time = raw[i++], Value = raw[i++] });
|
||||
points.Add(new CubismMotionPoint { Time = raw[i++], Value = raw[i++] });
|
||||
points.Add(new CubismMotionPoint { Time = raw[i++], Value = raw[i++] });
|
||||
break;
|
||||
}
|
||||
|
||||
count++;
|
||||
}
|
||||
|
||||
return count - 1;
|
||||
}
|
||||
|
||||
private static CubismMotionCurveTarget ParseTarget(string target) => target switch
|
||||
{
|
||||
"Model" => CubismMotionCurveTarget.Model,
|
||||
"Parameter" => CubismMotionCurveTarget.Parameter,
|
||||
"PartOpacity" => CubismMotionCurveTarget.PartOpacity,
|
||||
_ => CubismMotionCurveTarget.Parameter
|
||||
};
|
||||
|
||||
private void ParseEvents()
|
||||
{
|
||||
for (int i = 0; i < _motionJson.GetEventCount(); i++)
|
||||
{
|
||||
_events.Add(new CubismMotionEvent
|
||||
{
|
||||
FireTime = _motionJson.GetEventTime(i),
|
||||
Value = _motionJson.GetEventValue(i)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal data for a single parsed motion curve.
|
||||
/// Holds the curve type, target ID, segment layout, points array,
|
||||
/// and optional per-curve fade-in/fade-out durations.
|
||||
/// </summary>
|
||||
internal class MotionCurveData
|
||||
{
|
||||
public CubismMotionCurveTarget Type;
|
||||
public string Id;
|
||||
public int SegmentCount;
|
||||
public int BaseSegmentIndex;
|
||||
public float ParamFadeIn;
|
||||
public float ParamFadeOut;
|
||||
public CubismMotionPoint[] Points;
|
||||
public CubismMotionSegmentType[] SegmentTypes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Cthangover.Live2D.Cubism.Framework
|
||||
{
|
||||
/// <summary>
|
||||
/// Specifies which part of the model a motion curve affects.
|
||||
/// </summary>
|
||||
public enum CubismMotionCurveTarget { Model, Parameter, PartOpacity }
|
||||
|
||||
/// <summary>
|
||||
/// Segment interpolation type within a motion curve.
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="Linear"/> — straight line between two points</item>
|
||||
/// <item><see cref="Bezier"/> — cubic bezier (4 control points per segment)</item>
|
||||
/// <item><see cref="Stepped"/> — hold previous value until the segment ends</item>
|
||||
/// <item><see cref="InverseStepped"/> — jump to next value at segment start</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public enum CubismMotionSegmentType { Linear = 0, Bezier = 1, Stepped = 2, InverseStepped = 3 }
|
||||
|
||||
/// <summary>
|
||||
/// A time-value pair representing a keyframe or control point on a motion curve.
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct CubismMotionPoint
|
||||
{
|
||||
/// <summary>Time in seconds from the start of the motion.</summary>
|
||||
public float Time;
|
||||
|
||||
/// <summary>Value at this point. For bezier control points, this is an intermediate value.</summary>
|
||||
public float Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes a single curve within a motion. Contains the target type,
|
||||
/// parameter/part ID, segment count, offset into the flat segment data,
|
||||
/// and per-curve fade timing.
|
||||
/// </summary>
|
||||
public struct CubismMotionCurve
|
||||
{
|
||||
public CubismMotionCurveTarget Type;
|
||||
public string Id;
|
||||
public int SegmentCount;
|
||||
public int BaseSegmentIndex;
|
||||
public float FadeInTime;
|
||||
public float FadeOutTime;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A named event that fires at a specific time during motion playback.
|
||||
/// Events are defined in the UserData section of the .motion3.json.
|
||||
/// Accessed via <see cref="CubismMotion.GetFiredEventValue"/>.
|
||||
/// </summary>
|
||||
public struct CubismMotionEvent
|
||||
{
|
||||
/// <summary>Time offset in seconds when this event triggers.</summary>
|
||||
public float FireTime;
|
||||
|
||||
/// <summary>String value associated with the event.</summary>
|
||||
public string Value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Cthangover.Live2D.Cubism.Framework
|
||||
{
|
||||
/// <summary>
|
||||
/// JSON parser for the Cubism .motion3.json format.
|
||||
/// Provides typed access to the Meta section (duration, loop, fps, fade times,
|
||||
/// curve statistics, beziers-restricted flag) and the Curves/UserData sections.
|
||||
///
|
||||
/// Parsed curve segments are returned as flat float arrays (interleaved
|
||||
/// segment-type markers and point data), decoded by <see cref="CubismMotion.ParseSegments"/>.
|
||||
/// </summary>
|
||||
public class CubismMotionJson
|
||||
{
|
||||
private readonly JsonDocument _json;
|
||||
private readonly JsonElement _meta;
|
||||
private readonly JsonElement _curves;
|
||||
private readonly JsonElement _userData;
|
||||
private readonly bool _hasUserData;
|
||||
private readonly bool _isLoop;
|
||||
private readonly float _duration;
|
||||
private readonly float _fps;
|
||||
private readonly int _curveCount;
|
||||
private readonly int _totalSegmentCount;
|
||||
private readonly int _totalPointCount;
|
||||
private readonly bool _areBeziersRestricted;
|
||||
|
||||
/// <summary>
|
||||
/// Parses the given .motion3.json bytes and pre-extracts all Meta fields
|
||||
/// into readonly fields for fast repeated access.
|
||||
/// </summary>
|
||||
public CubismMotionJson(byte[] jsonBytes)
|
||||
{
|
||||
_json = JsonDocument.Parse(jsonBytes);
|
||||
var root = _json.RootElement;
|
||||
_meta = root.GetProperty("Meta");
|
||||
_curves = root.GetProperty("Curves");
|
||||
_hasUserData = root.TryGetProperty("UserData", out _userData);
|
||||
|
||||
_duration = _meta.GetProperty("Duration").GetSingle();
|
||||
_isLoop = _meta.TryGetProperty("Loop", out var loop) && loop.GetBoolean();
|
||||
_fps = _meta.TryGetProperty("Fps", out var fpsEl) ? fpsEl.GetSingle() : 30f;
|
||||
_curveCount = _meta.TryGetProperty("CurveCount", out var ccEl) ? ccEl.GetInt32() : 0;
|
||||
_totalSegmentCount = _meta.TryGetProperty("TotalSegmentCount", out var tscEl) ? tscEl.GetInt32() : 0;
|
||||
_totalPointCount = _meta.TryGetProperty("TotalPointCount", out var tpcEl) ? tpcEl.GetInt32() : 0;
|
||||
_areBeziersRestricted = _meta.TryGetProperty("AreBeziersRestricted", out var abrEl) && abrEl.GetBoolean();
|
||||
}
|
||||
|
||||
/// <summary>Total motion duration in seconds from the Meta section.</summary>
|
||||
public float GetDuration() => _duration;
|
||||
|
||||
/// <summary>Whether the motion loops.</summary>
|
||||
public bool IsLoop() => _isLoop;
|
||||
|
||||
/// <summary>Frames per second, default 30.</summary>
|
||||
public float GetFps() => _fps;
|
||||
|
||||
/// <summary>Number of curves in this motion.</summary>
|
||||
public int GetCurveCount() => _curveCount;
|
||||
|
||||
/// <summary>Total segment count across all curves.</summary>
|
||||
public int GetTotalSegmentCount() => _totalSegmentCount;
|
||||
|
||||
/// <summary>Total point count across all curves.</summary>
|
||||
public int GetTotalPointCount() => _totalPointCount;
|
||||
|
||||
/// <summary>
|
||||
/// If true, bezier curves use binary search instead of the Cardano formula
|
||||
/// (non-standard bezier parameterization).
|
||||
/// </summary>
|
||||
public bool AreBeziersRestricted() => _areBeziersRestricted;
|
||||
|
||||
/// <summary>
|
||||
/// Motion-level fade-in time in seconds, or -1 if not specified.
|
||||
/// </summary>
|
||||
public float GetFadeInTime() =>
|
||||
_meta.TryGetProperty("FadeInTime", out var v) ? v.GetSingle() : -1f;
|
||||
|
||||
/// <summary>
|
||||
/// Motion-level fade-out time in seconds, or -1 if not specified.
|
||||
/// </summary>
|
||||
public float GetFadeOutTime() =>
|
||||
_meta.TryGetProperty("FadeOutTime", out var v) ? v.GetSingle() : -1f;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the target string for the curve at the given index
|
||||
/// ("Model", "Parameter", or "PartOpacity").
|
||||
/// </summary>
|
||||
public string GetCurveTarget(int index) =>
|
||||
_curves[index].GetProperty("Target").GetString();
|
||||
|
||||
/// <summary>
|
||||
/// Returns the parameter/part/model ID string for the curve at the given index.
|
||||
/// </summary>
|
||||
public string GetCurveId(int index) =>
|
||||
_curves[index].GetProperty("Id").GetString();
|
||||
|
||||
/// <summary>
|
||||
/// Per-curve fade-in time in seconds, or -1 if not specified.
|
||||
/// </summary>
|
||||
public float GetCurveFadeInTime(int index) =>
|
||||
_curves[index].TryGetProperty("FadeInTime", out var v) ? v.GetSingle() : -1f;
|
||||
|
||||
/// <summary>
|
||||
/// Per-curve fade-out time in seconds, or -1 if not specified.
|
||||
/// </summary>
|
||||
public float GetCurveFadeOutTime(int index) =>
|
||||
_curves[index].TryGetProperty("FadeOutTime", out var v) ? v.GetSingle() : -1f;
|
||||
|
||||
/// <summary>
|
||||
/// Reads the raw float array of segment data for a curve.
|
||||
/// The first element is the segment type marker (cast to int),
|
||||
/// followed by point data depending on the segment type.
|
||||
/// </summary>
|
||||
public void GetCurveSegments(int index, out float[] segments)
|
||||
{
|
||||
var segArr = _curves[index].GetProperty("Segments");
|
||||
segments = new float[segArr.GetArrayLength()];
|
||||
int si = 0;
|
||||
foreach (var s in segArr.EnumerateArray())
|
||||
segments[si++] = s.GetSingle();
|
||||
}
|
||||
|
||||
/// <summary>Number of user-data events in the motion.</summary>
|
||||
public int GetEventCount() => _hasUserData ? _userData.GetArrayLength() : 0;
|
||||
|
||||
/// <summary>Fire time of the event at the given index, in seconds.</summary>
|
||||
public float GetEventTime(int index) =>
|
||||
_hasUserData && index < _userData.GetArrayLength()
|
||||
? _userData[index].GetProperty("Time").GetSingle() : 0f;
|
||||
|
||||
/// <summary>String value of the event at the given index.</summary>
|
||||
public string GetEventValue(int index) =>
|
||||
_hasUserData && index < _userData.GetArrayLength()
|
||||
? _userData[index].GetProperty("Value").GetString() : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
namespace Cthangover.Live2D.Cubism.Framework
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents one enqueued motion in the playback queue.
|
||||
/// Holds timing data (start time, end time, fade-in start time),
|
||||
/// lifecycle flags (available, finished, auto-delete), and a
|
||||
/// reference to the motion itself.
|
||||
///
|
||||
/// Created by <see cref="CubismMotionQueueManager.StartMotion"/>
|
||||
/// and consumed frame-by-frame in <see cref="CubismMotionQueueManager.DoUpdateMotion"/>.
|
||||
/// </summary>
|
||||
public class CubismMotionQueueEntry
|
||||
{
|
||||
/// <summary>Absolute user time when motion playback begins.</summary>
|
||||
public float StartTime;
|
||||
|
||||
/// <summary>
|
||||
/// Absolute user time when playback ends. -1 for infinite (looping) motions.
|
||||
/// </summary>
|
||||
public float EndTime;
|
||||
|
||||
/// <summary>Absolute user time when fade-in begins (may differ from StartTime for offset motions).</summary>
|
||||
public float FadeInStartTime;
|
||||
|
||||
/// <summary>Whether this entry should be processed during updates.</summary>
|
||||
public bool IsAvailable;
|
||||
|
||||
/// <summary>Flag set when playback completes. Checked by the queue manager during cleanup.</summary>
|
||||
public bool IsFinished;
|
||||
|
||||
/// <summary>The motion instance driving parameter updates.</summary>
|
||||
public ACubismMotion Motion;
|
||||
|
||||
/// <summary>If true, the entry is automatically removed from the queue when finished.</summary>
|
||||
public bool AutoDelete;
|
||||
|
||||
/// <summary>Arbitrary user data attached to this queue entry.</summary>
|
||||
public object CustomData;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new queue entry for the given motion.
|
||||
/// AutoDelete controls whether the entry is removed on finish.
|
||||
/// </summary>
|
||||
public CubismMotionQueueEntry(ACubismMotion motion, bool autoDelete)
|
||||
{
|
||||
Motion = motion;
|
||||
AutoDelete = autoDelete;
|
||||
IsAvailable = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manages a queue of <see cref="ACubismMotion"/> instances for a single model.
|
||||
/// Supports priority reservation (higher-priority motions preempt lower ones),
|
||||
/// concurrent playback of multiple motions, and automatic cleanup of finished entries.
|
||||
///
|
||||
/// Two instances are used by <see cref="CubismModelNode"/>:
|
||||
/// one for character motions (animations) and one for expressions.
|
||||
/// Each is updated independently in the per-frame loop, allowing motions
|
||||
/// and expressions to blend independently with their own fade weights.
|
||||
///
|
||||
/// The update loop iterates entries in reverse for safe removal during
|
||||
/// traversal. Finished auto-delete entries are removed immediately;
|
||||
/// non-auto-delete entries remain in the queue with their IsFinished flag set.
|
||||
/// </summary>
|
||||
public class CubismMotionQueueManager
|
||||
{
|
||||
private readonly System.Collections.Generic.List<CubismMotionQueueEntry> _entries = new();
|
||||
private int _reservedPriority = -1;
|
||||
|
||||
/// <summary>
|
||||
/// Sets the reserved priority threshold.
|
||||
/// Motions with priority below this are rejected by <see cref="ReserveMotion"/>.
|
||||
/// </summary>
|
||||
public void SetReservePriority(int priority) => _reservedPriority = priority;
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to reserve the given priority level.
|
||||
/// Returns true if the priority is accepted (higher than current reserved),
|
||||
/// false if a higher-priority motion is already reserved.
|
||||
/// </summary>
|
||||
public bool ReserveMotion(int priority)
|
||||
{
|
||||
if (_reservedPriority >= 0 && _reservedPriority > priority)
|
||||
return false;
|
||||
|
||||
_reservedPriority = priority;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates and enqueues a motion for playback.
|
||||
/// Instantiates a <see cref="CubismMotionQueueEntry"/>, calls
|
||||
/// <see cref="ACubismMotion.SetupMotionQueueEntry"/> on it, and
|
||||
/// adds it to the queue. Returns the entry for optional tracking.
|
||||
/// </summary>
|
||||
/// <param name="motion">The motion to play.</param>
|
||||
/// <param name="autoDelete">Whether to auto-remove on finish.</param>
|
||||
/// <param name="userTimeSeconds">Current user time for start time calculation.</param>
|
||||
public CubismMotionQueueEntry StartMotion(ACubismMotion motion, bool autoDelete, float userTimeSeconds)
|
||||
{
|
||||
if (motion == null)
|
||||
return null;
|
||||
|
||||
var entry = new CubismMotionQueueEntry(motion, autoDelete);
|
||||
motion.SetupMotionQueueEntry(entry, userTimeSeconds);
|
||||
_entries.Add(entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops all motions and clears the queue. Resets priority reservation.
|
||||
/// </summary>
|
||||
public void StopAllMotions()
|
||||
{
|
||||
_entries.Clear();
|
||||
_reservedPriority = -1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if all entries in the queue are finished.
|
||||
/// </summary>
|
||||
public bool IsFinished()
|
||||
{
|
||||
foreach (var entry in _entries)
|
||||
if (!entry.IsFinished)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-frame update for all queued motions.
|
||||
/// Iterates in reverse for safe removal. For each active entry:
|
||||
/// computes fade weight, invokes <see cref="ACubismMotion.DoUpdateParameters"/>,
|
||||
/// and checks for completion. Finished auto-delete entries are removed.
|
||||
/// </summary>
|
||||
public void DoUpdateMotion(CubismNativeModel model, float userTimeSeconds)
|
||||
{
|
||||
for (int i = _entries.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var entry = _entries[i];
|
||||
if (!entry.IsAvailable || entry.IsFinished)
|
||||
{
|
||||
if (entry.AutoDelete)
|
||||
_entries.RemoveAt(i);
|
||||
continue;
|
||||
}
|
||||
|
||||
var weight = entry.Motion.UpdateFadeWeight(entry, userTimeSeconds);
|
||||
entry.Motion.DoUpdateParameters(model, userTimeSeconds, weight, entry);
|
||||
|
||||
if (entry.Motion.GetDuration() > 0f)
|
||||
{
|
||||
if (userTimeSeconds >= entry.EndTime && !entry.Motion.IsLoop)
|
||||
{
|
||||
entry.IsFinished = true;
|
||||
entry.Motion.OnFinishedMotion?.Invoke(entry.Motion);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.Json;
|
||||
using Cthangover.Core.Utils;
|
||||
using Godot;
|
||||
using Cthangover.Live2D.Cubism.Core;
|
||||
using Cthangover.Core.Mods;
|
||||
|
||||
namespace Cthangover.Live2D.Cubism.Framework
|
||||
{
|
||||
/// <summary>
|
||||
/// Wraps a Live2D CubismCore native model instance.
|
||||
/// Handles .moc3 loading with the required 64-byte alignment, exposes
|
||||
/// model data through direct pointers into CubismCore arrays, and manages
|
||||
/// texture loading from external file providers.
|
||||
///
|
||||
/// Memory layout: .moc3 data and model instance memory are pinned via
|
||||
/// GCHandle to satisfy CubismCore's alignment requirements (64-byte and
|
||||
/// 16-byte respectively). The native model handle (_model) is an opaque
|
||||
/// pointer passed to all CubismCore P/Invoke calls.
|
||||
///
|
||||
/// Implements <see cref="IDisposable"/> to free pinned memory when the
|
||||
/// model is no longer needed.
|
||||
/// </summary>
|
||||
public class CubismNativeModel : IDisposable
|
||||
{
|
||||
private IntPtr _model;
|
||||
private GCHandle _mocAlignedHandle;
|
||||
private GCHandle _modelAlignedHandle;
|
||||
|
||||
private int _drawableCount;
|
||||
private int _parameterCount;
|
||||
|
||||
/// <summary>
|
||||
/// Array of Godot <see cref="ImageTexture"/> instances loaded from the
|
||||
/// model's texture references. Indexed by drawable texture index.
|
||||
/// </summary>
|
||||
public ImageTexture[] Textures { get; private set; }
|
||||
|
||||
/// <summary>Number of drawables (renderable mesh parts) in the model.</summary>
|
||||
public int DrawableCount => _drawableCount;
|
||||
|
||||
/// <summary>Number of parameters in the model.</summary>
|
||||
public int ParameterCount => _parameterCount;
|
||||
|
||||
/// <summary>Canvas pixel width from the moc3. Available after <see cref="LoadMoc"/>.</summary>
|
||||
public float CanvasWidth { get; private set; }
|
||||
|
||||
/// <summary>Canvas pixel height from the moc3. Available after <see cref="LoadMoc"/>.</summary>
|
||||
public float CanvasHeight { get; private set; }
|
||||
|
||||
/// <summary>Pixels-per-unit scale for converting Cubism coordinates to pixel space.</summary>
|
||||
public float PixelsPerUnit { get; private set; }
|
||||
|
||||
/// <summary>Whether the native model has been successfully initialized.</summary>
|
||||
public bool IsLoaded => _model != IntPtr.Zero;
|
||||
|
||||
/// <summary>Raw native model handle passed to CubismCore functions.</summary>
|
||||
public IntPtr ModelPtr => _model;
|
||||
|
||||
/// <summary>
|
||||
/// Loads raw .moc3 bytes into CubismCore.
|
||||
/// Allocates a 64-byte aligned buffer, copies moc data, revives the moc,
|
||||
/// allocates a 16-byte aligned model buffer, and initializes the model.
|
||||
/// Throws <see cref="InvalidOperationException"/> on failure.
|
||||
/// </summary>
|
||||
public unsafe void LoadMoc(byte[] mocBytes)
|
||||
{
|
||||
int alignedMocSize = (mocBytes.Length + CubismCoreBindings.AlignofMoc - 1)
|
||||
/ CubismCoreBindings.AlignofMoc * CubismCoreBindings.AlignofMoc;
|
||||
byte[] mocBuffer = new byte[alignedMocSize + CubismCoreBindings.AlignofMoc];
|
||||
_mocAlignedHandle = GCHandle.Alloc(mocBuffer, GCHandleType.Pinned);
|
||||
IntPtr mocPtr = _mocAlignedHandle.AddrOfPinnedObject();
|
||||
long alignedAddr = (mocPtr.ToInt64() + CubismCoreBindings.AlignofMoc - 1)
|
||||
/ CubismCoreBindings.AlignofMoc * CubismCoreBindings.AlignofMoc;
|
||||
var alignedMocPtr = new IntPtr(alignedAddr);
|
||||
Marshal.Copy(mocBytes, 0, alignedMocPtr, mocBytes.Length);
|
||||
|
||||
IntPtr moc = CubismCoreBindings.csmReviveMocInPlace(
|
||||
(void*)alignedMocPtr, (uint)mocBytes.Length);
|
||||
if (moc == IntPtr.Zero)
|
||||
{
|
||||
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");
|
||||
}
|
||||
|
||||
var sizeInPixels = default(CubismCoreBindings.csmVector2);
|
||||
var originInPixels = default(CubismCoreBindings.csmVector2);
|
||||
float ppu = 0;
|
||||
CubismCoreBindings.csmReadCanvasInfo(_model, &sizeInPixels, &originInPixels, &ppu);
|
||||
CanvasWidth = sizeInPixels.X;
|
||||
CanvasHeight = sizeInPixels.Y;
|
||||
PixelsPerUnit = ppu;
|
||||
|
||||
_drawableCount = CubismCoreBindings.csmGetDrawableCount(_model);
|
||||
_parameterCount = CubismCoreBindings.csmGetParameterCount(_model);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads a model from .model3.json bytes.
|
||||
/// Parses the FileReferences section to find the .moc3 file,
|
||||
/// calls <see cref="LoadMoc"/>, then loads all referenced textures
|
||||
/// through the given file provider.
|
||||
/// </summary>
|
||||
public void LoadFromProvider(byte[] model3JsonBytes, string modelDir, IModFileProvider provider)
|
||||
{
|
||||
var json = JsonDocument.Parse(model3JsonBytes);
|
||||
var fileRefs = json.RootElement.GetProperty("FileReferences");
|
||||
|
||||
string mocRelPath = fileRefs.GetProperty("Moc").GetString();
|
||||
var mocBytes = ReadFile(modelDir, mocRelPath, provider);
|
||||
if (mocBytes == null)
|
||||
{
|
||||
GameLogger.Log("LIVE2D", $"LoadFromProvider: .moc3 not found '{mocRelPath}'", LogLevel.Error);
|
||||
return;
|
||||
}
|
||||
LoadMoc(mocBytes);
|
||||
|
||||
var texturePaths = new List<string>();
|
||||
if (fileRefs.TryGetProperty("Textures", out var texArr))
|
||||
{
|
||||
foreach (var t in texArr.EnumerateArray())
|
||||
texturePaths.Add(t.GetString());
|
||||
}
|
||||
LoadTextures(texturePaths, modelDir, provider);
|
||||
}
|
||||
|
||||
private static byte[] ReadFile(string baseDir, string relativePath, IModFileProvider provider)
|
||||
{
|
||||
var normalized = relativePath.Replace('\\', '/');
|
||||
var combined = string.IsNullOrEmpty(baseDir) ? normalized : $"{baseDir}/{normalized}";
|
||||
var result = provider.ReadFileBinary(combined);
|
||||
if (result == null)
|
||||
GameLogger.Log("LIVE2D", $"ReadFile: provider returned null for '{combined}'", LogLevel.Warning);
|
||||
return result;
|
||||
}
|
||||
|
||||
private void LoadTextures(List<string> paths, string modelDir, IModFileProvider provider)
|
||||
{
|
||||
Textures = new ImageTexture[paths.Count];
|
||||
for (int i = 0; i < paths.Count; i++)
|
||||
{
|
||||
var texBytes = ReadFile(modelDir, paths[i], provider);
|
||||
if (texBytes == null || texBytes.Length == 0)
|
||||
{
|
||||
GameLogger.Log("LIVE2D", $"LoadTextures: failed to read texture '{paths[i]}'", LogLevel.Warning);
|
||||
continue;
|
||||
}
|
||||
var img = new Image();
|
||||
Error err;
|
||||
if (texBytes[0] == 0x89 && texBytes[1] == 0x50)
|
||||
err = img.LoadPngFromBuffer(texBytes);
|
||||
else
|
||||
err = img.LoadJpgFromBuffer(texBytes);
|
||||
|
||||
if (err != Error.Ok)
|
||||
{
|
||||
GameLogger.Log("LIVE2D", $"LoadTextures: failed to decode '{paths[i]}' — error={err}", LogLevel.Warning);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (img.GetFormat() != Image.Format.Rgba8)
|
||||
img.Convert(Image.Format.Rgba8);
|
||||
|
||||
img.GenerateMipmaps();
|
||||
|
||||
var tex = ImageTexture.CreateFromImage(img);
|
||||
Textures[i] = tex;
|
||||
var w = img.GetWidth();
|
||||
var h = img.GetHeight();
|
||||
img.Dispose();
|
||||
GameLogger.Log("LIVE2D", $"LoadTextures: loaded '{paths[i]}' ({texBytes.Length}b, {w}x{h})");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calls <c>csmUpdateModel</c> on the native model.
|
||||
/// Must be called after modifying parameters/parts/opacities and before
|
||||
/// reading vertex positions for rendering.
|
||||
/// </summary>
|
||||
public unsafe void UpdateModel()
|
||||
{
|
||||
if (_model == IntPtr.Zero) return;
|
||||
CubismCoreBindings.csmUpdateModel(_model);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the per-frame dynamic flags for all drawables.
|
||||
/// Called at the start of each render update cycle.
|
||||
/// </summary>
|
||||
public unsafe void ResetDynamicFlags()
|
||||
{
|
||||
if (_model == IntPtr.Zero) return;
|
||||
CubismCoreBindings.csmResetDrawableDynamicFlags(_model);
|
||||
}
|
||||
|
||||
/// <summary>Returns the drawable count from the native model.</summary>
|
||||
public unsafe int GetDrawableCount() => _drawableCount;
|
||||
|
||||
/// <summary>Returns a pointer to the dynamic flags byte array (one byte per drawable).</summary>
|
||||
public unsafe byte* GetDynamicFlags() =>
|
||||
CubismCoreBindings.csmGetDrawableDynamicFlags(_model);
|
||||
|
||||
/// <summary>Returns a pointer to the constant flags byte array (blend mode, double-sided, etc.).</summary>
|
||||
public unsafe byte* GetConstantFlags() =>
|
||||
CubismCoreBindings.csmGetDrawableConstantFlags(_model);
|
||||
|
||||
/// <summary>Returns a pointer to the texture index array (one int per drawable).</summary>
|
||||
public unsafe int* GetTextureIndices() =>
|
||||
CubismCoreBindings.csmGetDrawableTextureIndices(_model);
|
||||
|
||||
/// <summary>Returns a pointer to the vertex count array (one int per drawable).</summary>
|
||||
public unsafe int* GetVertexCounts() =>
|
||||
CubismCoreBindings.csmGetDrawableVertexCounts(_model);
|
||||
|
||||
/// <summary>Returns a pointer array of csmVector2 arrays — vertex positions per drawable.</summary>
|
||||
public unsafe CubismCoreBindings.csmVector2** GetVertexPositions() =>
|
||||
CubismCoreBindings.csmGetDrawableVertexPositions(_model);
|
||||
|
||||
/// <summary>Returns a pointer array of csmVector2 arrays — UV coordinates per drawable.</summary>
|
||||
public unsafe CubismCoreBindings.csmVector2** GetVertexUvs() =>
|
||||
CubismCoreBindings.csmGetDrawableVertexUvs(_model);
|
||||
|
||||
/// <summary>Returns a pointer to the index count array (one int per drawable).</summary>
|
||||
public unsafe int* GetIndexCounts() =>
|
||||
CubismCoreBindings.csmGetDrawableIndexCounts(_model);
|
||||
|
||||
/// <summary>Returns a pointer array of ushort arrays — triangle indices per drawable.</summary>
|
||||
public unsafe ushort** GetIndices() =>
|
||||
CubismCoreBindings.csmGetDrawableIndices(_model);
|
||||
|
||||
/// <summary>Returns a pointer to the per-drawable opacity array (one float per drawable).</summary>
|
||||
public unsafe float* GetOpacities() =>
|
||||
CubismCoreBindings.csmGetDrawableOpacities(_model);
|
||||
|
||||
/// <summary>Returns a pointer to the draw order indices array.</summary>
|
||||
public unsafe int* GetDrawOrders() =>
|
||||
CubismCoreBindings.csmGetDrawableDrawOrders(_model);
|
||||
|
||||
/// <summary>Returns a pointer to the render order indices array.</summary>
|
||||
public unsafe int* GetRenderOrders() =>
|
||||
CubismCoreBindings.csmGetRenderOrders(_model);
|
||||
|
||||
/// <summary>Returns a pointer to the mask count array (one int per drawable).</summary>
|
||||
public unsafe int* GetMaskCounts() =>
|
||||
CubismCoreBindings.csmGetDrawableMaskCounts(_model);
|
||||
|
||||
/// <summary>Returns a pointer array of int arrays — mask drawable indices per drawable.</summary>
|
||||
public unsafe int** GetMasks() =>
|
||||
CubismCoreBindings.csmGetDrawableMasks(_model);
|
||||
|
||||
/// <summary>Returns a pointer to the per-drawable multiply color array.</summary>
|
||||
public unsafe CubismCoreBindings.csmVector4* GetMultiplyColors() =>
|
||||
CubismCoreBindings.csmGetDrawableMultiplyColors(_model);
|
||||
|
||||
/// <summary>Returns a pointer to the per-drawable screen color array.</summary>
|
||||
public unsafe CubismCoreBindings.csmVector4* GetScreenColors() =>
|
||||
CubismCoreBindings.csmGetDrawableScreenColors(_model);
|
||||
|
||||
/// <summary>Returns a pointer to the current parameter values array (one float per parameter).</summary>
|
||||
public unsafe float* GetParameterValues() =>
|
||||
CubismCoreBindings.csmGetParameterValues(_model);
|
||||
|
||||
/// <summary>Returns a pointer to the parameter minimum bounds array.</summary>
|
||||
public unsafe float* GetParameterMinimumValues() =>
|
||||
CubismCoreBindings.csmGetParameterMinimumValues(_model);
|
||||
|
||||
/// <summary>Returns a pointer to the parameter maximum bounds array.</summary>
|
||||
public unsafe float* GetParameterMaximumValues() =>
|
||||
CubismCoreBindings.csmGetParameterMaximumValues(_model);
|
||||
|
||||
/// <summary>Returns a pointer to the parameter default values array.</summary>
|
||||
public unsafe float* GetParameterDefaultValues() =>
|
||||
CubismCoreBindings.csmGetParameterDefaultValues(_model);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a pointer array of null-terminated ANSI strings — parameter IDs.
|
||||
/// Use <see cref="ReadStringFromPtrArray"/> to read individual strings.
|
||||
/// </summary>
|
||||
public unsafe IntPtr GetParameterIds() =>
|
||||
CubismCoreBindings.csmGetParameterIds(_model);
|
||||
|
||||
/// <summary>
|
||||
/// Reads a null-terminated ANSI string from a pointer array at the given index.
|
||||
/// Each element is a byte pointer; reads until null terminator.
|
||||
/// </summary>
|
||||
public static unsafe string ReadStringFromPtrArray(IntPtr ptrArray, int index)
|
||||
{
|
||||
var strPtr = *(byte**)IntPtr.Add(ptrArray, index * IntPtr.Size);
|
||||
return Marshal.PtrToStringAnsi((IntPtr)strPtr);
|
||||
}
|
||||
|
||||
/// <summary>Returns the number of parts from the native model.</summary>
|
||||
public unsafe int GetPartCount() =>
|
||||
CubismCoreBindings.csmGetPartCount(_model);
|
||||
|
||||
/// <summary>Returns a pointer to the per-part opacity array (one float per part).</summary>
|
||||
public unsafe float* GetPartOpacities() =>
|
||||
CubismCoreBindings.csmGetPartOpacities(_model);
|
||||
|
||||
/// <summary>Returns a pointer array of part ID strings.</summary>
|
||||
public unsafe IntPtr GetPartIds() =>
|
||||
CubismCoreBindings.csmGetPartIds(_model);
|
||||
|
||||
/// <summary>
|
||||
/// Frees pinned memory handles. The native model handle is set to zero;
|
||||
/// the underlying CubismCore memory was allocated inline and lives in
|
||||
/// the pinned buffers — freeing those handles releases all model memory.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_mocAlignedHandle.IsAllocated) _mocAlignedHandle.Free();
|
||||
if (_modelAlignedHandle.IsAllocated) _modelAlignedHandle.Free();
|
||||
_model = IntPtr.Zero;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
namespace Cthangover.Live2D.Cubism.Framework
|
||||
{
|
||||
/// <summary>
|
||||
/// Cubism model parameter.
|
||||
/// Each parameter has a string ID, a numeric index into the native
|
||||
/// parameter arrays, current value, and bounds (min, max, default).
|
||||
///
|
||||
/// Parameters are created during <see cref="CubismModelNode.SetupParameters"/>
|
||||
/// and exposed via <see cref="CubismModelNode.Parameters"/>. External code
|
||||
/// can read/write <see cref="Value"/> and call <see cref="ApplyToModel"/>
|
||||
/// to push changes back to the CubismCore native arrays.
|
||||
/// </summary>
|
||||
public class CubismParameter
|
||||
{
|
||||
public CubismNativeModel Model { get; }
|
||||
|
||||
public CubismParameter(CubismNativeModel model)
|
||||
{
|
||||
Model = model;
|
||||
}
|
||||
|
||||
/// <summary>Cubism parameter ID string (e.g. "ParamAngleX", "ParamMouthOpenY").</summary>
|
||||
public string Id { get; set; }
|
||||
|
||||
/// <summary>Zero-based index into the native parameter arrays.</summary>
|
||||
public int Index { get; set; }
|
||||
|
||||
/// <summary>Current parameter value. Writes to native model when <see cref="ApplyToModel"/> is called.</summary>
|
||||
public float Value { get; set; }
|
||||
|
||||
/// <summary>Minimum allowed value for this parameter.</summary>
|
||||
public float MinimumValue { get; set; }
|
||||
|
||||
/// <summary>Maximum allowed value for this parameter.</summary>
|
||||
public float MaximumValue { get; set; }
|
||||
|
||||
/// <summary>Default (rest) value for this parameter from the moc3.</summary>
|
||||
public float DefaultValue { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Writes <see cref="Value"/> directly to the native model's parameter array.
|
||||
/// No clamping is performed — the caller should enforce bounds if needed.
|
||||
/// </summary>
|
||||
public unsafe void ApplyToModel()
|
||||
{
|
||||
var values = Model.GetParameterValues();
|
||||
values[Index] = Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace Cthangover.Live2D.Cubism.Framework
|
||||
{
|
||||
/// <summary>
|
||||
/// Cubism model part opacity.
|
||||
/// Each part has a string ID, a numeric index into the native part arrays,
|
||||
/// and a current opacity value (0 = hidden, 1 = fully visible).
|
||||
///
|
||||
/// Parts are created during <see cref="CubismModelNode.SetupParts"/>
|
||||
/// and exposed via <see cref="CubismModelNode.PartOpacities"/>.
|
||||
/// External code can set <see cref="Value"/> and call
|
||||
/// <see cref="ApplyToModel"/> to push the change to CubismCore.
|
||||
/// </summary>
|
||||
public class CubismPartOpacity
|
||||
{
|
||||
public CubismNativeModel Model { get; }
|
||||
|
||||
public CubismPartOpacity(CubismNativeModel model)
|
||||
{
|
||||
Model = model;
|
||||
}
|
||||
|
||||
/// <summary>Cubism part ID string (e.g. "PartArmL", "PartHairBack").</summary>
|
||||
public string Id { get; set; }
|
||||
|
||||
/// <summary>Zero-based index into the native part arrays.</summary>
|
||||
public int Index { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Current opacity value for this part. 0 = hidden, 1 = fully visible.
|
||||
/// Written to native model when <see cref="ApplyToModel"/> is called.
|
||||
/// </summary>
|
||||
public float Value { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Writes <see cref="Value"/> directly to the native model's part opacities array.
|
||||
/// </summary>
|
||||
public unsafe void ApplyToModel()
|
||||
{
|
||||
var opacities = Model.GetPartOpacities();
|
||||
opacities[Index] = Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Cthangover.Live2D.Cubism.Framework
|
||||
{
|
||||
/// <summary>
|
||||
/// Cubism physics simulation engine. Drives secondary motion (hair, cloth,
|
||||
/// accessories) using a chain of particles influenced by parameter inputs
|
||||
/// (X, Y, angle from model parameters), gravity, and wind.
|
||||
///
|
||||
/// Architecture:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="CubismPhysicsRig"/> holds the complete rig structure:
|
||||
/// sub-rigs, inputs, outputs, and particles</item>
|
||||
/// <item>Each sub-rig is an independent chain of particles anchored to one
|
||||
/// or more model parameters via inputs</item>
|
||||
/// <item>Inputs map model parameters (normalized and weighted) to particle
|
||||
/// chain root position/angle</item>
|
||||
/// <item>Outputs map particle chain state back to model parameters after
|
||||
/// physics integration</item>
|
||||
/// </list>
|
||||
///
|
||||
/// The simulation runs at the rig's configured FPS (from .physics3.json),
|
||||
/// using sub-frame accumulation when the display delta time is smaller
|
||||
/// than the physics step. Output values are interpolated between steps
|
||||
/// for smooth visual results.
|
||||
/// </summary>
|
||||
public class CubismPhysics
|
||||
{
|
||||
private const float AirResistance = 5f;
|
||||
private const float MaximumWeight = 100f;
|
||||
|
||||
private readonly CubismPhysicsRig _rig = new();
|
||||
private readonly List<float> _currentOutputs = new();
|
||||
private readonly List<float> _previousOutputs = new();
|
||||
private float _currentRemainTime;
|
||||
private float _physicsDeltaTime;
|
||||
|
||||
/// <summary>
|
||||
/// Parses a .physics3.json byte buffer and builds the full physics rig
|
||||
/// with sub-rigs, inputs, outputs, and particles.
|
||||
/// </summary>
|
||||
public CubismPhysics(byte[] physics3JsonBytes)
|
||||
{
|
||||
var json = new CubismPhysicsJson(physics3JsonBytes);
|
||||
|
||||
json.GetGravity(out _rig.GravityX, out _rig.GravityY);
|
||||
json.GetWind(out _rig.WindX, out _rig.WindY);
|
||||
_rig.Fps = json.GetFps();
|
||||
_physicsDeltaTime = 1f / _rig.Fps;
|
||||
|
||||
int inputOffset = 0, outputOffset = 0, particleOffset = 0;
|
||||
for (int si = 0; si < json.GetSubRigCount(); si++)
|
||||
{
|
||||
var subRig = new CubismPhysicsSubRig
|
||||
{
|
||||
InputCount = json.GetInputCount(si),
|
||||
OutputCount = json.GetOutputCount(si),
|
||||
ParticleCount = json.GetVertexCount(si),
|
||||
BaseInputIndex = inputOffset,
|
||||
BaseOutputIndex = outputOffset,
|
||||
BaseParticleIndex = particleOffset
|
||||
};
|
||||
json.GetNormalizationPosition(si, out subRig.NormalizationPosition);
|
||||
json.GetNormalizationAngle(si, out subRig.NormalizationAngle);
|
||||
_rig.SubRigs.Add(subRig);
|
||||
|
||||
for (int ii = 0; ii < subRig.InputCount; ii++)
|
||||
{
|
||||
json.GetInput(si, ii, out var pid, out var w, out var type, out var reflect);
|
||||
_rig.Inputs.Add(new CubismPhysicsInput
|
||||
{
|
||||
ParameterId = pid, Weight = w, Type = type, Reflect = reflect
|
||||
});
|
||||
}
|
||||
|
||||
for (int oi = 0; oi < subRig.OutputCount; oi++)
|
||||
{
|
||||
json.GetOutput(si, oi, out var pid, out var vi, out var sx, out var sy,
|
||||
out var w, out var type, out var reflect);
|
||||
_rig.Outputs.Add(new CubismPhysicsOutput
|
||||
{
|
||||
ParameterId = pid, VertexIndex = vi + particleOffset,
|
||||
TranslationScaleX = sx, TranslationScaleY = sy, Weight = w,
|
||||
Type = type, Reflect = reflect
|
||||
});
|
||||
}
|
||||
|
||||
int totalParticles = particleOffset + subRig.ParticleCount;
|
||||
var newParticles = new CubismPhysicsParticle[totalParticles];
|
||||
if (_rig.Particles.Length > 0)
|
||||
System.Array.Copy(_rig.Particles, newParticles, _rig.Particles.Length);
|
||||
_rig.Particles = newParticles;
|
||||
|
||||
for (int pi = 0; pi < subRig.ParticleCount; pi++)
|
||||
{
|
||||
var pIdx = particleOffset + pi;
|
||||
json.GetParticle(si, pi, out var mob, out var del, out var acc, out var rad,
|
||||
out var px, out var py);
|
||||
_rig.Particles[pIdx] = new CubismPhysicsParticle
|
||||
{
|
||||
InitialX = px, InitialY = py,
|
||||
Mobility = mob, Delay = del, Acceleration = acc, Radius = rad,
|
||||
PositionX = px, PositionY = py,
|
||||
LastPositionX = px, LastPositionY = py
|
||||
};
|
||||
}
|
||||
|
||||
inputOffset += subRig.InputCount;
|
||||
outputOffset += subRig.OutputCount;
|
||||
particleOffset += subRig.ParticleCount;
|
||||
}
|
||||
|
||||
_currentOutputs.Capacity = _rig.Outputs.Count;
|
||||
_previousOutputs.Capacity = _rig.Outputs.Count;
|
||||
for (int i = 0; i < _rig.Outputs.Count; i++)
|
||||
{
|
||||
_currentOutputs.Add(0f);
|
||||
_previousOutputs.Add(0f);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Overrides gravity and wind at runtime.
|
||||
/// </summary>
|
||||
public void SetOptions(float gravityX, float gravityY, float windX, float windY)
|
||||
{
|
||||
_rig.GravityX = gravityX;
|
||||
_rig.GravityY = gravityY;
|
||||
_rig.WindX = windX;
|
||||
_rig.WindY = windY;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forces the physics to stabilize immediately by running one update
|
||||
/// cycle in stabilization mode. Particles snap to their rest positions
|
||||
/// along the chain direction.
|
||||
/// </summary>
|
||||
public void Stabilization(CubismNativeModel model)
|
||||
{
|
||||
UpdatePhysics(model, true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates physics for one display frame.
|
||||
/// Accumulates time and runs physics steps at the rig's native FPS.
|
||||
/// Output values are linearly interpolated between the previous and
|
||||
/// current step for smooth visual results.
|
||||
/// Called from <see cref="CubismModelNode.OnUpdate"/> every frame.
|
||||
/// </summary>
|
||||
public unsafe void Evaluate(CubismNativeModel model, float deltaTimeSeconds)
|
||||
{
|
||||
_currentRemainTime += deltaTimeSeconds;
|
||||
var physicsDeltaCalc = _physicsDeltaTime > 0f ? _physicsDeltaTime : deltaTimeSeconds;
|
||||
|
||||
var prevValues = model.GetParameterValues();
|
||||
var paramCache = stackalloc float[_rig.Inputs.Count];
|
||||
for (int i = 0; i < _rig.Inputs.Count; i++)
|
||||
paramCache[i] = 0f;
|
||||
|
||||
while (_currentRemainTime >= physicsDeltaCalc)
|
||||
{
|
||||
_currentRemainTime -= physicsDeltaCalc;
|
||||
|
||||
for (int i = 0; i < _rig.Outputs.Count; i++)
|
||||
{
|
||||
_previousOutputs[i] = _currentOutputs[i];
|
||||
_currentOutputs[i] = 0f;
|
||||
}
|
||||
|
||||
UpdatePhysics(model, false);
|
||||
|
||||
for (int i = 0; i < _rig.Outputs.Count; i++)
|
||||
_currentOutputs[i] = EvaluateOutputValue(i);
|
||||
}
|
||||
|
||||
var alpha = physicsDeltaCalc > 0f ? _currentRemainTime / physicsDeltaCalc : 0f;
|
||||
var idsPtr = model.GetParameterIds();
|
||||
var values = model.GetParameterValues();
|
||||
var maxValues = model.GetParameterMaximumValues();
|
||||
var minValues = model.GetParameterMinimumValues();
|
||||
|
||||
for (int oi = 0; oi < _rig.Outputs.Count; oi++)
|
||||
{
|
||||
var output = _rig.Outputs[oi];
|
||||
var interpolated = _previousOutputs[oi] * (1f - alpha) + _currentOutputs[oi] * alpha;
|
||||
for (int pi = 0; pi < model.ParameterCount; pi++)
|
||||
{
|
||||
if (CubismNativeModel.ReadStringFromPtrArray(idsPtr, pi) == output.ParameterId)
|
||||
{
|
||||
var result = interpolated * MaximumWeight;
|
||||
if (result < minValues[pi]) result = minValues[pi];
|
||||
if (result > maxValues[pi]) result = maxValues[pi];
|
||||
values[pi] = values[pi] * (1f - output.Weight) + result * output.Weight;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private unsafe void UpdatePhysics(CubismNativeModel model, bool stabilization)
|
||||
{
|
||||
var idsPtr = model.GetParameterIds();
|
||||
var values = model.GetParameterValues();
|
||||
var minValues = model.GetParameterMinimumValues();
|
||||
var maxValues = model.GetParameterMaximumValues();
|
||||
var defaultValues = model.GetParameterDefaultValues();
|
||||
|
||||
foreach (var subRig in _rig.SubRigs)
|
||||
{
|
||||
float totalTranslationX = 0f, totalTranslationY = 0f, totalAngle = 0f;
|
||||
|
||||
for (int ii = 0; ii < subRig.InputCount; ii++)
|
||||
{
|
||||
var input = _rig.Inputs[subRig.BaseInputIndex + ii];
|
||||
var paramValue = GetParameterValue(idsPtr, values, model.ParameterCount, input.ParameterId);
|
||||
|
||||
float tx = 0f, ty = 0f, angle = 0f;
|
||||
switch (input.Type)
|
||||
{
|
||||
case CubismPhysicsSource.X:
|
||||
tx = NormalizeParameterValue(paramValue, subRig.NormalizationPosition, input.Reflect) * input.Weight;
|
||||
break;
|
||||
case CubismPhysicsSource.Y:
|
||||
ty = NormalizeParameterValue(paramValue, subRig.NormalizationPosition, input.Reflect) * input.Weight;
|
||||
break;
|
||||
case CubismPhysicsSource.Angle:
|
||||
angle = NormalizeParameterValue(paramValue, subRig.NormalizationAngle, input.Reflect) * input.Weight;
|
||||
break;
|
||||
}
|
||||
|
||||
totalTranslationX += tx;
|
||||
totalTranslationY += ty;
|
||||
totalAngle += angle;
|
||||
}
|
||||
|
||||
var particles = _rig.Particles;
|
||||
int baseP = subRig.BaseParticleIndex;
|
||||
|
||||
float prevRootX = particles[baseP].PositionX;
|
||||
float prevRootY = particles[baseP].PositionY;
|
||||
float smoothRate = MathF.Min(1f, _physicsDeltaTime * 12f);
|
||||
particles[baseP].PositionX = prevRootX + (totalTranslationX - prevRootX) * smoothRate;
|
||||
particles[baseP].PositionY = prevRootY + (totalTranslationY - prevRootY) * smoothRate;
|
||||
|
||||
for (int pi = 1; pi < subRig.ParticleCount; pi++)
|
||||
{
|
||||
var pIdx = baseP + pi;
|
||||
var prev = particles[pIdx - 1];
|
||||
|
||||
if (stabilization)
|
||||
{
|
||||
var dirX = particles[pIdx].InitialX - prev.InitialX;
|
||||
var dirY = particles[pIdx].InitialY - prev.InitialY;
|
||||
var dist = MathF.Sqrt(dirX * dirX + dirY * dirY);
|
||||
if (dist > 0f)
|
||||
{
|
||||
dirX /= dist;
|
||||
dirY /= dist;
|
||||
}
|
||||
particles[pIdx].PositionX = particles[pIdx - 1].PositionX + dirX * particles[pIdx].Radius;
|
||||
particles[pIdx].PositionY = particles[pIdx - 1].PositionY + dirY * particles[pIdx].Radius;
|
||||
}
|
||||
else
|
||||
{
|
||||
var forceX = _rig.GravityX * particles[pIdx].Acceleration + _rig.WindX;
|
||||
var forceY = _rig.GravityY * particles[pIdx].Acceleration + _rig.WindY;
|
||||
|
||||
var delay = particles[pIdx].Delay * _physicsDeltaTime * 30f;
|
||||
if (delay > 0f)
|
||||
{
|
||||
var delay2 = delay * delay;
|
||||
var newX = particles[pIdx].PositionX + particles[pIdx].VelocityX * delay + 0.5f * forceX * delay2;
|
||||
var newY = particles[pIdx].PositionY + particles[pIdx].VelocityY * delay + 0.5f * forceY * delay2;
|
||||
|
||||
var radian = CubismMath.DirectionToRadian(
|
||||
particles[pIdx].LastGravityX, particles[pIdx].LastGravityY, forceX, forceY);
|
||||
radian /= AirResistance;
|
||||
|
||||
var dirX = newX - particles[pIdx - 1].PositionX;
|
||||
var dirY = newY - particles[pIdx - 1].PositionY;
|
||||
var currentDist = MathF.Sqrt(dirX * dirX + dirY * dirY);
|
||||
if (currentDist > 0f)
|
||||
{
|
||||
var ndirX = dirX / currentDist;
|
||||
var ndirY = dirY / currentDist;
|
||||
var cosR = MathF.Cos(radian);
|
||||
var sinR = MathF.Sin(radian);
|
||||
dirX = ndirX * cosR - ndirY * sinR;
|
||||
dirY = ndirX * sinR + ndirY * cosR;
|
||||
}
|
||||
particles[pIdx].VelocityX = (newX - particles[pIdx].LastPositionX) / delay * particles[pIdx].Mobility;
|
||||
particles[pIdx].VelocityY = (newY - particles[pIdx].LastPositionY) / delay * particles[pIdx].Mobility;
|
||||
}
|
||||
|
||||
particles[pIdx].LastGravityX = forceX;
|
||||
particles[pIdx].LastGravityY = forceY;
|
||||
particles[pIdx].ForceX = forceX;
|
||||
particles[pIdx].ForceY = forceY;
|
||||
|
||||
var curDirX = particles[pIdx].PositionX - particles[pIdx - 1].PositionX;
|
||||
var curDirY = particles[pIdx].PositionY - particles[pIdx - 1].PositionY;
|
||||
var curDist = MathF.Sqrt(curDirX * curDirX + curDirY * curDirY);
|
||||
if (curDist > 0f)
|
||||
{
|
||||
curDirX = curDirX / curDist * particles[pIdx].Radius;
|
||||
curDirY = curDirY / curDist * particles[pIdx].Radius;
|
||||
}
|
||||
particles[pIdx].PositionX = particles[pIdx - 1].PositionX + curDirX;
|
||||
particles[pIdx].PositionY = particles[pIdx - 1].PositionY + curDirY;
|
||||
particles[pIdx].LastPositionX = particles[pIdx].PositionX;
|
||||
particles[pIdx].LastPositionY = particles[pIdx].PositionY;
|
||||
}
|
||||
}
|
||||
|
||||
for (int oi = 0; oi < subRig.OutputCount; oi++)
|
||||
{
|
||||
var outIdx = subRig.BaseOutputIndex + oi;
|
||||
var output = _rig.Outputs[outIdx];
|
||||
var particle = particles[output.VertexIndex];
|
||||
var value = EvaluateOutputValueStatic(particle, particles, output, baseP, _currentOutputs[outIdx]);
|
||||
_currentOutputs[outIdx] = value * output.Weight;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private float EvaluateOutputValue(int outputIndex)
|
||||
{
|
||||
return _currentOutputs[outputIndex];
|
||||
}
|
||||
|
||||
private static float EvaluateOutputValueStatic(CubismPhysicsParticle particle,
|
||||
CubismPhysicsParticle[] particles, CubismPhysicsOutput output, int baseParticleIndex, float currentValue)
|
||||
{
|
||||
var pIdx = output.VertexIndex;
|
||||
var p = particles[pIdx];
|
||||
var prev = pIdx > baseParticleIndex ? particles[pIdx - 1] : particles[pIdx];
|
||||
|
||||
return output.Type switch
|
||||
{
|
||||
CubismPhysicsSource.X => p.PositionX - prev.PositionX,
|
||||
CubismPhysicsSource.Y => p.PositionY - prev.PositionY,
|
||||
CubismPhysicsSource.Angle =>
|
||||
MathF.Atan2(p.PositionY - prev.PositionY, p.PositionX - prev.PositionX) * 180f / MathF.PI,
|
||||
_ => p.PositionX
|
||||
};
|
||||
}
|
||||
|
||||
private static unsafe float GetParameterValue(IntPtr idsPtr, float* values, int count, string id)
|
||||
{
|
||||
for (int i = 0; i < count; i++)
|
||||
if (CubismNativeModel.ReadStringFromPtrArray(idsPtr, i) == id)
|
||||
return values[i];
|
||||
return 0f;
|
||||
}
|
||||
|
||||
private static float NormalizeParameterValue(float value, CubismPhysicsNormalization norm, bool reflect)
|
||||
{
|
||||
var range = norm.Maximum - norm.Minimum;
|
||||
if (MathF.Abs(range) < 1e-6f) return 0f;
|
||||
var result = (value - norm.Default) / range;
|
||||
return reflect ? -result : result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Cthangover.Live2D.Cubism.Framework
|
||||
{
|
||||
/// <summary>
|
||||
/// Physics input/output dimensionality.
|
||||
/// </summary>
|
||||
public enum CubismPhysicsSource { X, Y, Angle }
|
||||
|
||||
/// <summary>
|
||||
/// Normalization range for mapping raw parameter values to normalized physics inputs.
|
||||
/// </summary>
|
||||
public struct CubismPhysicsNormalization { public float Minimum; public float Maximum; public float Default; }
|
||||
|
||||
/// <summary>
|
||||
/// A single particle in a physics chain. Holds position, velocity, force accumulators,
|
||||
/// and per-particle properties (mobility, delay, acceleration, radius) from the
|
||||
/// .physics3.json file. Positions are computed relative to the chain anchor.
|
||||
/// </summary>
|
||||
public struct CubismPhysicsParticle
|
||||
{
|
||||
public float InitialX, InitialY;
|
||||
public float Mobility;
|
||||
public float Delay;
|
||||
public float Acceleration;
|
||||
public float Radius;
|
||||
public float PositionX, PositionY;
|
||||
public float LastPositionX, LastPositionY;
|
||||
public float LastGravityX, LastGravityY;
|
||||
public float ForceX, ForceY;
|
||||
public float VelocityX, VelocityY;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A sub-rig groups a contiguous range of inputs, outputs, and particles
|
||||
/// that form one independent physics chain. Each sub-rig has its own
|
||||
/// normalization settings for position and angle inputs.
|
||||
/// </summary>
|
||||
public struct CubismPhysicsSubRig
|
||||
{
|
||||
public int InputCount, OutputCount, ParticleCount;
|
||||
public int BaseInputIndex, BaseOutputIndex, BaseParticleIndex;
|
||||
public CubismPhysicsNormalization NormalizationPosition;
|
||||
public CubismPhysicsNormalization NormalizationAngle;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a model parameter to a physics chain input (X, Y, or angle)
|
||||
/// with a configurable weight and optional reflection.
|
||||
/// </summary>
|
||||
public struct CubismPhysicsInput
|
||||
{
|
||||
public string ParameterId;
|
||||
public int SourceParameterIndex;
|
||||
public float Weight;
|
||||
public CubismPhysicsSource Type;
|
||||
public bool Reflect;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a physics chain output back to a model parameter.
|
||||
/// Defines which particle vertex to read, the output type (X, Y, angle),
|
||||
/// translation scale, weight, and reflection.
|
||||
/// </summary>
|
||||
public struct CubismPhysicsOutput
|
||||
{
|
||||
public string ParameterId;
|
||||
public int DestinationParameterIndex;
|
||||
public int VertexIndex;
|
||||
public float TranslationScaleX, TranslationScaleY;
|
||||
public float AngleScale;
|
||||
public float Weight;
|
||||
public CubismPhysicsSource Type;
|
||||
public bool Reflect;
|
||||
public float ValueBelowMinimum;
|
||||
public float ValueExceededMaximum;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Complete physics rig data structure. Holds all sub-rigs, flattened
|
||||
/// input/output/particle arrays, and global gravity/wind/fps settings.
|
||||
/// Used by <see cref="CubismPhysics"/> as its internal state.
|
||||
/// </summary>
|
||||
public class CubismPhysicsRig
|
||||
{
|
||||
public List<CubismPhysicsSubRig> SubRigs = new();
|
||||
public List<CubismPhysicsInput> Inputs = new();
|
||||
public List<CubismPhysicsOutput> Outputs = new();
|
||||
public CubismPhysicsParticle[] Particles = System.Array.Empty<CubismPhysicsParticle>();
|
||||
public float GravityX, GravityY;
|
||||
public float WindX, WindY;
|
||||
public float Fps;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// JSON parser for the Cubism .physics3.json format.
|
||||
/// Reads the Meta section (fps, gravity, wind) and the PhysicsSettings
|
||||
/// array (sub-rigs with their inputs, outputs, and vertices/particles).
|
||||
///
|
||||
/// All data is accessed through typed out-parameter methods that
|
||||
/// parse JSON elements directly — avoiding intermediate allocation
|
||||
/// of temporary data structures.
|
||||
/// </summary>
|
||||
public class CubismPhysicsJson
|
||||
{
|
||||
private readonly JsonDocument _json;
|
||||
|
||||
/// <summary>
|
||||
/// Parses the given .physics3.json bytes.
|
||||
/// </summary>
|
||||
public CubismPhysicsJson(byte[] jsonBytes)
|
||||
{
|
||||
_json = JsonDocument.Parse(jsonBytes);
|
||||
}
|
||||
|
||||
/// <summary>Physics simulation FPS from the Meta section, default 30.</summary>
|
||||
public float GetFps()
|
||||
{
|
||||
var meta = _json.RootElement.GetProperty("Meta");
|
||||
return meta.TryGetProperty("Fps", out var v) ? v.GetSingle() : 30f;
|
||||
}
|
||||
|
||||
/// <summary>Number of sub-rig entries in PhysicsSettings.</summary>
|
||||
public int GetSubRigCount() =>
|
||||
_json.RootElement.TryGetProperty("PhysicsSettings", out var arr) ? arr.GetArrayLength() : 0;
|
||||
|
||||
/// <summary>Reads gravity vector from Meta.EffectiveForces.Gravity. Defaults to (0, -1).</summary>
|
||||
public void GetGravity(out float x, out float y)
|
||||
{
|
||||
x = 0; y = -1;
|
||||
var meta = _json.RootElement.GetProperty("Meta");
|
||||
if (meta.TryGetProperty("EffectiveForces", out var ef) &&
|
||||
ef.TryGetProperty("Gravity", out var g))
|
||||
{
|
||||
x = g.TryGetProperty("X", out var gx) ? gx.GetSingle() : 0f;
|
||||
y = g.TryGetProperty("Y", out var gy) ? gy.GetSingle() : -1f;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads wind vector from Meta.EffectiveForces.Wind. Defaults to (0, 0).</summary>
|
||||
public void GetWind(out float x, out float y)
|
||||
{
|
||||
x = 0; y = 0;
|
||||
var meta = _json.RootElement.GetProperty("Meta");
|
||||
if (meta.TryGetProperty("EffectiveForces", out var ef) &&
|
||||
ef.TryGetProperty("Wind", out var w))
|
||||
{
|
||||
x = w.TryGetProperty("X", out var wx) ? wx.GetSingle() : 0f;
|
||||
y = w.TryGetProperty("Y", out var wy) ? wy.GetSingle() : 0f;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Number of input entries in the given sub-rig.</summary>
|
||||
public int GetInputCount(int subRigIndex) =>
|
||||
GetSubRig(subRigIndex).GetProperty("Input").GetArrayLength();
|
||||
|
||||
/// <summary>Number of output entries in the given sub-rig.</summary>
|
||||
public int GetOutputCount(int subRigIndex) =>
|
||||
GetSubRig(subRigIndex).GetProperty("Output").GetArrayLength();
|
||||
|
||||
/// <summary>Number of vertex/particle entries in the given sub-rig.</summary>
|
||||
public int GetVertexCount(int subRigIndex) =>
|
||||
GetSubRig(subRigIndex).GetProperty("Vertices").GetArrayLength();
|
||||
|
||||
/// <summary>Reads position normalization (min, max, default) for the given sub-rig.</summary>
|
||||
public void GetNormalizationPosition(int subRigIndex, out CubismPhysicsNormalization norm)
|
||||
{
|
||||
norm = default;
|
||||
var sr = GetSubRig(subRigIndex);
|
||||
if (!sr.TryGetProperty("Normalization", out var n)) return;
|
||||
if (!n.TryGetProperty("Position", out var pos)) return;
|
||||
norm.Minimum = pos.TryGetProperty("Minimum", out var p) ? p.GetSingle() : -30f;
|
||||
norm.Maximum = pos.TryGetProperty("Maximum", out var pp) ? pp.GetSingle() : 30f;
|
||||
norm.Default = pos.TryGetProperty("Default", out var pd) ? pd.GetSingle() : 0f;
|
||||
}
|
||||
|
||||
/// <summary>Reads angle normalization (min, max, default) for the given sub-rig.</summary>
|
||||
public void GetNormalizationAngle(int subRigIndex, out CubismPhysicsNormalization norm)
|
||||
{
|
||||
norm = default;
|
||||
var sr = GetSubRig(subRigIndex);
|
||||
if (!sr.TryGetProperty("Normalization", out var n)) return;
|
||||
if (!n.TryGetProperty("Angle", out var angle)) return;
|
||||
norm.Minimum = angle.TryGetProperty("Minimum", out var p) ? p.GetSingle() : -30f;
|
||||
norm.Maximum = angle.TryGetProperty("Maximum", out var pp) ? pp.GetSingle() : 30f;
|
||||
norm.Default = angle.TryGetProperty("Default", out var pd) ? pd.GetSingle() : 0f;
|
||||
}
|
||||
|
||||
/// <summary>Reads a single input entry: parameter ID, weight, type, reflect flag.</summary>
|
||||
public void GetInput(int subRigIndex, int inputIndex, out string parameterId, out float weight,
|
||||
out CubismPhysicsSource type, out bool reflect)
|
||||
{
|
||||
var inp = GetSubRig(subRigIndex).GetProperty("Input")[inputIndex];
|
||||
parameterId = inp.GetProperty("Source").GetProperty("Id").GetString();
|
||||
weight = inp.TryGetProperty("Weight", out var w) ? w.GetSingle() : 1f;
|
||||
type = ParseSource(inp.TryGetProperty("Type", out var t) ? t.GetString() : "X");
|
||||
reflect = inp.TryGetProperty("Reflect", out var r) && r.GetBoolean();
|
||||
}
|
||||
|
||||
/// <summary>Reads a single output entry: destination parameter ID, vertex index, scales, type, reflect.</summary>
|
||||
public void GetOutput(int subRigIndex, int outputIndex, out string parameterId, out int vertexIndex,
|
||||
out float scaleX, out float scaleY, out float weight, out CubismPhysicsSource type, out bool reflect)
|
||||
{
|
||||
var outp = GetSubRig(subRigIndex).GetProperty("Output")[outputIndex];
|
||||
parameterId = outp.GetProperty("Destination").GetProperty("Id").GetString();
|
||||
vertexIndex = outp.TryGetProperty("VertexIndex", out var v) ? v.GetInt32() : 0;
|
||||
scaleX = scaleY = outp.TryGetProperty("Scale", out var s) ? s.GetSingle() : 1f;
|
||||
weight = outp.TryGetProperty("Weight", out var w) ? w.GetSingle() : 1f;
|
||||
type = ParseSource(outp.TryGetProperty("Type", out var t) ? t.GetString() : "X");
|
||||
reflect = outp.TryGetProperty("Reflect", out var r) && r.GetBoolean();
|
||||
}
|
||||
|
||||
/// <summary>Reads a single particle/vertex: mobility, delay, acceleration, radius, position.</summary>
|
||||
public void GetParticle(int subRigIndex, int vertexIndex,
|
||||
out float mobility, out float delay, out float acceleration, out float radius,
|
||||
out float posX, out float posY)
|
||||
{
|
||||
var verts = GetSubRig(subRigIndex).GetProperty("Vertices")[vertexIndex];
|
||||
mobility = verts.TryGetProperty("Mobility", out var m) ? m.GetSingle() : 1f;
|
||||
delay = verts.TryGetProperty("Delay", out var d) ? d.GetSingle() : 0f;
|
||||
acceleration = verts.TryGetProperty("Acceleration", out var a) ? a.GetSingle() : 1f;
|
||||
radius = verts.TryGetProperty("Radius", out var r) ? r.GetSingle() : 1f;
|
||||
var pos = verts.GetProperty("Position");
|
||||
posX = pos.TryGetProperty("X", out var px) ? px.GetSingle() : 0f;
|
||||
posY = pos.TryGetProperty("Y", out var py) ? py.GetSingle() : 0f;
|
||||
}
|
||||
|
||||
private JsonElement GetSubRig(int index) =>
|
||||
_json.RootElement.GetProperty("PhysicsSettings")[index];
|
||||
|
||||
private static CubismPhysicsSource ParseSource(string v) => v switch
|
||||
{
|
||||
"Angle" => CubismPhysicsSource.Angle,
|
||||
"Y" => CubismPhysicsSource.Y,
|
||||
_ => CubismPhysicsSource.X
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Cthangover.Live2D.Cubism.Framework
|
||||
{
|
||||
/// <summary>
|
||||
/// Manages pose-based part visibility from a Cubism .pose3.json file.
|
||||
/// Poses organize model parts into mutual-exclusion groups — only one
|
||||
/// part in each group is visible at a time. This is commonly used for
|
||||
/// eye states (open/closed/surprised) and other binary/multi-state
|
||||
/// part switches.
|
||||
///
|
||||
/// How it works:
|
||||
/// <list type="number">
|
||||
/// <item>On model change (detected via ModelPtr comparison), all
|
||||
/// part groups are reset: the first part in each group gets
|
||||
/// opacity 1, others get opacity 0</item>
|
||||
/// <item>Each frame, the currently visible part in each group
|
||||
/// fades toward opacity 1 while all others fade toward 0</item>
|
||||
/// <item>Linked parts (defined by the "Link" array in pose data)
|
||||
/// copy their parent's opacity, cascading visibility changes</item>
|
||||
/// </list>
|
||||
///
|
||||
/// The fade uses a constant rate (1 / fadeTimeSeconds per second).
|
||||
/// A piecewise-linear alpha calculation with a crossing threshold (Phi=0.5)
|
||||
/// prevents parts from being partially visible during transitions.
|
||||
/// </summary>
|
||||
public class CubismPose
|
||||
{
|
||||
private struct PartData
|
||||
{
|
||||
public string PartId;
|
||||
public int PartIndex;
|
||||
public int ParameterIndex;
|
||||
public List<PartData> Link;
|
||||
}
|
||||
|
||||
private readonly List<List<PartData>> _partGroups = new();
|
||||
private readonly float _fadeTimeSeconds;
|
||||
private IntPtr _lastModel = IntPtr.Zero;
|
||||
|
||||
/// <summary>
|
||||
/// Parses a .pose3.json byte buffer.
|
||||
/// Extracts the global fade time and all part groups with their
|
||||
/// linked-part chains.
|
||||
/// </summary>
|
||||
public CubismPose(byte[] pose3JsonBytes)
|
||||
{
|
||||
if (pose3JsonBytes == null || pose3JsonBytes.Length == 0) return;
|
||||
|
||||
var json = JsonDocument.Parse(pose3JsonBytes);
|
||||
var root = json.RootElement;
|
||||
_fadeTimeSeconds = root.TryGetProperty("FadeInTime", out var ft) ? ft.GetSingle() : 0.5f;
|
||||
|
||||
if (!root.TryGetProperty("Groups", out var groups)) return;
|
||||
|
||||
foreach (var group in groups.EnumerateArray())
|
||||
{
|
||||
var partList = new List<PartData>();
|
||||
foreach (var part in group.EnumerateArray())
|
||||
{
|
||||
var pd = new PartData
|
||||
{
|
||||
PartId = part.GetProperty("Id").GetString(),
|
||||
PartIndex = -1,
|
||||
ParameterIndex = -1,
|
||||
Link = new List<PartData>()
|
||||
};
|
||||
|
||||
if (part.TryGetProperty("Link", out var links))
|
||||
{
|
||||
foreach (var link in links.EnumerateArray())
|
||||
pd.Link.Add(new PartData { PartId = link.GetString(), PartIndex = -1, ParameterIndex = -1 });
|
||||
}
|
||||
|
||||
partList.Add(pd);
|
||||
}
|
||||
_partGroups.Add(partList);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-frame pose update. Detects model pointer changes to trigger
|
||||
/// a full reset, then fades parts within each group and propagates
|
||||
/// opacities to linked parts.
|
||||
/// Called from <see cref="CubismModelNode.OnUpdate"/> after expressions.
|
||||
/// </summary>
|
||||
public unsafe void UpdateParameters(CubismNativeModel model, float deltaTimeSeconds)
|
||||
{
|
||||
if (model == null || model.ModelPtr == IntPtr.Zero) return;
|
||||
|
||||
if (_lastModel != model.ModelPtr)
|
||||
{
|
||||
_lastModel = model.ModelPtr;
|
||||
Reset(model);
|
||||
}
|
||||
|
||||
foreach (var group in _partGroups)
|
||||
DoFade(model, group, deltaTimeSeconds);
|
||||
|
||||
CopyPartOpacities(model);
|
||||
}
|
||||
|
||||
private unsafe void Reset(CubismNativeModel model)
|
||||
{
|
||||
var idsPtr = model.GetPartIds();
|
||||
var opacities = model.GetPartOpacities();
|
||||
int partCount = model.GetPartCount();
|
||||
|
||||
foreach (var group in _partGroups)
|
||||
{
|
||||
for (int i = 0; i < group.Count; i++)
|
||||
{
|
||||
var pd = group[i];
|
||||
pd.PartIndex = -1;
|
||||
pd.ParameterIndex = -1;
|
||||
|
||||
for (int pi = 0; pi < partCount; pi++)
|
||||
{
|
||||
if (CubismNativeModel.ReadStringFromPtrArray(idsPtr, pi) == pd.PartId)
|
||||
{
|
||||
pd.PartIndex = pi;
|
||||
opacities[pi] = i == 0 ? 1f : 0f;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
group[i] = pd;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private unsafe void DoFade(CubismNativeModel model, List<PartData> group, float deltaTimeSeconds)
|
||||
{
|
||||
const float Phi = 0.5f;
|
||||
const float BackOpacityThreshold = 0.15f;
|
||||
|
||||
var idsPtr = model.GetPartIds();
|
||||
var opacities = model.GetPartOpacities();
|
||||
int partCount = model.GetPartCount();
|
||||
|
||||
float newOpacity = 1f;
|
||||
int visibleIndex = 0;
|
||||
|
||||
for (int i = 0; i < group.Count; i++)
|
||||
{
|
||||
var pd = group[i];
|
||||
if (pd.PartIndex < 0) continue;
|
||||
|
||||
if (opacities[pd.PartIndex] > 0.001f)
|
||||
{
|
||||
visibleIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < group.Count; i++)
|
||||
{
|
||||
var pd = group[i];
|
||||
if (pd.PartIndex < 0) continue;
|
||||
|
||||
if (i == visibleIndex)
|
||||
{
|
||||
var fadeRate = deltaTimeSeconds / _fadeTimeSeconds;
|
||||
newOpacity = MathF.Min(opacities[pd.PartIndex] + fadeRate, 1f);
|
||||
opacities[pd.PartIndex] = newOpacity;
|
||||
}
|
||||
else
|
||||
{
|
||||
float a1;
|
||||
if (newOpacity < Phi)
|
||||
a1 = newOpacity * (Phi - 1f) / Phi + 1f;
|
||||
else
|
||||
a1 = (1f - newOpacity) * Phi / (1f - Phi);
|
||||
|
||||
var backOpacity = (1f - a1) * (1f - newOpacity);
|
||||
if (backOpacity > BackOpacityThreshold)
|
||||
a1 = 1f - BackOpacityThreshold / (1f - newOpacity);
|
||||
|
||||
opacities[pd.PartIndex] = MathF.Min(opacities[pd.PartIndex], a1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private unsafe void CopyPartOpacities(CubismNativeModel model)
|
||||
{
|
||||
var idsPtr = model.GetPartIds();
|
||||
var opacities = model.GetPartOpacities();
|
||||
int partCount = model.GetPartCount();
|
||||
|
||||
foreach (var group in _partGroups)
|
||||
{
|
||||
for (int gi = 0; gi < group.Count; gi++)
|
||||
{
|
||||
var pd = group[gi];
|
||||
if (pd.PartIndex < 0 || pd.Link == null) continue;
|
||||
|
||||
var parentOpacity = opacities[pd.PartIndex];
|
||||
for (int li = 0; li < pd.Link.Count; li++)
|
||||
{
|
||||
var link = pd.Link[li];
|
||||
for (int pi = 0; pi < partCount; pi++)
|
||||
{
|
||||
if (CubismNativeModel.ReadStringFromPtrArray(idsPtr, pi) == link.PartId)
|
||||
{
|
||||
link.PartIndex = pi;
|
||||
opacities[pi] = parentOpacity;
|
||||
pd.Link[li] = link;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,661 @@
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using Cthangover.Core.Utils;
|
||||
using Godot;
|
||||
using Cthangover.Live2D.Cubism.Core;
|
||||
|
||||
namespace Cthangover.Live2D.Cubism.Framework
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds and updates 2D mesh instances for rendering a Cubism model in Godot.
|
||||
///
|
||||
/// Handles the full rendering pipeline:
|
||||
/// <list type="number">
|
||||
/// <item><b>BuildModel</b> — Creates <see cref="ArrayMesh"/> instances from
|
||||
/// native vertex/index data, applies textures, resolves shaders based
|
||||
/// on drawable blend flags and mask configuration</item>
|
||||
/// <item><b>UpdateModel</b> — Updates visibility, opacity, and vertex
|
||||
/// positions each frame by reading CubismCore dynamic flags</item>
|
||||
/// <item><b>Mask support</b> — Creates SubViewport-based mask textures
|
||||
/// for drawables that have mask counts. Masked drawables use
|
||||
/// specialized shaders (2d_cubism_mask_*) that composite the mask
|
||||
/// texture with the main texture</item>
|
||||
/// </list>
|
||||
///
|
||||
/// Shader resolution delegates to <see cref="Cthangover.Core.Mods.ModManager.ResolveShader"/>.
|
||||
/// Shader names follow the pattern: 2d_cubism_[norm|mask]_[mix|add|mul][_inv]?
|
||||
/// where "norm" = no mask, "mask" = masked, "mix/add/mul" = blend mode,
|
||||
/// "_inv" = inverted mask.
|
||||
/// </summary>
|
||||
public class CubismRenderer2D
|
||||
{
|
||||
private MeshInstance2D[] _meshInstances;
|
||||
private ShaderMaterial[] _materials;
|
||||
private readonly Node2D _parentNode;
|
||||
private CubismNativeModel _model;
|
||||
|
||||
private struct MaskGroupData
|
||||
{
|
||||
public SubViewport Viewport;
|
||||
public ViewportTexture Texture;
|
||||
public int[] MaskDrawableIndices;
|
||||
public MeshInstance2D[] MaskMeshes;
|
||||
public ArrayMesh[] MaskArrayMeshes;
|
||||
public int ViewportSize;
|
||||
public float OffsetX;
|
||||
public float OffsetY;
|
||||
}
|
||||
|
||||
private readonly List<MaskGroupData> _maskGroups = new();
|
||||
private int[] _drawableMaskGroupIndex;
|
||||
private Color[] _drawableMaskChannel;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a renderer that attaches mesh instances as children of
|
||||
/// the given parent node.
|
||||
/// </summary>
|
||||
public CubismRenderer2D(Node2D parentNode)
|
||||
{
|
||||
_parentNode = parentNode;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds all mesh instances for the given model.
|
||||
/// Clears any previously built meshes. Creates mask SubViewports first,
|
||||
/// then iterates drawables in render order, building ArrayMesh instances
|
||||
/// with the appropriate shader and texture.
|
||||
/// </summary>
|
||||
public unsafe void BuildModel(CubismNativeModel model)
|
||||
{
|
||||
_model = model;
|
||||
ClearMeshes();
|
||||
|
||||
var dc = model.DrawableCount;
|
||||
_meshInstances = new MeshInstance2D[dc];
|
||||
_materials = new ShaderMaterial[dc];
|
||||
GameLogger.Log("LIVE2D", $"BuildModel ENTER: drawables={dc} textures={model.Textures?.Length ?? 0} ppu={model.PixelsPerUnit} canvas={model.CanvasWidth}x{model.CanvasHeight}");
|
||||
|
||||
var renderOrders = model.GetRenderOrders();
|
||||
GameLogger.Log("LIVE2D", $"BuildModel S1: renderOrders ptr ok");
|
||||
var constantFlags = model.GetConstantFlags();
|
||||
var texIndices = model.GetTextureIndices();
|
||||
var vertexCounts = model.GetVertexCounts();
|
||||
var vertexPositions = model.GetVertexPositions();
|
||||
var vertexUvs = model.GetVertexUvs();
|
||||
var indexCounts = model.GetIndexCounts();
|
||||
var indices = model.GetIndices();
|
||||
var opacities = model.GetOpacities();
|
||||
var maskCounts = model.GetMaskCounts();
|
||||
GameLogger.Log("LIVE2D", $"BuildModel S2: all native ptrs fetched");
|
||||
|
||||
// Log first 10 renderOrders to understand mapping
|
||||
var roSample = new System.Text.StringBuilder("renderOrders: ");
|
||||
for (int i = 0; i < Math.Min(dc, 10); i++)
|
||||
roSample.Append($"[{i}]={renderOrders[i]} ");
|
||||
GameLogger.Log("LIVE2D", roSample.ToString());
|
||||
|
||||
// Sanity: check renderOrders contain valid drawable indices
|
||||
for (int i = 0; i < dc; i++)
|
||||
{
|
||||
var ro = renderOrders[i];
|
||||
if (ro < 0 || ro >= dc)
|
||||
{
|
||||
GameLogger.Log("LIVE2D", $"BuildModel BAD_RO: renderOrders[{i}]={ro} — OUT OF RANGE [0,{dc - 1}]");
|
||||
}
|
||||
}
|
||||
GameLogger.Log("LIVE2D", $"BuildModel S3: renderOrders validated");
|
||||
|
||||
float minX = float.MaxValue, minY = float.MaxValue, maxX = float.MinValue, maxY = float.MinValue;
|
||||
for (int di = 0; di < dc; di++)
|
||||
{
|
||||
var vc = vertexCounts[di];
|
||||
if (vc <= 0 || vc > 100000) { GameLogger.Log("LIVE2D", $"BuildModel BOUNDS_DIAG: di={di} vc={vc} SKIP"); continue; }
|
||||
var vp = vertexPositions[di];
|
||||
if (di == 0)
|
||||
GameLogger.Log("LIVE2D", $"BuildModel: drawable[0] vc={vc} vp0=({vp[0].X},{vp[0].Y})");
|
||||
for (int v = 0; v < vc; v++)
|
||||
{
|
||||
if (vp[v].X < minX) minX = vp[v].X;
|
||||
if (vp[v].Y < minY) minY = vp[v].Y;
|
||||
if (vp[v].X > maxX) maxX = vp[v].X;
|
||||
if (vp[v].Y > maxY) maxY = vp[v].Y;
|
||||
}
|
||||
}
|
||||
GameLogger.Log("LIVE2D", $"BuildModel: vertexBounds=({minX:F3},{minY:F3})-({maxX:F3},{maxY:F3}) ppu={model.PixelsPerUnit}");
|
||||
|
||||
for (int di = 0; di < dc; di++)
|
||||
{
|
||||
var vc = vertexCounts[di];
|
||||
if (vc <= 0 || vc > 100000) continue;
|
||||
var uv = vertexUvs[di];
|
||||
float uMin = 2, uMax = -1, vMin = 2, vMax = -1;
|
||||
for (int i = 0; i < vc; i++)
|
||||
{
|
||||
if (uv[i].X < uMin) uMin = uv[i].X;
|
||||
if (uv[i].Y < vMin) vMin = uv[i].Y;
|
||||
if (uv[i].X > uMax) uMax = uv[i].X;
|
||||
if (uv[i].Y > vMax) vMax = uv[i].Y;
|
||||
}
|
||||
GameLogger.Log("LIVE2D", $"BuildModel: drawable[{di}] uvBounds=({uMin:F3},{vMin:F3})-({uMax:F3},{vMax:F3})");
|
||||
}
|
||||
|
||||
// Build mask data
|
||||
BuildMaskGroups(model, maskCounts);
|
||||
|
||||
SetupMaskViewports(model);
|
||||
|
||||
// Second pass: create drawable meshes — iterate by drawable index (matching C++ original)
|
||||
GameLogger.Log("LIVE2D", $"BuildModel S4: entering mesh loop, dc={dc}");
|
||||
for (int di = 0; di < dc; di++)
|
||||
{
|
||||
int vCount = vertexCounts[di];
|
||||
int iCount = indexCounts[di];
|
||||
if (vCount <= 0 || vCount > 100000 || iCount <= 0 || iCount > 500000) continue;
|
||||
|
||||
var mesh = new ArrayMesh();
|
||||
var surfaceArray = new Godot.Collections.Array();
|
||||
surfaceArray.Resize((int)Mesh.ArrayType.Max);
|
||||
|
||||
var verts = new Vector2[vCount];
|
||||
var uvs = new Vector2[vCount];
|
||||
var idxs = new int[iCount];
|
||||
|
||||
var srcVerts = vertexPositions[di];
|
||||
var srcUvs = vertexUvs[di];
|
||||
var srcIndices = indices[di];
|
||||
|
||||
for (int v = 0; v < vCount; v++)
|
||||
{
|
||||
verts[v] = new Vector2(srcVerts[v].X * _model.PixelsPerUnit, -srcVerts[v].Y * _model.PixelsPerUnit);
|
||||
uvs[v] = new Vector2(srcUvs[v].X, 1f - srcUvs[v].Y);
|
||||
}
|
||||
|
||||
for (int t = 0; t < iCount; t++)
|
||||
idxs[t] = srcIndices[t];
|
||||
|
||||
surfaceArray[(int)Mesh.ArrayType.Vertex] = verts;
|
||||
surfaceArray[(int)Mesh.ArrayType.TexUV] = uvs;
|
||||
surfaceArray[(int)Mesh.ArrayType.Index] = idxs;
|
||||
|
||||
mesh.AddSurfaceFromArrays(Mesh.PrimitiveType.Triangles, surfaceArray);
|
||||
|
||||
var mi = new MeshInstance2D { Mesh = mesh, ZIndex = renderOrders[di], ZAsRelative = false };
|
||||
var texIndex = texIndices[di];
|
||||
var texture = texIndex >= 0 && texIndex < model.Textures.Length ? model.Textures[texIndex] : null;
|
||||
var opacity = opacities[di];
|
||||
GameLogger.Log("LIVE2D", $"BuildModel: drawable[{di}] opacity={opacity:F3}");
|
||||
|
||||
var shader = ResolveShaderForDrawable(di, constantFlags, maskCounts);
|
||||
|
||||
ShaderMaterial mat = null;
|
||||
if (shader != null)
|
||||
{
|
||||
mat = new ShaderMaterial { Shader = shader };
|
||||
mat.SetShaderParameter("color_base", new Color(1f, 1f, 1f, 1f));
|
||||
mat.SetShaderParameter("color_multiply", new Color(1f, 1f, 1f, 1f));
|
||||
mat.SetShaderParameter("color_screen", new Color(0f, 0f, 0f, 0f));
|
||||
mat.SetShaderParameter("channel", new Color(0f, 0f, 0f, 0f));
|
||||
mat.SetShaderParameter("mask_scale", 1f);
|
||||
mat.SetShaderParameter("mesh_offset", Vector2.Zero);
|
||||
|
||||
if (texture != null)
|
||||
mat.SetShaderParameter("tex_main", texture);
|
||||
|
||||
if (maskCounts[di] > 0)
|
||||
{
|
||||
var groupIdx = _drawableMaskGroupIndex[di];
|
||||
if (groupIdx >= 0 && groupIdx < _maskGroups.Count)
|
||||
{
|
||||
var group = _maskGroups[groupIdx];
|
||||
if (group.Texture != null)
|
||||
{
|
||||
mat.SetShaderParameter("tex_mask", group.Texture);
|
||||
mat.SetShaderParameter("channel", _drawableMaskChannel[di]);
|
||||
mat.SetShaderParameter("mask_scale", 1f);
|
||||
mat.SetShaderParameter("mesh_offset", new Vector2(group.OffsetX, group.OffsetY));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mi.Material = mat;
|
||||
}
|
||||
|
||||
if (texture != null)
|
||||
{
|
||||
mat.SetShaderParameter("tex_main", texture);
|
||||
GameLogger.Log("LIVE2D", $"BuildModel: drawable[{di}] texture Ok");
|
||||
}
|
||||
else
|
||||
{
|
||||
GameLogger.Log("LIVE2D", $"BuildModel: drawable[{di}] texture MISSING (texIndex={texIndex})");
|
||||
}
|
||||
|
||||
mi.Visible = true;
|
||||
_parentNode.AddChild(mi);
|
||||
|
||||
GameLogger.Log("LIVE2D", $"BuildModel: drawable[{di}] materialSet={mi.Material != null} visible={mi.Visible} parent={mi.GetParent()?.Name}");
|
||||
_meshInstances[di] = mi;
|
||||
_materials[di] = mat;
|
||||
}
|
||||
GameLogger.Log("LIVE2D", $"BuildModel DONE: meshes created");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the union AABB of all drawable meshes in local pixel space.
|
||||
/// Valid after <see cref="BuildModel"/> has completed.
|
||||
/// </summary>
|
||||
public Aabb GetContentBounds()
|
||||
{
|
||||
if (_meshInstances == null) return new Aabb();
|
||||
|
||||
Aabb aabb = default;
|
||||
bool first = true;
|
||||
foreach (var mi in _meshInstances)
|
||||
{
|
||||
if (mi?.Mesh == null) continue;
|
||||
var meshAabb = ((ArrayMesh)mi.Mesh).GetAabb();
|
||||
if (first) { aabb = meshAabb; first = false; }
|
||||
else aabb = aabb.Merge(meshAabb);
|
||||
}
|
||||
return aabb;
|
||||
}
|
||||
|
||||
private unsafe void BuildMaskGroups(CubismNativeModel model, int* maskCounts)
|
||||
{
|
||||
_maskGroups.Clear();
|
||||
_drawableMaskGroupIndex = new int[model.DrawableCount];
|
||||
_drawableMaskChannel = new Color[model.DrawableCount];
|
||||
for (int i = 0; i < model.DrawableCount; i++)
|
||||
_drawableMaskGroupIndex[i] = -1;
|
||||
|
||||
var masks = model.GetMasks();
|
||||
var dc = model.DrawableCount;
|
||||
var maskSetToGroup = new Dictionary<string, int>();
|
||||
|
||||
for (int di = 0; di < dc; di++)
|
||||
{
|
||||
var count = maskCounts[di];
|
||||
if (count == 0) continue;
|
||||
|
||||
var maskIndices = masks[di];
|
||||
var sorted = new int[count];
|
||||
for (int mi = 0; mi < count; mi++)
|
||||
sorted[mi] = maskIndices[mi];
|
||||
System.Array.Sort(sorted);
|
||||
|
||||
var keyBuilder = new System.Text.StringBuilder();
|
||||
for (int mi = 0; mi < count; mi++)
|
||||
{
|
||||
if (mi > 0) keyBuilder.Append(',');
|
||||
keyBuilder.Append(sorted[mi]);
|
||||
}
|
||||
var key = keyBuilder.ToString();
|
||||
|
||||
if (!maskSetToGroup.TryGetValue(key, out var groupIdx))
|
||||
{
|
||||
groupIdx = _maskGroups.Count;
|
||||
maskSetToGroup[key] = groupIdx;
|
||||
|
||||
var uniqueMaskDrawables = new System.Collections.Generic.List<int>();
|
||||
for (int mi = 0; mi < count; mi++)
|
||||
if (!uniqueMaskDrawables.Contains(sorted[mi]))
|
||||
uniqueMaskDrawables.Add(sorted[mi]);
|
||||
|
||||
_maskGroups.Add(new MaskGroupData
|
||||
{
|
||||
MaskDrawableIndices = uniqueMaskDrawables.ToArray()
|
||||
});
|
||||
}
|
||||
|
||||
_drawableMaskGroupIndex[di] = groupIdx;
|
||||
|
||||
var maskSet = _maskGroups[groupIdx].MaskDrawableIndices;
|
||||
float cr = 0f, cg = 0f, cb = 0f, ca = 0f;
|
||||
for (int mi = 0; mi < count; mi++)
|
||||
{
|
||||
int slot = System.Array.IndexOf(maskSet, sorted[mi]);
|
||||
if (slot >= 0)
|
||||
{
|
||||
switch (slot)
|
||||
{
|
||||
case 0: cr = 1f; break;
|
||||
case 1: cg = 1f; break;
|
||||
case 2: cb = 1f; break;
|
||||
case 3: ca = 1f; break;
|
||||
}
|
||||
}
|
||||
}
|
||||
_drawableMaskChannel[di] = new Color(cr, cg, cb, ca);
|
||||
}
|
||||
}
|
||||
|
||||
private unsafe void SetupMaskViewports(CubismNativeModel model)
|
||||
{
|
||||
for (int gi = 0; gi < _maskGroups.Count; gi++)
|
||||
{
|
||||
var group = _maskGroups[gi];
|
||||
var maskIndices = group.MaskDrawableIndices;
|
||||
var channelCount = maskIndices.Length;
|
||||
|
||||
var ppu = model.PixelsPerUnit;
|
||||
float minX = float.MaxValue, minY = float.MaxValue, maxX = float.MinValue, maxY = float.MinValue;
|
||||
for (int mi = 0; mi < channelCount; mi++)
|
||||
{
|
||||
var di = maskIndices[mi];
|
||||
var vCount = model.GetVertexCounts()[di];
|
||||
var vp = model.GetVertexPositions()[di];
|
||||
for (int v = 0; v < vCount; v++)
|
||||
{
|
||||
float wx = vp[v].X * ppu;
|
||||
float wy = -vp[v].Y * ppu;
|
||||
if (wx < minX) minX = wx;
|
||||
if (wy < minY) minY = wy;
|
||||
if (wx > maxX) maxX = wx;
|
||||
if (wy > maxY) maxY = wy;
|
||||
}
|
||||
}
|
||||
|
||||
float pad = 4f;
|
||||
float vpSize = Math.Max(maxX - minX + pad * 2f, maxY - minY + pad * 2f);
|
||||
vpSize = Math.Max(vpSize, 64f);
|
||||
int vpSizeInt = (int)Mathf.Ceil(vpSize);
|
||||
vpSizeInt = NextPowerOfTwo(vpSizeInt);
|
||||
|
||||
var viewport = new SubViewport
|
||||
{
|
||||
Size = new Vector2I(vpSizeInt, vpSizeInt),
|
||||
TransparentBg = true,
|
||||
RenderTargetUpdateMode = SubViewport.UpdateMode.Always
|
||||
};
|
||||
_parentNode.AddChild(viewport);
|
||||
|
||||
group.Viewport = viewport;
|
||||
group.Texture = viewport.GetTexture();
|
||||
group.ViewportSize = vpSizeInt;
|
||||
group.OffsetX = minX - pad;
|
||||
group.OffsetY = minY - pad;
|
||||
group.MaskMeshes = new MeshInstance2D[channelCount];
|
||||
group.MaskArrayMeshes = new ArrayMesh[channelCount];
|
||||
|
||||
for (int mi = 0; mi < channelCount; mi++)
|
||||
{
|
||||
var di = maskIndices[mi];
|
||||
var mi2d = new MeshInstance2D();
|
||||
viewport.AddChild(mi2d);
|
||||
group.MaskMeshes[mi] = mi2d;
|
||||
group.MaskArrayMeshes[mi] = BuildMaskArrayMesh(di, model, mi2d, mi, group.OffsetX, group.OffsetY);
|
||||
}
|
||||
|
||||
_maskGroups[gi] = group;
|
||||
}
|
||||
}
|
||||
|
||||
private unsafe ArrayMesh BuildMaskArrayMesh(int drawableIndex, CubismNativeModel model, MeshInstance2D mi2d, int channelSlot, float offsetX, float offsetY)
|
||||
{
|
||||
var vCount = model.GetVertexCounts()[drawableIndex];
|
||||
var iCount = model.GetIndexCounts()[drawableIndex];
|
||||
if (vCount == 0 || iCount == 0) return null;
|
||||
|
||||
var mesh = new ArrayMesh();
|
||||
var surfaceArray = new Godot.Collections.Array();
|
||||
surfaceArray.Resize((int)Mesh.ArrayType.Max);
|
||||
|
||||
var verts = new Vector2[vCount];
|
||||
var uvs = new Vector2[vCount];
|
||||
var idxs = new int[iCount];
|
||||
|
||||
var srcVerts = model.GetVertexPositions()[drawableIndex];
|
||||
var srcUvs = model.GetVertexUvs()[drawableIndex];
|
||||
var srcIndices = model.GetIndices()[drawableIndex];
|
||||
var ppu = model.PixelsPerUnit;
|
||||
|
||||
for (int v = 0; v < vCount; v++)
|
||||
{
|
||||
verts[v] = new Vector2(srcVerts[v].X * ppu - offsetX, -srcVerts[v].Y * ppu - offsetY);
|
||||
uvs[v] = new Vector2(srcUvs[v].X, 1f - srcUvs[v].Y);
|
||||
}
|
||||
|
||||
for (int t = 0; t < iCount; t++)
|
||||
idxs[t] = srcIndices[t];
|
||||
|
||||
surfaceArray[(int)Mesh.ArrayType.Vertex] = verts;
|
||||
surfaceArray[(int)Mesh.ArrayType.TexUV] = uvs;
|
||||
surfaceArray[(int)Mesh.ArrayType.Index] = idxs;
|
||||
|
||||
mesh.AddSurfaceFromArrays(Mesh.PrimitiveType.Triangles, surfaceArray);
|
||||
|
||||
var maskTexIndex = model.GetTextureIndices()[drawableIndex];
|
||||
var texture = maskTexIndex >= 0 && maskTexIndex < model.Textures.Length ? model.Textures[maskTexIndex] : null;
|
||||
|
||||
var shader = ResolveShader("2d_cubism_mask");
|
||||
if (shader == null) return mesh;
|
||||
|
||||
var mat = new ShaderMaterial { Shader = shader };
|
||||
mat.SetShaderParameter("channel", ChannelFromSlot(channelSlot));
|
||||
if (texture != null)
|
||||
mat.SetShaderParameter("tex_main", texture);
|
||||
|
||||
mi2d.Mesh = mesh;
|
||||
mi2d.Material = mat;
|
||||
return mesh;
|
||||
}
|
||||
|
||||
private static Color ChannelFromSlot(int slot) => slot switch
|
||||
{
|
||||
0 => new Color(1f, 0f, 0f, 0f),
|
||||
1 => new Color(0f, 1f, 0f, 0f),
|
||||
2 => new Color(0f, 0f, 1f, 0f),
|
||||
_ => new Color(0f, 0f, 0f, 1f)
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Per-frame render update. Resets dynamic flags, then iterates all
|
||||
/// mesh instances in render order, applying visibility, opacity, and
|
||||
/// vertex position changes based on CubismCore dynamic flags.
|
||||
/// </summary>
|
||||
public unsafe void UpdateModel()
|
||||
{
|
||||
if (_model == null || _meshInstances == null) return;
|
||||
|
||||
var dynamicFlags = _model.GetDynamicFlags();
|
||||
var opacities = _model.GetOpacities();
|
||||
var renderOrders = _model.GetRenderOrders();
|
||||
var multiplyColors = _model.GetMultiplyColors();
|
||||
var screenColors = _model.GetScreenColors();
|
||||
var dc = _model.DrawableCount;
|
||||
|
||||
for (int di = 0; di < dc; di++)
|
||||
{
|
||||
var mi = _meshInstances[di];
|
||||
if (mi == null) continue;
|
||||
var mat = _materials[di];
|
||||
|
||||
var flags = dynamicFlags[di];
|
||||
|
||||
if ((flags & CubismCoreBindings.VisibilityDidChange) != 0)
|
||||
mi.Visible = (flags & CubismCoreBindings.IsVisible) != 0;
|
||||
|
||||
mi.ZIndex = renderOrders[di];
|
||||
|
||||
var mc = multiplyColors[di];
|
||||
mat.SetShaderParameter("color_multiply", new Color(mc.X, mc.Y, mc.Z, mc.W));
|
||||
|
||||
var sc = screenColors[di];
|
||||
mat.SetShaderParameter("color_screen", new Color(sc.X, sc.Y, sc.Z, sc.W));
|
||||
|
||||
if ((flags & CubismCoreBindings.OpacityDidChange) != 0)
|
||||
mat.SetShaderParameter("color_base", new Color(1f, 1f, 1f, opacities[di]));
|
||||
|
||||
if ((flags & CubismCoreBindings.VertexPositionsDidChange) != 0)
|
||||
{
|
||||
UpdateMeshVertices(di, mi.Mesh as ArrayMesh);
|
||||
}
|
||||
}
|
||||
|
||||
for (int gi = 0; gi < _maskGroups.Count; gi++)
|
||||
{
|
||||
var group = _maskGroups[gi];
|
||||
if (group.MaskDrawableIndices == null) continue;
|
||||
for (int mi = 0; mi < group.MaskDrawableIndices.Length; mi++)
|
||||
{
|
||||
var di = group.MaskDrawableIndices[mi];
|
||||
if (di < 0 || di >= dc) continue;
|
||||
var mFlags = dynamicFlags[di];
|
||||
if ((mFlags & CubismCoreBindings.VertexPositionsDidChange) == 0) continue;
|
||||
UpdateMaskMeshVertices(di, group.MaskArrayMeshes[mi], gi);
|
||||
}
|
||||
}
|
||||
|
||||
_model.ResetDynamicFlags();
|
||||
}
|
||||
|
||||
private unsafe void UpdateMeshVertices(int drawableIndex, ArrayMesh mesh)
|
||||
{
|
||||
if (mesh == null) return;
|
||||
|
||||
var vCount = _model.GetVertexCounts()[drawableIndex];
|
||||
var srcVerts = _model.GetVertexPositions()[drawableIndex];
|
||||
var ppu = _model.PixelsPerUnit;
|
||||
|
||||
int stride = 8;
|
||||
byte[] data = new byte[vCount * stride];
|
||||
|
||||
float minX = float.MaxValue, minY = float.MaxValue, maxX = float.MinValue, maxY = float.MinValue;
|
||||
for (int v = 0; v < vCount; v++)
|
||||
{
|
||||
float x = srcVerts[v].X * ppu;
|
||||
float y = -srcVerts[v].Y * ppu;
|
||||
BinaryPrimitives.WriteSingleLittleEndian(data.AsSpan(v * stride), x);
|
||||
BinaryPrimitives.WriteSingleLittleEndian(data.AsSpan(v * stride + 4), y);
|
||||
|
||||
if (x < minX) minX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y > maxY) maxY = y;
|
||||
}
|
||||
|
||||
mesh.SurfaceUpdateVertexRegion(0, 0, data);
|
||||
|
||||
mesh.CustomAabb = new Aabb(
|
||||
new Vector3(minX, minY, 0),
|
||||
new Vector3(maxX - minX, maxY - minY, 0));
|
||||
}
|
||||
|
||||
private unsafe void UpdateMaskMeshVertices(int drawableIndex, ArrayMesh mesh, int groupIndex)
|
||||
{
|
||||
if (mesh == null || groupIndex < 0 || groupIndex >= _maskGroups.Count) return;
|
||||
|
||||
var group = _maskGroups[groupIndex];
|
||||
float offsetX = group.OffsetX;
|
||||
float offsetY = group.OffsetY;
|
||||
var ppu = _model.PixelsPerUnit;
|
||||
|
||||
var vCount = _model.GetVertexCounts()[drawableIndex];
|
||||
var srcVerts = _model.GetVertexPositions()[drawableIndex];
|
||||
|
||||
int stride = 8;
|
||||
byte[] data = new byte[vCount * stride];
|
||||
|
||||
float vMinX = float.MaxValue, vMinY = float.MaxValue, vMaxX = float.MinValue, vMaxY = float.MinValue;
|
||||
for (int v = 0; v < vCount; v++)
|
||||
{
|
||||
float x = srcVerts[v].X * ppu - offsetX;
|
||||
float y = -srcVerts[v].Y * ppu - offsetY;
|
||||
BinaryPrimitives.WriteSingleLittleEndian(data.AsSpan(v * stride), x);
|
||||
BinaryPrimitives.WriteSingleLittleEndian(data.AsSpan(v * stride + 4), y);
|
||||
|
||||
if (x < vMinX) vMinX = x;
|
||||
if (y < vMinY) vMinY = y;
|
||||
if (x > vMaxX) vMaxX = x;
|
||||
if (y > vMaxY) vMaxY = y;
|
||||
}
|
||||
|
||||
mesh.SurfaceUpdateVertexRegion(0, 0, data);
|
||||
|
||||
mesh.CustomAabb = new Aabb(
|
||||
new Vector3(vMinX, vMinY, 0),
|
||||
new Vector3(vMaxX - vMinX, vMaxY - vMinY, 0));
|
||||
}
|
||||
|
||||
private void ClearMeshes()
|
||||
{
|
||||
if (_meshInstances != null)
|
||||
{
|
||||
for (int i = 0; i < _meshInstances.Length; i++)
|
||||
{
|
||||
var mi = _meshInstances[i];
|
||||
if (mi != null) mi.QueueFree();
|
||||
}
|
||||
}
|
||||
_meshInstances = null;
|
||||
_materials = null;
|
||||
_drawableMaskGroupIndex = null;
|
||||
_drawableMaskChannel = null;
|
||||
|
||||
foreach (var group in _maskGroups)
|
||||
group.Viewport.QueueFree();
|
||||
_maskGroups.Clear();
|
||||
}
|
||||
|
||||
private unsafe Shader ResolveShaderForDrawable(int drawableIndex, byte* constantFlags, int* maskCounts)
|
||||
{
|
||||
var flags = constantFlags[drawableIndex];
|
||||
var hasMask = maskCounts[drawableIndex] > 0;
|
||||
var isInverted = (flags & CubismCoreBindings.IsInvertedMask) != 0;
|
||||
var isAdditive = (flags & CubismCoreBindings.BlendAdditive) != 0;
|
||||
var isMultiplicative = (flags & CubismCoreBindings.BlendMultiplicative) != 0;
|
||||
|
||||
string name;
|
||||
|
||||
if (!hasMask)
|
||||
{
|
||||
name = isAdditive ? "2d_cubism_norm_add" :
|
||||
isMultiplicative ? "2d_cubism_norm_mul" : "2d_cubism_norm_mix";
|
||||
}
|
||||
else if (!isInverted)
|
||||
{
|
||||
name = isAdditive ? "2d_cubism_mask_add" :
|
||||
isMultiplicative ? "2d_cubism_mask_mul" : "2d_cubism_mask_mix";
|
||||
}
|
||||
else
|
||||
{
|
||||
name = isAdditive ? "2d_cubism_mask_add_inv" :
|
||||
isMultiplicative ? "2d_cubism_mask_mul_inv" : "2d_cubism_mask_mix_inv";
|
||||
}
|
||||
|
||||
return ResolveShader(name);
|
||||
}
|
||||
|
||||
private static Shader ResolveShader(string name)
|
||||
{
|
||||
try
|
||||
{
|
||||
var shader = Cthangover.Core.Mods.ModManager.Instance?.ResolveShader(name);
|
||||
if (shader != null)
|
||||
{
|
||||
GameLogger.Log("LIVE2D", $"ResolveShader: '{name}' OK");
|
||||
return shader;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
GameLogger.Log("LIVE2D", $"ResolveShader: '{name}' threw {ex.Message}", LogLevel.Error);
|
||||
}
|
||||
GameLogger.Log("LIVE2D", $"ResolveShader: '{name}' FAILED", LogLevel.Error);
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int NextPowerOfTwo(int v)
|
||||
{
|
||||
if (v <= 0) return 1;
|
||||
v--;
|
||||
v |= v >> 1;
|
||||
v |= v >> 2;
|
||||
v |= v >> 4;
|
||||
v |= v >> 8;
|
||||
v |= v >> 16;
|
||||
return v + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using System;
|
||||
|
||||
namespace Cthangover.Live2D.Cubism.Framework
|
||||
{
|
||||
/// <summary>
|
||||
/// Smooth target-point tracking for face/eye movement parameters.
|
||||
/// Drives the ParamAngleX/Y and ParamEyeBallX/Y model parameters in
|
||||
/// <see cref="CubismModelNode"/>.
|
||||
///
|
||||
/// Uses a velocity-based smoothing approach: each update computes a
|
||||
/// desired velocity toward the target, applies acceleration limits,
|
||||
/// enforces a maximum safe approach speed to prevent overshoot near
|
||||
/// the target, and integrates velocity into position.
|
||||
///
|
||||
/// The tracking is frame-rate independent — delta time is scaled by
|
||||
/// a factor of 30 (matching Cubism's internal 30fps reference).
|
||||
/// Acceleration and max velocity are clamped per Cubism SDK conventions
|
||||
/// (FaceParamMaxV = 40, FrameToMaxSpeed = 4.5).
|
||||
/// </summary>
|
||||
public class CubismTargetPoint
|
||||
{
|
||||
private const float FaceParamMaxV = 40f;
|
||||
private const float FrameToMaxSpeed = 4.5f;
|
||||
|
||||
private float _faceTargetX, _faceTargetY;
|
||||
private float _faceX, _faceY;
|
||||
private float _faceVX, _faceVY;
|
||||
private float _lastTimeSeconds;
|
||||
private float _userTimeSeconds;
|
||||
|
||||
/// <summary>Current smoothed X position.</summary>
|
||||
public float X => _faceX;
|
||||
|
||||
/// <summary>Current smoothed Y position.</summary>
|
||||
public float Y => _faceY;
|
||||
|
||||
/// <summary>
|
||||
/// Sets the target point immediately. The actual position
|
||||
/// will smoothly approach this target over subsequent calls to <see cref="Update"/>.
|
||||
/// </summary>
|
||||
public void Set(float x, float y)
|
||||
{
|
||||
_faceTargetX = x;
|
||||
_faceTargetY = y;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advances the smoothing simulation by one frame.
|
||||
/// Computes delta-time-weighted velocity toward target, applies
|
||||
/// acceleration clamping and safe approach speed limiting, then
|
||||
/// integrates position.
|
||||
///
|
||||
/// Called each frame from <see cref="CubismModelNode.OnUpdate"/>
|
||||
/// before injecting face-tracking parameters.
|
||||
/// </summary>
|
||||
public void Update(float deltaTimeSeconds)
|
||||
{
|
||||
_userTimeSeconds += deltaTimeSeconds;
|
||||
var deltaTimeWeight = (_userTimeSeconds - _lastTimeSeconds) * 30f;
|
||||
_lastTimeSeconds = _userTimeSeconds;
|
||||
|
||||
if (deltaTimeWeight <= 0f) return;
|
||||
|
||||
var maxV = FaceParamMaxV / 30f;
|
||||
var maxA = deltaTimeWeight * maxV / FrameToMaxSpeed;
|
||||
|
||||
var dx = _faceTargetX - _faceX;
|
||||
var dy = _faceTargetY - _faceY;
|
||||
var distance = MathF.Sqrt(dx * dx + dy * dy);
|
||||
if (distance < 0.01f) return;
|
||||
|
||||
var vx = maxV * dx / distance;
|
||||
var vy = maxV * dy / distance;
|
||||
|
||||
var ax = vx - _faceVX;
|
||||
var ay = vy - _faceVY;
|
||||
var aLen = MathF.Sqrt(ax * ax + ay * ay);
|
||||
if (aLen > 0f)
|
||||
{
|
||||
var clampedA = MathF.Min(aLen, maxA);
|
||||
ax = ax * clampedA / aLen;
|
||||
ay = ay * clampedA / aLen;
|
||||
}
|
||||
|
||||
var maxSafeV = 0.5f * (MathF.Sqrt(maxA * maxA + 8f * maxA * distance) - maxA);
|
||||
var vLen = MathF.Sqrt(_faceVX * _faceVX + _faceVY * _faceVY);
|
||||
if (vLen > maxSafeV && vLen > 0f)
|
||||
{
|
||||
var scale = maxSafeV / vLen;
|
||||
_faceVX *= scale;
|
||||
_faceVY *= scale;
|
||||
}
|
||||
|
||||
_faceVX += ax;
|
||||
_faceVY += ay;
|
||||
_faceX += _faceVX;
|
||||
_faceY += _faceVY;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using Godot;
|
||||
using Cthangover.Live2D.Cubism.Framework;
|
||||
|
||||
namespace Cthangover.Live2D.Cubism.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration for <see cref="Live2DService.CreateModel(Node, Live2DModelConfig)"/>.
|
||||
/// When <see cref="Position"/> is set, <see cref="ScaleFit"/> defaults to
|
||||
/// <see cref="FitMode.None"/> to disable automatic layout, giving manual
|
||||
/// coordinates full control. Otherwise <see cref="FitMode.FitAuto"/> is used.
|
||||
/// </summary>
|
||||
public class Live2DModelConfig
|
||||
{
|
||||
public string ModelPath;
|
||||
public string ModId;
|
||||
public string NodeName;
|
||||
public Vector2? Position;
|
||||
public Vector2? Scale;
|
||||
public float AnchorX = 0.5f;
|
||||
public float AnchorY = 0.5f;
|
||||
public FitMode ScaleFit = FitMode.FitAuto;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// High-level factory for creating Live2D model nodes and attaching them
|
||||
/// to the scene tree. Encapsulates the boilerplate of instantiating
|
||||
/// <see cref="CubismModelNode"/>, adding to a parent, and calling
|
||||
/// <see cref="CubismModelNode.LoadModelFromMod"/>.
|
||||
/// </summary>
|
||||
public static class Live2DService
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a <see cref="CubismModelNode"/>, adds it to <paramref name="parent"/>,
|
||||
/// and loads the model from the specified mod.
|
||||
/// </summary>
|
||||
/// <param name="parent">Parent node to attach the model to.</param>
|
||||
/// <param name="modelPath">Path to the .model3.json file, relative to the mod root.</param>
|
||||
/// <param name="modId">Registered mod ID containing the model assets.</param>
|
||||
/// <param name="nodeName">Godot node name for the model instance.</param>
|
||||
/// <returns>The created and loaded model node.</returns>
|
||||
public static CubismModelNode CreateModel(
|
||||
Node parent,
|
||||
string modelPath,
|
||||
string nodeName,
|
||||
string modId = null)
|
||||
{
|
||||
return CreateModel(parent, new Live2DModelConfig
|
||||
{
|
||||
ModelPath = modelPath,
|
||||
ModId = modId,
|
||||
NodeName = nodeName
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a <see cref="CubismModelNode"/> using full configuration.
|
||||
/// When <see cref="Live2DModelConfig.Position"/> is provided,
|
||||
/// <see cref="Live2DModelConfig.ScaleFit"/> is forced to
|
||||
/// <see cref="FitMode.None"/> — manual coordinates take priority over
|
||||
/// the automatic <see cref="CubismModelNode.DeferredApplyLayout"/>.
|
||||
/// </summary>
|
||||
public static CubismModelNode CreateModel(Node parent, Live2DModelConfig config)
|
||||
{
|
||||
var fitMode = config.Position.HasValue ? FitMode.None : config.ScaleFit;
|
||||
|
||||
var model = new CubismModelNode
|
||||
{
|
||||
Name = config.NodeName,
|
||||
AnchorX = config.AnchorX,
|
||||
AnchorY = config.AnchorY,
|
||||
ScaleFit = fitMode
|
||||
};
|
||||
|
||||
parent.AddChild(model);
|
||||
|
||||
if (config.Position.HasValue)
|
||||
model.Position = config.Position.Value;
|
||||
if (config.Scale.HasValue)
|
||||
model.Scale = config.Scale.Value;
|
||||
|
||||
model.LoadModelFromMod(config.ModelPath, config.ModId);
|
||||
return model;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<AssemblyName>Live2D</AssemblyName>
|
||||
<RootNamespace>Live2D.Cubism</RootNamespace>
|
||||
<Nullable>disable</Nullable>
|
||||
<LangVersion>12</LangVersion>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\Cthangover.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,37 @@
|
||||
@echo off
|
||||
setlocal
|
||||
|
||||
set "PROJECT_DIR=%~dp0"
|
||||
set "OUTPUT_DIR=%PROJECT_DIR%bin\Debug\net8.0"
|
||||
set "TARGET_DIR=%PROJECT_DIR%..\dll"
|
||||
set "CSPROJ_PATH=%PROJECT_DIR%Live2D.csproj"
|
||||
|
||||
echo Building %CSPROJ_PATH% ...
|
||||
dotnet build "%CSPROJ_PATH%"
|
||||
if %ERRORLEVEL% neq 0 (
|
||||
echo BUILD FAILED
|
||||
goto :pause_and_exit
|
||||
)
|
||||
|
||||
echo Build succeeded. Copying files to %TARGET_DIR% ...
|
||||
|
||||
set "ERRORS=0"
|
||||
for %%f in (Live2D.dll Live2D.pdb Live2D.deps.json) do (
|
||||
if not exist "%OUTPUT_DIR%\%%f" (
|
||||
echo ERROR: Source file not found: %OUTPUT_DIR%\%%f
|
||||
set "ERRORS=1"
|
||||
) else (
|
||||
copy /y "%OUTPUT_DIR%\%%f" "%TARGET_DIR%\%%f" >nul
|
||||
echo Copied: %%f
|
||||
)
|
||||
)
|
||||
|
||||
if %ERRORS% neq 0 (
|
||||
echo COPY ERRORS DETECTED
|
||||
goto :pause_and_exit
|
||||
)
|
||||
|
||||
echo Done.
|
||||
|
||||
:pause_and_exit
|
||||
pause
|
||||
@@ -0,0 +1,62 @@
|
||||
using Cthangover.Core.Actions;
|
||||
using Cthangover.Core.Scenes;
|
||||
using Cthangover.Core.Utils;
|
||||
using Godot;
|
||||
using Cthangover.Live2D.Cubism.Services;
|
||||
|
||||
namespace Mods.Live2D
|
||||
{
|
||||
public class AddLive2DModelAction : IScenarioAction
|
||||
{
|
||||
public string Name => "add_live2d_model";
|
||||
|
||||
public void Run(IActionContext ctx)
|
||||
{
|
||||
var modelPath = ctx.GetParam("model_path");
|
||||
var modId = ctx.GetParam("mod_id");
|
||||
var nodeName = ctx.GetParam("node_name") ?? "Live2DModel";
|
||||
|
||||
if (string.IsNullOrEmpty(modelPath))
|
||||
{
|
||||
ctx.LogWarning("LIVE2D", "AddLive2DModelAction: missing 'model_path'");
|
||||
return;
|
||||
}
|
||||
|
||||
var viewBox = SceneContextNode.FindNode<Control>("ViewBox");
|
||||
if (viewBox == null)
|
||||
{
|
||||
ctx.LogWarning("LIVE2D", "AddLive2DModelAction: ViewBox not found");
|
||||
return;
|
||||
}
|
||||
|
||||
var existing = viewBox.GetNodeOrNull<Node>(nodeName);
|
||||
if (existing != null)
|
||||
{
|
||||
ctx.Log("LIVE2D", $"AddLive2DModelAction: '{nodeName}' already exists, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
var config = new Live2DModelConfig
|
||||
{
|
||||
ModelPath = modelPath,
|
||||
ModId = modId,
|
||||
NodeName = nodeName
|
||||
};
|
||||
|
||||
var xStr = ctx.GetParam("x");
|
||||
var yStr = ctx.GetParam("y");
|
||||
if (!string.IsNullOrEmpty(xStr) && !string.IsNullOrEmpty(yStr) &&
|
||||
float.TryParse(xStr, out var x) && float.TryParse(yStr, out var y))
|
||||
{
|
||||
config.Position = new Vector2(x, y);
|
||||
}
|
||||
|
||||
var scaleStr = ctx.GetParam("scale");
|
||||
if (!string.IsNullOrEmpty(scaleStr) && float.TryParse(scaleStr, out var s))
|
||||
config.Scale = new Vector2(s, s);
|
||||
|
||||
Live2DService.CreateModel(viewBox, config);
|
||||
ctx.Log("LIVE2D", $"AddLive2DModelAction: loaded '{modelPath}' from mod '{modId}' as '{nodeName}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user