This commit is contained in:
2026-09-10 10:59:37 +03:00
commit 5408d711b1
32 changed files with 5116 additions and 0 deletions
+37
View File
@@ -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
+192
View File
@@ -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": ""
}
}
}
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+10
View File
@@ -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"]
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

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;
}
}
}
}
+202
View File
@@ -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
};
}
}
+218
View File
@@ -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;
}
}
}
+14
View File
@@ -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>
+37
View File
@@ -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
+62
View File
@@ -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}'");
}
}
}