commit 5408d711b143531e2f6931ce1675174e1e72101a Author: iisaev Date: Thu Sep 10 10:59:37 2026 +0300 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c301ca8 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/dll/Live2D.deps.json b/dll/Live2D.deps.json new file mode 100644 index 0000000..7a4e42a --- /dev/null +++ b/dll/Live2D.deps.json @@ -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": "" + } + } +} \ No newline at end of file diff --git a/dll/Live2D.dll b/dll/Live2D.dll new file mode 100644 index 0000000..40bc7e9 Binary files /dev/null and b/dll/Live2D.dll differ diff --git a/dll/Live2D.pdb b/dll/Live2D.pdb new file mode 100644 index 0000000..fc6edba Binary files /dev/null and b/dll/Live2D.pdb differ diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..920876e --- /dev/null +++ b/manifest.json @@ -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"] +} diff --git a/mod.png b/mod.png new file mode 100644 index 0000000..d112f2e Binary files /dev/null and b/mod.png differ diff --git a/native/Live2DCubismCore.dll b/native/Live2DCubismCore.dll new file mode 100644 index 0000000..dbb4254 Binary files /dev/null and b/native/Live2DCubismCore.dll differ diff --git a/source_code/Cubism/Core/CubismCoreBindings.cs b/source_code/Cubism/Core/CubismCoreBindings.cs new file mode 100644 index 0000000..1deec03 --- /dev/null +++ b/source_code/Cubism/Core/CubismCoreBindings.cs @@ -0,0 +1,206 @@ +using System; +using System.Runtime.InteropServices; + +namespace Cthangover.Live2D.Cubism.Core +{ + /// + /// 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. + /// wraps this layer into a managed API with proper + /// memory alignment and lifetime management. + /// + public static class CubismCoreBindings + { + /// Name of the native DLL loaded at runtime. + public const string DllName = "Live2DCubismCore"; + + /// Required byte alignment for .moc3 data buffers before revival. + public const int AlignofMoc = 64; + + /// Required byte alignment for model instance memory. + public const int AlignofModel = 16; + + /// Flag: drawable uses additive blending. + public const byte BlendAdditive = 1 << 0; + + /// Flag: drawable uses multiplicative blending. + public const byte BlendMultiplicative = 1 << 1; + + /// Flag: drawable is double-sided (no back-face culling). + public const byte IsDoubleSided = 1 << 2; + + /// Flag: drawable's mask is inverted. + public const byte IsInvertedMask = 1 << 3; + + /// Dynamic flag: drawable is currently visible. + public const byte IsVisible = 1 << 0; + + /// Dynamic flag: drawable visibility changed this frame. + public const byte VisibilityDidChange = 1 << 1; + + /// Dynamic flag: drawable opacity changed this frame. + public const byte OpacityDidChange = 1 << 2; + + /// Dynamic flag: drawable draw order changed this frame. + public const byte DrawOrderDidChange = 1 << 3; + + /// Dynamic flag: drawable render order changed this frame. + public const byte RenderOrderDidChange = 1 << 4; + + /// Dynamic flag: vertex positions changed this frame. + public const byte VertexPositionsDidChange = 1 << 5; + + /// Dynamic flag: blend color changed this frame. + public const byte BlendColorDidChange = 1 << 6; + + /// 2D vector used in CubismCore struct layouts. + [StructLayout(LayoutKind.Sequential)] + public struct csmVector2 { public float X; public float Y; } + + /// 4D vector used in CubismCore color struct layouts. + [StructLayout(LayoutKind.Sequential)] + public struct csmVector4 { public float X; public float Y; public float Z; public float W; } + + /// Returns the CubismCore SDK version number. + [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] + public static extern uint csmGetVersion(); + + /// Revives a .moc3 binary from an aligned memory buffer. Returns a moc handle or IntPtr.Zero. + [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] + public static extern unsafe IntPtr csmReviveMocInPlace(void* address, uint size); + + /// Queries the memory size needed to initialize a model from the given moc. + [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] + public static extern unsafe uint csmGetSizeofModel(IntPtr moc); + + /// Creates a model instance inside the provided aligned buffer. Returns model handle or IntPtr.Zero. + [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] + public static extern unsafe IntPtr csmInitializeModelInPlace(IntPtr moc, void* address, uint size); + + /// Advance the model by one frame — updates physics, parameter-to-vertex mapping, etc. + [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] + public static extern unsafe void csmUpdateModel(IntPtr model); + + /// Reads the model's canvas dimensions and pixels-per-unit scale. + [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] + public static extern unsafe void csmReadCanvasInfo(IntPtr model, csmVector2* outSizeInPixels, csmVector2* outOriginInPixels, float* outPixelsPerUnit); + + /// Returns the number of parameters in the model. + [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] + public static extern unsafe int csmGetParameterCount(IntPtr model); + + /// Returns a pointer array of null-terminated parameter ID strings. + [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] + public static extern unsafe IntPtr csmGetParameterIds(IntPtr model); + + /// Returns a float array of current parameter values. + [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] + public static extern unsafe float* csmGetParameterValues(IntPtr model); + + /// Returns a float array of parameter minimum bounds. + [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] + public static extern unsafe float* csmGetParameterMinimumValues(IntPtr model); + + /// Returns a float array of parameter maximum bounds. + [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] + public static extern unsafe float* csmGetParameterMaximumValues(IntPtr model); + + /// Returns a float array of parameter default values. + [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] + public static extern unsafe float* csmGetParameterDefaultValues(IntPtr model); + + /// Returns the number of drawables (renderable mesh parts) in the model. + [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] + public static extern unsafe int csmGetDrawableCount(IntPtr model); + + /// Returns byte flags array for constant (immutable) drawable properties. + [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] + public static extern unsafe byte* csmGetDrawableConstantFlags(IntPtr model); + + /// Returns byte flags array for per-frame dynamic drawable state. + [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] + public static extern unsafe byte* csmGetDrawableDynamicFlags(IntPtr model); + + /// Returns an int array mapping each drawable to a texture index. + [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] + public static extern unsafe int* csmGetDrawableTextureIndices(IntPtr model); + + /// Returns an int array of vertex counts per drawable. + [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] + public static extern unsafe int* csmGetDrawableVertexCounts(IntPtr model); + + /// Returns a pointer array of csmVector2 arrays (vertex positions per drawable). + [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] + public static extern unsafe csmVector2** csmGetDrawableVertexPositions(IntPtr model); + + /// Returns a pointer array of csmVector2 arrays (UVs per drawable). + [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] + public static extern unsafe csmVector2** csmGetDrawableVertexUvs(IntPtr model); + + /// Returns an int array of index counts per drawable. + [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] + public static extern unsafe int* csmGetDrawableIndexCounts(IntPtr model); + + /// Returns a pointer array of ushort index arrays per drawable. + [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] + public static extern unsafe ushort** csmGetDrawableIndices(IntPtr model); + + /// Returns a float array of per-drawable opacities. + [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] + public static extern unsafe float* csmGetDrawableOpacities(IntPtr model); + + /// Returns draw order indices — maps render order slot to drawable index. + [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] + public static extern unsafe int* csmGetDrawableDrawOrders(IntPtr model); + + /// Returns render order indices (newer Cubism format). + [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] + public static extern unsafe int* csmGetRenderOrders(IntPtr model); + + /// Resets dynamic flags for all drawables, preparing for the next frame. + [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] + public static extern unsafe void csmResetDrawableDynamicFlags(IntPtr model); + + /// Returns an int array of mask counts per drawable. + [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] + public static extern unsafe int* csmGetDrawableMaskCounts(IntPtr model); + + /// Returns a pointer array of int arrays (mask drawable indices per drawable). + [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] + public static extern unsafe int** csmGetDrawableMasks(IntPtr model); + + /// Returns a csmVector4 array of per-drawable multiply colors. + [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] + public static extern unsafe csmVector4* csmGetDrawableMultiplyColors(IntPtr model); + + /// Returns a csmVector4 array of per-drawable screen colors. + [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] + public static extern unsafe csmVector4* csmGetDrawableScreenColors(IntPtr model); + + /// Returns the number of parts in the model. + [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] + public static extern unsafe int csmGetPartCount(IntPtr model); + + /// Returns a pointer array of part ID strings. + [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] + public static extern unsafe IntPtr csmGetPartIds(IntPtr model); + + /// Returns a float array of per-part opacities. + [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] + public static extern unsafe float* csmGetPartOpacities(IntPtr model); + + /// Checks if the given .moc3 data passes consistency validation. + [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] + public static extern unsafe int csmHasMocConsistency(void* address, uint size); + + /// Returns the latest .moc3 format version supported by the DLL. + [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] + public static extern unsafe uint csmGetLatestMocVersion(); + + /// Returns the .moc3 format version of the given binary data. + [DllImport(DllName, CallingConvention = CallingConvention.StdCall)] + public static extern unsafe uint csmGetMocVersion(void* address, uint size); + } +} diff --git a/source_code/Cubism/Framework/ACubismMotion.cs b/source_code/Cubism/Framework/ACubismMotion.cs new file mode 100644 index 0000000..b05da30 --- /dev/null +++ b/source_code/Cubism/Framework/ACubismMotion.cs @@ -0,0 +1,182 @@ +using System; + +namespace Cthangover.Live2D.Cubism.Framework +{ + /// + /// Abstract base for all Live2D Cubism motions (animations and expressions). + /// Provides fade-in/fade-out timing, looping, weight blending, and event + /// callbacks shared across and + /// . + /// + /// Motions are managed by , which + /// calls on start, then + /// and + /// every frame. + /// + public abstract class ACubismMotion + { + /// + /// Callback invoked when the motion finishes (non-looping only). + /// The finished motion is passed as the argument. + /// + public delegate void FinishedMotionCallback(ACubismMotion motion); + + /// + /// Callback invoked when the motion begins playback. + /// Called from right after + /// the entry's times are configured. + /// + 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; + + /// + /// Duration of the fade-in transition in seconds. + /// A negative value means the default (1 second) will be used + /// by and . + /// + public float FadeInSeconds { get => _fadeInSeconds; set => _fadeInSeconds = value; } + + /// + /// Duration of the fade-out transition in seconds. + /// A negative value means the default (1 second) will be used. + /// + public float FadeOutSeconds { get => _fadeOutSeconds; set => _fadeOutSeconds = value; } + + /// + /// Overall motion weight (0..1). Multiplied with the fade-in/fade-out + /// factors in to produce the final blend weight. + /// + public float Weight { get => _weight; set => _weight = value; } + + /// + /// Time offset in seconds applied to the motion's start time. + /// Allows starting playback from an arbitrary point. + /// + public float OffsetSeconds { get => _offsetSeconds; set => _offsetSeconds = value; } + + /// + /// Whether the motion loops. When enabled, + /// wraps timeOffset back to the beginning after the duration elapses. + /// + public bool IsLoop { get => _isLoop; set => _isLoop = value; } + + /// + /// When true and looping, the motion performs a fade-in at each loop + /// restart. When false, the fade-in is skipped on subsequent loops. + /// + public bool IsLoopFadeIn { get => _isLoopFadeIn; set => _isLoopFadeIn = value; } + + /// + /// Invoked when the motion finishes (non-looping only). + /// Automatically called by + /// when timeOffset exceeds duration. + /// + public FinishedMotionCallback OnFinishedMotion { get; set; } + + /// + /// Invoked when the motion begins. Fires at the start of + /// after the entry is initialized. + /// + public BeganMotionCallback OnBeganMotion { get; set; } + + /// + /// Returns the total motion duration in seconds. + /// Override in to return the motion3.json + /// duration, or -1 for looping motions. + /// + public virtual float GetDuration() => -1f; + + /// + /// Returns the loop cycle duration in seconds. + /// Override in to return the full duration + /// even when looping is active. + /// + public virtual float GetLoopDuration() => -1f; + + /// + /// Initializes a queue entry with start/end times based on current + /// , , + /// and . Fires . + /// + 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); + } + + /// + /// Computes the final frame weight = x fadeIn x fadeOut. + /// Fade curves use the sine easing function from . + /// + public float UpdateFadeWeight(CubismMotionQueueEntry entry, float userTimeSeconds) + { + var fadeIn = GetFadeIn(entry, userTimeSeconds); + var fadeOut = GetFadeOut(entry, userTimeSeconds); + return _weight * fadeIn * fadeOut; + } + + private float GetFadeIn(CubismMotionQueueEntry entry, float userTimeSeconds) + { + var fadeInTime = _fadeInSeconds >= 0f ? _fadeInSeconds : 1f; + if (fadeInTime <= 0f) return 1f; + var t = (userTimeSeconds - entry.FadeInStartTime) / fadeInTime; + return CubismMath.GetEasingSine(t); + } + + private float GetFadeOut(CubismMotionQueueEntry entry, float userTimeSeconds) + { + var fadeOutTime = _fadeOutSeconds >= 0f ? _fadeOutSeconds : 1f; + if (fadeOutTime <= 0f) return 1f; + var t = (entry.EndTime - userTimeSeconds) / fadeOutTime; + return CubismMath.GetEasingSine(t); + } + + /// + /// Sets the queue entry's EndTime based on loop state and duration. + /// Looping motions get EndTime = -1 (infinite). + /// + protected void UpdateEndTime(CubismMotionQueueEntry entry, float duration) + { + entry.EndTime = _isLoop ? -1f : entry.StartTime + duration; + } + + /// + /// Recalculates EndTime when changes mid-playback. + /// Called by every frame + /// to detect loop state transitions. + /// + protected void AdjustEndTime(CubismMotionQueueEntry entry, float duration) + { + if (_isLoop != _previousLoopState) + { + _previousLoopState = _isLoop; + UpdateEndTime(entry, duration); + } + } + + /// + /// Applies motion parameter values to the model for the current frame. + /// Called every frame by . + /// + /// The native Cubism model to modify. + /// Elapsed user time in seconds. + /// Blend weight already multiplied by fade factors. + /// The queue entry containing start/end timing. + public abstract void DoUpdateParameters( + CubismNativeModel model, + float userTimeSeconds, + float weight, + CubismMotionQueueEntry entry); + } +} diff --git a/source_code/Cubism/Framework/CubismBreath.cs b/source_code/Cubism/Framework/CubismBreath.cs new file mode 100644 index 0000000..bdc2b79 --- /dev/null +++ b/source_code/Cubism/Framework/CubismBreath.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; + +namespace Cthangover.Live2D.Cubism.Framework +{ + /// + /// 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. + /// + public struct BreathParameterData + { + /// Model parameter ID to apply the breath to. + public string ParameterId; + + /// Base offset added to the sinusoidal value. + public float Offset; + + /// Amplitude of the sine wave. + public float Peak; + + /// Full cycle duration in seconds. + public float Cycle; + + /// Multiplier applied to the final breath value. + public float Weight; + } + + /// + /// Applies sinusoidal breathing animation to model parameters. + /// Operates additively on top of existing parameter values — + /// the breath oscillation is multiplied by + /// and added to the current parameter value every frame. + /// + /// Configured with a list of entries, + /// each targeting a specific parameter ID. Called from + /// alongside other effect layers + /// (eye blink, pose, physics). + /// + public class CubismBreath + { + private readonly List _parameters = new(); + private float _currentTime; + + /// + /// Replaces the current breath parameter set. + /// Previously configured parameters are cleared. + /// + public void SetParameters(List parameters) + { + _parameters.Clear(); + _parameters.AddRange(parameters); + } + + /// + /// Applies the breath oscillation to matching model parameters. + /// Accumulates internal time and evaluates a sine wave per parameter: + /// offset + peak * sin(2 * pi * time / cycle), then adds + /// the weighted result to the native parameter value. + /// + 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; + } + } + } + } +} diff --git a/source_code/Cubism/Framework/CubismExpressionMotion.cs b/source_code/Cubism/Framework/CubismExpressionMotion.cs new file mode 100644 index 0000000..16b4e05 --- /dev/null +++ b/source_code/Cubism/Framework/CubismExpressionMotion.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; + +namespace Cthangover.Live2D.Cubism.Framework +{ + /// + /// Blend mode for expression parameters. + /// Determines how the expression value is combined with the existing + /// parameter value during animation. + /// + public enum ExpressionBlendType { Additive = 0, Multiply = 1, Overwrite = 2 } + + /// + /// A single parameter entry in an expression motion. + /// Targets a model parameter by ID with a specific blend type and value. + /// + public struct ExpressionParameter + { + /// Target model parameter ID. + public string ParameterId; + + /// How to combine this value with existing parameter data. + public ExpressionBlendType BlendType; + + /// The target value (interpretation depends on ). + public float Value; + } + + /// + /// Expression motion, parsed from a Cubism .exp3.json file. + /// Inherits fade and weight management from . + /// + /// Expressions are single-frame poses that transition between values using + /// one of three blend modes per parameter: + /// + /// — adds value * weight + /// — multiplies by 1 + value * weight + /// — lerps from current to target using weight + /// + /// + /// Loaded and started by . + /// + public class CubismExpressionMotion : ACubismMotion + { + private readonly List _parameters = new(); + + /// + /// 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. + /// + 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 + }; + + /// + /// Applies all expression parameters to the model using their + /// respective blend modes. Called every frame by the queue manager. + /// + 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; + } + } + } + } +} diff --git a/source_code/Cubism/Framework/CubismEyeBlink.cs b/source_code/Cubism/Framework/CubismEyeBlink.cs new file mode 100644 index 0000000..e1bd07e --- /dev/null +++ b/source_code/Cubism/Framework/CubismEyeBlink.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; + +namespace Cthangover.Live2D.Cubism.Framework +{ + /// + /// 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 (0, 2 * BlinkIntervalSeconds). The actual blink consists of + /// configurable closing, closed, and opening phase durations. + /// + /// Used by when + /// is true and the model3.json defines EyeBlink parameter group. + /// + 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 _parameterIds = new(); + + /// + /// Average interval between successive blinks, in seconds. + /// Actual interval is randomized in range (0, 2 * this value). + /// Default is 4 seconds. + /// + public float BlinkIntervalSeconds { get; set; } = 4f; + + /// + /// Duration of the eyelid closing transition, in seconds. Default 0.1. + /// + public float ClosingSeconds { get; set; } = 0.1f; + + /// + /// Duration the eyes stay fully closed, in seconds. Default 0.05. + /// + public float ClosedSeconds { get; set; } = 0.05f; + + /// + /// Duration of the eyelid opening transition, in seconds. Default 0.15. + /// + public float OpeningSeconds { get; set; } = 0.15f; + + /// + /// Sets the parameter IDs that represent eye open/close. + /// Typically sourced from the model3.json EyeBlink group. + /// Clears any previously set IDs. + /// + public void SetParameterIds(IEnumerable ids) + { + _parameterIds.Clear(); + _parameterIds.AddRange(ids); + } + + /// + /// 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 . + /// + 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; + } + } + } +} diff --git a/source_code/Cubism/Framework/CubismMath.cs b/source_code/Cubism/Framework/CubismMath.cs new file mode 100644 index 0000000..f65d259 --- /dev/null +++ b/source_code/Cubism/Framework/CubismMath.cs @@ -0,0 +1,202 @@ +using System; + +namespace Cthangover.Live2D.Cubism.Framework +{ + /// + /// 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: + /// calculates a value at a given time, + /// solves for the exact + /// bezier parameter t, and + /// is a fallback when beziers are restricted (non-standard). + /// + public static class CubismMath + { + /// + /// Sine-based easing function mapping [0,1] -> [0,1] with smooth + /// acceleration and deceleration. Used for fade-in/fade-out curves + /// in motion transitions. + /// 0.5 - 0.5 * cos(pi * t), clamped to [0,1]. + /// + 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); + } + + /// Converts degrees to radians. + public static float DegreesToRadian(float degrees) + { + return degrees * (MathF.PI / 180f); + } + + /// Converts radians to degrees. + public static float RadianToDegrees(float radian) + { + return radian * (180f / MathF.PI); + } + + /// + /// Computes the signed angle in radians from vector (fromX,fromY) to vector (toX,toY). + /// Uses Atan2(det, dot) for the angle between two vectors. + /// + 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); + } + + /// + /// Converts a radian angle to a unit direction vector (sin, cos). + /// + public static void RadianToDirection(float radian, out float x, out float y) + { + x = MathF.Sin(radian); + y = MathF.Cos(radian); + } + + /// Standard linear interpolation between two float values. + public static float LinearEvaluation(float startValue, float endValue, float time) + { + return startValue + (endValue - startValue) * time; + } + + /// + /// Linear interpolation between two values, + /// using their stored Time properties to compute the interpolation factor. + /// + 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; + } + + /// + /// 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. + /// + 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; + } + + /// + /// 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). + /// + 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); + } + + /// + /// Binary search bezier evaluation — fallback for when + /// 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. + /// + 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; + } + } +} diff --git a/source_code/Cubism/Framework/CubismModelMatrix.cs b/source_code/Cubism/Framework/CubismModelMatrix.cs new file mode 100644 index 0000000..dd23988 --- /dev/null +++ b/source_code/Cubism/Framework/CubismModelMatrix.cs @@ -0,0 +1,175 @@ +using System; +using System.Collections.Generic; +using Godot; + +namespace Cthangover.Live2D.Cubism.Framework +{ + /// + /// Manages the 2D transformation (scale, translation, alignment) for a + /// Cubism model rendered inside a Godot . + /// + /// Works with two coordinate systems: + /// + /// Cubism canvas coordinates (the model's native coordinate space) + /// Godot node local coordinates (where the model is placed) + /// + /// + /// The scale is computed relative to the canvas dimensions stored at construction. + /// and set a uniform scale based + /// on the ratio of target size to canvas size. reads + /// positioning keys from a Cubism .model3.json "Layout" section. + /// writes the final transform to a Godot Node2D. + /// + public class CubismModelMatrix + { + private float _width; + private float _height; + private float _scaleX = 1f; + private float _scaleY = 1f; + private float _translateX; + private float _translateY; + + /// Creates an identity matrix with no known canvas size. + public CubismModelMatrix() { } + + /// + /// Creates a matrix referenced to the given canvas pixel dimensions. + /// Subsequent calls to and + /// will compute scale as target / canvas. + /// + public CubismModelMatrix(float width, float height) + { + _width = width; + _height = height; + } + + /// Current uniform scale on the X axis. + public float ScaleX => _scaleX; + + /// Current uniform scale on the Y axis. + public float ScaleY => _scaleY; + + /// Current X translation offset in Godot local coordinates. + public float TranslateX => _translateX; + + /// Current Y translation offset in Godot local coordinates. + public float TranslateY => _translateY; + + /// + /// Sets uniform scale to match the given width. Scale = targetWidth / canvasWidth. + /// Does nothing if canvas width is zero or unknown. + /// + public void SetWidth(float w) + { + if (_width > 0f) + { + var scale = w / _width; + _scaleX = scale; + _scaleY = scale; + } + } + + /// + /// Sets uniform scale to match the given height. Scale = targetHeight / canvasHeight. + /// Does nothing if canvas height is zero or unknown. + /// + public void SetHeight(float h) + { + if (_height > 0f) + { + var scale = h / _height; + _scaleX = scale; + _scaleY = scale; + } + } + + /// Sets absolute translation position. + public void SetPosition(float x, float y) + { + _translateX = x; + _translateY = y; + } + + /// Sets absolute X translation. + public void SetX(float x) => _translateX = x; + + /// Sets absolute Y translation. + public void SetY(float y) => _translateY = y; + + /// + /// Centers the model horizontally and vertically at the given point. + /// Equivalent to calling then . + /// + public void SetCenterPosition(float x, float y) + { + CenterX(x); + CenterY(y); + } + + /// + /// Centers horizontally: translateX = x - (width * scaleX) / 2. + /// + public void CenterX(float x) => _translateX = x - (_width * _scaleX) / 2f; + + /// + /// Centers vertically: translateY = y - (height * scaleY) / 2. + /// + public void CenterY(float y) => _translateY = y - (_height * _scaleY) / 2f; + + /// Aligns the top edge of the model to y. + public void Top(float y) => _translateY = y; + + /// Aligns the bottom edge of the model to y. + public void Bottom(float y) => _translateY = y - _height * _scaleY; + + /// Aligns the left edge of the model to x. + public void Left(float x) => _translateX = x; + + /// Aligns the right edge of the model to x. + public void Right(float x) => _translateX = x - _width * _scaleX; + + /// + /// Configures scale and position from a layout dictionary. + /// Supported keys: "width", "height", "x", "y", "center_x", "center_y", + /// "top", "bottom", "left", "right". + /// Width/height are applied first (since they affect scale), + /// then position keys are applied. + /// + public void SetupFromLayout(Dictionary 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; + } + } + } + + /// + /// Writes the current scale and translation to a Godot . + /// Sets both Scale and Position properties. + /// + public void ApplyToNode(Node2D node) + { + node.Scale = new Vector2(_scaleX, _scaleY); + node.Position = new Vector2(_translateX, _translateY); + } + } +} diff --git a/source_code/Cubism/Framework/CubismModelNode.cs b/source_code/Cubism/Framework/CubismModelNode.cs new file mode 100644 index 0000000..169dae0 --- /dev/null +++ b/source_code/Cubism/Framework/CubismModelNode.cs @@ -0,0 +1,520 @@ +using Cthangover.Core.Mods; +using Cthangover.Core.UI.Event; +using Cthangover.Core.Utils; +using Godot; + +namespace Cthangover.Live2D.Cubism.Framework +{ + /// + /// Defines how the Live2D model scales to fit its parent container. + /// + public enum FitMode { None, FitWidth, FitHeight, FitAuto } + + /// + /// 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: + /// + /// Loads .model3.json and .moc3 via + /// Sets up and arrays + /// Creates optional effect layers: , , + /// , + /// Builds 2D meshes through + /// Every frame (): target point -> parameter injection -> + /// motion/expression evaluation -> effect layers -> model update -> renderer update + /// + /// + /// 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). + /// + 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 _manualParameters = new(); + + /// + /// All model parameters with their IDs, bounds, and current values. + /// Directly references the native parameter arrays — modifying + /// and calling + /// writes back to CubismCore. + /// + public CubismParameter[] Parameters { get; private set; } = System.Array.Empty(); + + /// + /// All model part opacities, indexed by part index. + /// Useful for showing/hiding parts (clothing layers, accessories, etc.). + /// + public CubismPartOpacity[] PartOpacities { get; private set; } = System.Array.Empty(); + + /// Canvas pixel width from the model's .moc3 data. Available after loading. + public float CanvasWidth => _nativeModel?.CanvasWidth ?? 0f; + + /// Canvas pixel height from the model's .moc3 data. Available after loading. + public float CanvasHeight => _nativeModel?.CanvasHeight ?? 0f; + + /// + /// 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. + /// + [Export] public float AnchorX { get; set; } = 0.5f; + + /// + /// Vertical anchor point for layout positioning, 0..1. + /// 0 = top edge, 0.5 = center, 1 = bottom edge. Default 0.5. + /// + [Export] public float AnchorY { get; set; } = 0.5f; + + /// + /// How the model scales to fit its parent container. Default . + /// Used by on the first frame with a valid parent size. + /// + [Export] public FitMode ScaleFit { get; set; } = FitMode.FitAuto; + + /// + /// Enables automatic eye blink animation. Requires EyeBlink parameter groups + /// in the model3.json. Default true. + /// + [Export] public bool EnableEyeBlink { get; set; } = true; + + /// + /// Enables breathing animation. Default false. + /// Note: breath parameters are not currently loaded from model settings — + /// enable this only if parameters are set externally via . + /// + [Export] public bool EnableBreath { get; set; } + + /// + /// Enables physics simulation (hair, cloth, etc.) from the .physics3.json file. + /// Default true. + /// + [Export] public bool EnablePhysics { get; set; } = true; + + /// + /// Enables pose-based part opacity management from the .pose3.json file. + /// Default true. + /// + [Export] public bool EnablePose { get; set; } = true; + + private bool _loaded; + + /// + /// Loads a Cubism model from within a registered mod. + /// Reads the .model3.json file, resolves the .moc3 and textures, then + /// calls to build parameters, parts, effects, and meshes. + /// When is null, all mods are searched in priority + /// order for the first match. + /// + /// Path to .model3.json relative to the mod root. + /// Optional registered mod ID containing the model assets. + 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("/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; + + /// + /// Per-frame update called by the SceneEventController. + /// Execution order: + /// + /// Apply deferred layout (first valid frame only) + /// Update for face tracking + /// Inject face-tracking parameters (ParamAngleX/Y, ParamEyeBallX/Y) + /// into the native model + /// Evaluate motion queue (character animations) + /// Evaluate expression queue (facial expressions) + /// Run effect layers in order: breath, eye blink, pose, physics + /// Call csmUpdateModel on the native model + /// Update the renderer (visibility, opacity, vertex positions) + /// + /// + 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(); + 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; + } + + + /// + /// Sets the face-tracking target. Drives eye/head movement parameters + /// (ParamAngleX/Y, ParamEyeBallX/Y) through a smooth damped approach + /// in . + /// + public void SetTargetPoint(float x, float y) => _targetPoint.Set(x, y); + + /// + /// Finds a parameter by its Cubism ID string (e.g. "ParamAngleX"). + /// Returns null if not found. + /// + public CubismParameter FindParameter(string id) + { + foreach (var p in Parameters) + if (p.Id == id) return p; + return null; + } + + /// + /// Finds a part by its Cubism ID string. Returns null if not found. + /// + public CubismPartOpacity FindPart(string id) + { + foreach (var p in PartOpacities) + if (p.Id == id) return p; + return null; + } + + /// + /// Sets a parameter value directly in the native model, bypassing the per-frame + /// face-tracking injection in . Once a parameter is set + /// via this method, stops overwriting it — the caller + /// takes full control of that parameter's value. + /// Safe to call before the model is loaded — silently ignored. + /// + /// Cubism parameter ID (e.g. "ParamAngleX"). + /// Raw parameter value. Caller should clamp to the parameter's bounds. + 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); + } + + /// + /// 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. + /// + /// Motion group name from model3.json (e.g. "Idle", "TapBody"). + /// Zero-based index within the group. + 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); + } + + /// + /// 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. + /// + 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; + } + } + } + + /// Stops all currently playing motion animations. + public void StopMotion() => _motionManager.StopAllMotions(); + + /// Stops all currently playing expression animations. + public void StopExpression() => _expressionManager.StopAllMotions(); + } +} diff --git a/source_code/Cubism/Framework/CubismModelSettingJson.cs b/source_code/Cubism/Framework/CubismModelSettingJson.cs new file mode 100644 index 0000000..3838a2a --- /dev/null +++ b/source_code/Cubism/Framework/CubismModelSettingJson.cs @@ -0,0 +1,239 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; + +namespace Cthangover.Live2D.Cubism.Framework +{ + /// + /// Interface for reading a Cubism model3.json setting file. + /// All accessors return default/empty values when the corresponding + /// data is absent from the JSON. + /// + public interface ICubismModelSetting + { + /// Returns the relative path to the .moc3 file. + string GetModelFileName(); + + /// Returns the number of texture references in the model. + int GetTextureCount(); + + /// Returns the relative path to the texture at the given index. + string GetTextureFileName(int index); + + /// Returns the relative path to the physics3.json file, or null. + string GetPhysicsFileName(); + + /// Returns the relative path to the pose3.json file, or null. + string GetPoseFileName(); + + /// Returns the number of expressions defined in the model. + int GetExpressionCount(); + + /// Returns the display name of the expression at the given index. + string GetExpressionName(int index); + + /// Returns the relative path to the .exp3.json file for the expression at the given index. + string GetExpressionFileName(int index); + + /// Returns the number of motion groups. + int GetMotionGroupCount(); + + /// Returns the name of the motion group at the given index. + string GetMotionGroupName(int index); + + /// Returns the number of motions within the named group. + int GetMotionCount(string groupName); + + /// Returns the relative path to the motion3.json file for the given group and index. + string GetMotionFileName(string groupName, int index); + + /// Returns the relative path to the associated sound file, or null. + string GetMotionSoundFileName(string groupName, int index); + + /// Returns the fade-in time in seconds for the motion, or -1 if not specified. + float GetMotionFadeInTimeValue(string groupName, int index); + + /// Returns the fade-out time in seconds for the motion, or -1 if not specified. + float GetMotionFadeOutTimeValue(string groupName, int index); + + /// Returns the number of parameters in the EyeBlink group. + int GetEyeBlinkParameterCount(); + + /// Returns the parameter ID at the given index within the EyeBlink group. + string GetEyeBlinkParameterId(int index); + + /// Returns the number of parameters in the LipSync group. + int GetLipSyncParameterCount(); + + /// Returns the parameter ID at the given index within the LipSync group. + string GetLipSyncParameterId(int index); + + /// Returns the number of hit area definitions. + int GetHitAreasCount(); + + /// Returns the hit area ID at the given index. + string GetHitAreaId(int index); + + /// Returns the hit area display name at the given index. + string GetHitAreaName(int index); + + /// + /// 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. + /// + Dictionary GetLayoutMap(); + } + + /// + /// JSON parser for the Cubism .model3.json format (Live2D model settings). + /// Implements . + /// + /// 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 and should be disposed + /// via when no longer needed. + /// + 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; + + /// + /// Parses the given .model3.json bytes. + /// + 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 GetLayoutMap() + { + var map = new Dictionary(); + 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; + + /// + /// Releases the underlying . + /// + public void Dispose() => _json?.Dispose(); + } +} diff --git a/source_code/Cubism/Framework/CubismMotion.cs b/source_code/Cubism/Framework/CubismMotion.cs new file mode 100644 index 0000000..f04b8da --- /dev/null +++ b/source_code/Cubism/Framework/CubismMotion.cs @@ -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); + + /// + /// Parses and plays a Cubism .motion3.json animation. + /// Manages three categories of curves: + /// + /// Model curves — eye blink, lip sync, and opacity + /// Parameter curves — arbitrary model parameter animations + /// Part opacity curves — visibility/opacity of model parts + /// + /// + /// Each curve is a sequence of segments (linear, stepped, bezier) stored in + /// flat arrays for performance. Evaluation uses a segment-finding pass + /// followed by per-segment interpolation — linear, stepped, or bezier + /// (analytic via Cardano formula or binary search for restricted beziers). + /// + /// The four-pass update cycle (): + /// + /// Evaluate model curves (eye blink, lip sync, opacity) + /// Evaluate parameter curves, applying eye blink/lip sync modulation + /// Apply eye blink/lip sync to remaining (non-animated) parameters + /// Evaluate part opacity curves + /// + /// + /// Parameter fade-in/out is handled per-curve (overriding the motion-level + /// fade settings). Curve targets and IDs come from the motion3.json metadata. + /// + 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 _modelCurves = new(); + private readonly List _parameterCurves = new(); + private readonly List _partOpacityCurves = new(); + + private readonly List _eyeBlinkParameterIds = new(); + private readonly List _lipSyncParameterIds = new(); + + private readonly List _events = new(); + private readonly List _firedEventValues = new(); + + private float _modelOpacity = 1f; + private bool _areBeziersRestricted; + + /// + /// Parses a .motion3.json byte buffer. + /// Reads the meta section (duration, loop, fps, fade times, bezier flag) + /// and delegates curve/event parsing to private helpers. + /// + 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(); + } + + /// + /// 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. + /// + public void SetEffectIds(List eyeBlinkIds, List lipSyncIds) + { + _eyeBlinkParameterIds.Clear(); + _lipSyncParameterIds.Clear(); + if (eyeBlinkIds != null) _eyeBlinkParameterIds.AddRange(eyeBlinkIds); + if (lipSyncIds != null) _lipSyncParameterIds.AddRange(lipSyncIds); + } + + /// + /// Returns the total duration from the motion3.json, or -1 for looping motions. + /// + public override float GetDuration() => _isLoop ? -1f : _motionJson.GetDuration(); + + /// + /// Returns the full duration even when looping is active — used for computing + /// loop cycle length. + /// + public override float GetLoopDuration() => _motionJson.GetDuration(); + + /// Whether the model's overall opacity is actively controlled by this motion. + public bool IsExistModelOpacity() => _modelOpacity > 0f; + + /// Current model opacity value from the Opacity model curve, clamped [0,1]. + public float GetModelOpacity() => _modelOpacity; + + /// + /// Collects event values that fired between two time points. + /// Clears the internal fired-event list on each call. (Currently returns 0f; + /// the fired values list is populated as a side effect.) + /// + public float GetFiredEventValue(float beforeTime, float motionTime) + { + _firedEventValues.Clear(); + foreach (var evt in _events) + if (evt.FireTime > beforeTime && evt.FireTime <= motionTime) + _firedEventValues.Add(evt.Value); + return 0f; + } + + /// + /// Four-pass parameter update. Called by + /// every frame. + /// + 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(); + 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 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) + }); + } + } + } + + /// + /// Internal data for a single parsed motion curve. + /// Holds the curve type, target ID, segment layout, points array, + /// and optional per-curve fade-in/fade-out durations. + /// + internal 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; + } +} diff --git a/source_code/Cubism/Framework/CubismMotionInternal.cs b/source_code/Cubism/Framework/CubismMotionInternal.cs new file mode 100644 index 0000000..2caf7a3 --- /dev/null +++ b/source_code/Cubism/Framework/CubismMotionInternal.cs @@ -0,0 +1,62 @@ +using System.Runtime.InteropServices; + +namespace Cthangover.Live2D.Cubism.Framework +{ + /// + /// Specifies which part of the model a motion curve affects. + /// + public enum CubismMotionCurveTarget { Model, Parameter, PartOpacity } + + /// + /// Segment interpolation type within a motion curve. + /// + /// — straight line between two points + /// — cubic bezier (4 control points per segment) + /// — hold previous value until the segment ends + /// — jump to next value at segment start + /// + /// + public enum CubismMotionSegmentType { Linear = 0, Bezier = 1, Stepped = 2, InverseStepped = 3 } + + /// + /// A time-value pair representing a keyframe or control point on a motion curve. + /// + [StructLayout(LayoutKind.Sequential)] + public struct CubismMotionPoint + { + /// Time in seconds from the start of the motion. + public float Time; + + /// Value at this point. For bezier control points, this is an intermediate value. + public float Value; + } + + /// + /// 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. + /// + public struct CubismMotionCurve + { + public CubismMotionCurveTarget Type; + public string Id; + public int SegmentCount; + public int BaseSegmentIndex; + public float FadeInTime; + public float FadeOutTime; + } + + /// + /// 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 . + /// + public struct CubismMotionEvent + { + /// Time offset in seconds when this event triggers. + public float FireTime; + + /// String value associated with the event. + public string Value; + } +} diff --git a/source_code/Cubism/Framework/CubismMotionJson.cs b/source_code/Cubism/Framework/CubismMotionJson.cs new file mode 100644 index 0000000..3566daf --- /dev/null +++ b/source_code/Cubism/Framework/CubismMotionJson.cs @@ -0,0 +1,138 @@ +using System.Collections.Generic; +using System.Text.Json; + +namespace Cthangover.Live2D.Cubism.Framework +{ + /// + /// 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 . + /// + 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; + + /// + /// Parses the given .motion3.json bytes and pre-extracts all Meta fields + /// into readonly fields for fast repeated access. + /// + 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(); + } + + /// Total motion duration in seconds from the Meta section. + public float GetDuration() => _duration; + + /// Whether the motion loops. + public bool IsLoop() => _isLoop; + + /// Frames per second, default 30. + public float GetFps() => _fps; + + /// Number of curves in this motion. + public int GetCurveCount() => _curveCount; + + /// Total segment count across all curves. + public int GetTotalSegmentCount() => _totalSegmentCount; + + /// Total point count across all curves. + public int GetTotalPointCount() => _totalPointCount; + + /// + /// If true, bezier curves use binary search instead of the Cardano formula + /// (non-standard bezier parameterization). + /// + public bool AreBeziersRestricted() => _areBeziersRestricted; + + /// + /// Motion-level fade-in time in seconds, or -1 if not specified. + /// + public float GetFadeInTime() => + _meta.TryGetProperty("FadeInTime", out var v) ? v.GetSingle() : -1f; + + /// + /// Motion-level fade-out time in seconds, or -1 if not specified. + /// + public float GetFadeOutTime() => + _meta.TryGetProperty("FadeOutTime", out var v) ? v.GetSingle() : -1f; + + /// + /// Returns the target string for the curve at the given index + /// ("Model", "Parameter", or "PartOpacity"). + /// + public string GetCurveTarget(int index) => + _curves[index].GetProperty("Target").GetString(); + + /// + /// Returns the parameter/part/model ID string for the curve at the given index. + /// + public string GetCurveId(int index) => + _curves[index].GetProperty("Id").GetString(); + + /// + /// Per-curve fade-in time in seconds, or -1 if not specified. + /// + public float GetCurveFadeInTime(int index) => + _curves[index].TryGetProperty("FadeInTime", out var v) ? v.GetSingle() : -1f; + + /// + /// Per-curve fade-out time in seconds, or -1 if not specified. + /// + public float GetCurveFadeOutTime(int index) => + _curves[index].TryGetProperty("FadeOutTime", out var v) ? v.GetSingle() : -1f; + + /// + /// 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. + /// + 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(); + } + + /// Number of user-data events in the motion. + public int GetEventCount() => _hasUserData ? _userData.GetArrayLength() : 0; + + /// Fire time of the event at the given index, in seconds. + public float GetEventTime(int index) => + _hasUserData && index < _userData.GetArrayLength() + ? _userData[index].GetProperty("Time").GetSingle() : 0f; + + /// String value of the event at the given index. + public string GetEventValue(int index) => + _hasUserData && index < _userData.GetArrayLength() + ? _userData[index].GetProperty("Value").GetString() : null; + } +} diff --git a/source_code/Cubism/Framework/CubismMotionQueueEntry.cs b/source_code/Cubism/Framework/CubismMotionQueueEntry.cs new file mode 100644 index 0000000..8a6613c --- /dev/null +++ b/source_code/Cubism/Framework/CubismMotionQueueEntry.cs @@ -0,0 +1,163 @@ +namespace Cthangover.Live2D.Cubism.Framework +{ + /// + /// Represents one enqueued motion in the playback queue. + /// Holds timing data (start time, end time, fade-in start time), + /// lifecycle flags (available, finished, auto-delete), and a + /// reference to the motion itself. + /// + /// Created by + /// and consumed frame-by-frame in . + /// + public class CubismMotionQueueEntry + { + /// Absolute user time when motion playback begins. + public float StartTime; + + /// + /// Absolute user time when playback ends. -1 for infinite (looping) motions. + /// + public float EndTime; + + /// Absolute user time when fade-in begins (may differ from StartTime for offset motions). + public float FadeInStartTime; + + /// Whether this entry should be processed during updates. + public bool IsAvailable; + + /// Flag set when playback completes. Checked by the queue manager during cleanup. + public bool IsFinished; + + /// The motion instance driving parameter updates. + public ACubismMotion Motion; + + /// If true, the entry is automatically removed from the queue when finished. + public bool AutoDelete; + + /// Arbitrary user data attached to this queue entry. + public object CustomData; + + /// + /// Creates a new queue entry for the given motion. + /// AutoDelete controls whether the entry is removed on finish. + /// + public CubismMotionQueueEntry(ACubismMotion motion, bool autoDelete) + { + Motion = motion; + AutoDelete = autoDelete; + IsAvailable = true; + } + } + + /// + /// Manages a queue of instances for a single model. + /// Supports priority reservation (higher-priority motions preempt lower ones), + /// concurrent playback of multiple motions, and automatic cleanup of finished entries. + /// + /// Two instances are used by : + /// one for character motions (animations) and one for expressions. + /// Each is updated independently in the per-frame loop, allowing motions + /// and expressions to blend independently with their own fade weights. + /// + /// The update loop iterates entries in reverse for safe removal during + /// traversal. Finished auto-delete entries are removed immediately; + /// non-auto-delete entries remain in the queue with their IsFinished flag set. + /// + public class CubismMotionQueueManager + { + private readonly System.Collections.Generic.List _entries = new(); + private int _reservedPriority = -1; + + /// + /// Sets the reserved priority threshold. + /// Motions with priority below this are rejected by . + /// + public void SetReservePriority(int priority) => _reservedPriority = priority; + + /// + /// 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. + /// + public bool ReserveMotion(int priority) + { + if (_reservedPriority >= 0 && _reservedPriority > priority) + return false; + + _reservedPriority = priority; + return true; + } + + /// + /// Creates and enqueues a motion for playback. + /// Instantiates a , calls + /// on it, and + /// adds it to the queue. Returns the entry for optional tracking. + /// + /// The motion to play. + /// Whether to auto-remove on finish. + /// Current user time for start time calculation. + 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; + } + + /// + /// Stops all motions and clears the queue. Resets priority reservation. + /// + public void StopAllMotions() + { + _entries.Clear(); + _reservedPriority = -1; + } + + /// + /// Returns true if all entries in the queue are finished. + /// + public bool IsFinished() + { + foreach (var entry in _entries) + if (!entry.IsFinished) + return false; + return true; + } + + /// + /// Per-frame update for all queued motions. + /// Iterates in reverse for safe removal. For each active entry: + /// computes fade weight, invokes , + /// and checks for completion. Finished auto-delete entries are removed. + /// + 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); + } + } + } + } + } +} diff --git a/source_code/Cubism/Framework/CubismNativeModel.cs b/source_code/Cubism/Framework/CubismNativeModel.cs new file mode 100644 index 0000000..5558bb2 --- /dev/null +++ b/source_code/Cubism/Framework/CubismNativeModel.cs @@ -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 +{ + /// + /// 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 to free pinned memory when the + /// model is no longer needed. + /// + public class CubismNativeModel : IDisposable + { + private IntPtr _model; + private GCHandle _mocAlignedHandle; + private GCHandle _modelAlignedHandle; + + private int _drawableCount; + private int _parameterCount; + + /// + /// Array of Godot instances loaded from the + /// model's texture references. Indexed by drawable texture index. + /// + public ImageTexture[] Textures { get; private set; } + + /// Number of drawables (renderable mesh parts) in the model. + public int DrawableCount => _drawableCount; + + /// Number of parameters in the model. + public int ParameterCount => _parameterCount; + + /// Canvas pixel width from the moc3. Available after . + public float CanvasWidth { get; private set; } + + /// Canvas pixel height from the moc3. Available after . + public float CanvasHeight { get; private set; } + + /// Pixels-per-unit scale for converting Cubism coordinates to pixel space. + public float PixelsPerUnit { get; private set; } + + /// Whether the native model has been successfully initialized. + public bool IsLoaded => _model != IntPtr.Zero; + + /// Raw native model handle passed to CubismCore functions. + public IntPtr ModelPtr => _model; + + /// + /// 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 on failure. + /// + 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); + } + + /// + /// Loads a model from .model3.json bytes. + /// Parses the FileReferences section to find the .moc3 file, + /// calls , then loads all referenced textures + /// through the given file provider. + /// + 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(); + 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 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})"); + } + } + + /// + /// Calls csmUpdateModel on the native model. + /// Must be called after modifying parameters/parts/opacities and before + /// reading vertex positions for rendering. + /// + public unsafe void UpdateModel() + { + if (_model == IntPtr.Zero) return; + CubismCoreBindings.csmUpdateModel(_model); + } + + /// + /// Resets the per-frame dynamic flags for all drawables. + /// Called at the start of each render update cycle. + /// + public unsafe void ResetDynamicFlags() + { + if (_model == IntPtr.Zero) return; + CubismCoreBindings.csmResetDrawableDynamicFlags(_model); + } + + /// Returns the drawable count from the native model. + public unsafe int GetDrawableCount() => _drawableCount; + + /// Returns a pointer to the dynamic flags byte array (one byte per drawable). + public unsafe byte* GetDynamicFlags() => + CubismCoreBindings.csmGetDrawableDynamicFlags(_model); + + /// Returns a pointer to the constant flags byte array (blend mode, double-sided, etc.). + public unsafe byte* GetConstantFlags() => + CubismCoreBindings.csmGetDrawableConstantFlags(_model); + + /// Returns a pointer to the texture index array (one int per drawable). + public unsafe int* GetTextureIndices() => + CubismCoreBindings.csmGetDrawableTextureIndices(_model); + + /// Returns a pointer to the vertex count array (one int per drawable). + public unsafe int* GetVertexCounts() => + CubismCoreBindings.csmGetDrawableVertexCounts(_model); + + /// Returns a pointer array of csmVector2 arrays — vertex positions per drawable. + public unsafe CubismCoreBindings.csmVector2** GetVertexPositions() => + CubismCoreBindings.csmGetDrawableVertexPositions(_model); + + /// Returns a pointer array of csmVector2 arrays — UV coordinates per drawable. + public unsafe CubismCoreBindings.csmVector2** GetVertexUvs() => + CubismCoreBindings.csmGetDrawableVertexUvs(_model); + + /// Returns a pointer to the index count array (one int per drawable). + public unsafe int* GetIndexCounts() => + CubismCoreBindings.csmGetDrawableIndexCounts(_model); + + /// Returns a pointer array of ushort arrays — triangle indices per drawable. + public unsafe ushort** GetIndices() => + CubismCoreBindings.csmGetDrawableIndices(_model); + + /// Returns a pointer to the per-drawable opacity array (one float per drawable). + public unsafe float* GetOpacities() => + CubismCoreBindings.csmGetDrawableOpacities(_model); + + /// Returns a pointer to the draw order indices array. + public unsafe int* GetDrawOrders() => + CubismCoreBindings.csmGetDrawableDrawOrders(_model); + + /// Returns a pointer to the render order indices array. + public unsafe int* GetRenderOrders() => + CubismCoreBindings.csmGetRenderOrders(_model); + + /// Returns a pointer to the mask count array (one int per drawable). + public unsafe int* GetMaskCounts() => + CubismCoreBindings.csmGetDrawableMaskCounts(_model); + + /// Returns a pointer array of int arrays — mask drawable indices per drawable. + public unsafe int** GetMasks() => + CubismCoreBindings.csmGetDrawableMasks(_model); + + /// Returns a pointer to the per-drawable multiply color array. + public unsafe CubismCoreBindings.csmVector4* GetMultiplyColors() => + CubismCoreBindings.csmGetDrawableMultiplyColors(_model); + + /// Returns a pointer to the per-drawable screen color array. + public unsafe CubismCoreBindings.csmVector4* GetScreenColors() => + CubismCoreBindings.csmGetDrawableScreenColors(_model); + + /// Returns a pointer to the current parameter values array (one float per parameter). + public unsafe float* GetParameterValues() => + CubismCoreBindings.csmGetParameterValues(_model); + + /// Returns a pointer to the parameter minimum bounds array. + public unsafe float* GetParameterMinimumValues() => + CubismCoreBindings.csmGetParameterMinimumValues(_model); + + /// Returns a pointer to the parameter maximum bounds array. + public unsafe float* GetParameterMaximumValues() => + CubismCoreBindings.csmGetParameterMaximumValues(_model); + + /// Returns a pointer to the parameter default values array. + public unsafe float* GetParameterDefaultValues() => + CubismCoreBindings.csmGetParameterDefaultValues(_model); + + /// + /// Returns a pointer array of null-terminated ANSI strings — parameter IDs. + /// Use to read individual strings. + /// + public unsafe IntPtr GetParameterIds() => + CubismCoreBindings.csmGetParameterIds(_model); + + /// + /// Reads a null-terminated ANSI string from a pointer array at the given index. + /// Each element is a byte pointer; reads until null terminator. + /// + public static unsafe string ReadStringFromPtrArray(IntPtr ptrArray, int index) + { + var strPtr = *(byte**)IntPtr.Add(ptrArray, index * IntPtr.Size); + return Marshal.PtrToStringAnsi((IntPtr)strPtr); + } + + /// Returns the number of parts from the native model. + public unsafe int GetPartCount() => + CubismCoreBindings.csmGetPartCount(_model); + + /// Returns a pointer to the per-part opacity array (one float per part). + public unsafe float* GetPartOpacities() => + CubismCoreBindings.csmGetPartOpacities(_model); + + /// Returns a pointer array of part ID strings. + public unsafe IntPtr GetPartIds() => + CubismCoreBindings.csmGetPartIds(_model); + + /// + /// 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. + /// + public void Dispose() + { + if (_mocAlignedHandle.IsAllocated) _mocAlignedHandle.Free(); + if (_modelAlignedHandle.IsAllocated) _modelAlignedHandle.Free(); + _model = IntPtr.Zero; + } + } +} diff --git a/source_code/Cubism/Framework/CubismParameter.cs b/source_code/Cubism/Framework/CubismParameter.cs new file mode 100644 index 0000000..7f2994f --- /dev/null +++ b/source_code/Cubism/Framework/CubismParameter.cs @@ -0,0 +1,50 @@ +namespace Cthangover.Live2D.Cubism.Framework +{ + /// + /// 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 + /// and exposed via . External code + /// can read/write and call + /// to push changes back to the CubismCore native arrays. + /// + public class CubismParameter + { + public CubismNativeModel Model { get; } + + public CubismParameter(CubismNativeModel model) + { + Model = model; + } + + /// Cubism parameter ID string (e.g. "ParamAngleX", "ParamMouthOpenY"). + public string Id { get; set; } + + /// Zero-based index into the native parameter arrays. + public int Index { get; set; } + + /// Current parameter value. Writes to native model when is called. + public float Value { get; set; } + + /// Minimum allowed value for this parameter. + public float MinimumValue { get; set; } + + /// Maximum allowed value for this parameter. + public float MaximumValue { get; set; } + + /// Default (rest) value for this parameter from the moc3. + public float DefaultValue { get; set; } + + /// + /// Writes directly to the native model's parameter array. + /// No clamping is performed — the caller should enforce bounds if needed. + /// + public unsafe void ApplyToModel() + { + var values = Model.GetParameterValues(); + values[Index] = Value; + } + } +} diff --git a/source_code/Cubism/Framework/CubismPartOpacity.cs b/source_code/Cubism/Framework/CubismPartOpacity.cs new file mode 100644 index 0000000..bd54856 --- /dev/null +++ b/source_code/Cubism/Framework/CubismPartOpacity.cs @@ -0,0 +1,43 @@ +namespace Cthangover.Live2D.Cubism.Framework +{ + /// + /// 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 + /// and exposed via . + /// External code can set and call + /// to push the change to CubismCore. + /// + public class CubismPartOpacity + { + public CubismNativeModel Model { get; } + + public CubismPartOpacity(CubismNativeModel model) + { + Model = model; + } + + /// Cubism part ID string (e.g. "PartArmL", "PartHairBack"). + public string Id { get; set; } + + /// Zero-based index into the native part arrays. + public int Index { get; set; } + + /// + /// Current opacity value for this part. 0 = hidden, 1 = fully visible. + /// Written to native model when is called. + /// + public float Value { get; set; } + + /// + /// Writes directly to the native model's part opacities array. + /// + public unsafe void ApplyToModel() + { + var opacities = Model.GetPartOpacities(); + opacities[Index] = Value; + } + } +} diff --git a/source_code/Cubism/Framework/CubismPhysics.cs b/source_code/Cubism/Framework/CubismPhysics.cs new file mode 100644 index 0000000..442b524 --- /dev/null +++ b/source_code/Cubism/Framework/CubismPhysics.cs @@ -0,0 +1,365 @@ +using System; +using System.Collections.Generic; + +namespace Cthangover.Live2D.Cubism.Framework +{ + /// + /// Cubism physics simulation engine. Drives secondary motion (hair, cloth, + /// accessories) using a chain of particles influenced by parameter inputs + /// (X, Y, angle from model parameters), gravity, and wind. + /// + /// Architecture: + /// + /// holds the complete rig structure: + /// sub-rigs, inputs, outputs, and particles + /// Each sub-rig is an independent chain of particles anchored to one + /// or more model parameters via inputs + /// Inputs map model parameters (normalized and weighted) to particle + /// chain root position/angle + /// Outputs map particle chain state back to model parameters after + /// physics integration + /// + /// + /// The simulation runs at the rig's configured FPS (from .physics3.json), + /// using sub-frame accumulation when the display delta time is smaller + /// than the physics step. Output values are interpolated between steps + /// for smooth visual results. + /// + public class CubismPhysics + { + private const float AirResistance = 5f; + private const float MaximumWeight = 100f; + + private readonly CubismPhysicsRig _rig = new(); + private readonly List _currentOutputs = new(); + private readonly List _previousOutputs = new(); + private float _currentRemainTime; + private float _physicsDeltaTime; + + /// + /// Parses a .physics3.json byte buffer and builds the full physics rig + /// with sub-rigs, inputs, outputs, and particles. + /// + 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); + } + } + + /// + /// Overrides gravity and wind at runtime. + /// + public void SetOptions(float gravityX, float gravityY, float windX, float windY) + { + _rig.GravityX = gravityX; + _rig.GravityY = gravityY; + _rig.WindX = windX; + _rig.WindY = windY; + } + + /// + /// Forces the physics to stabilize immediately by running one update + /// cycle in stabilization mode. Particles snap to their rest positions + /// along the chain direction. + /// + public void Stabilization(CubismNativeModel model) + { + UpdatePhysics(model, true); + } + + /// + /// 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 every frame. + /// + 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; + } + } +} diff --git a/source_code/Cubism/Framework/CubismPhysicsJson.cs b/source_code/Cubism/Framework/CubismPhysicsJson.cs new file mode 100644 index 0000000..f5220a1 --- /dev/null +++ b/source_code/Cubism/Framework/CubismPhysicsJson.cs @@ -0,0 +1,240 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; + +namespace Cthangover.Live2D.Cubism.Framework +{ + /// + /// Physics input/output dimensionality. + /// + public enum CubismPhysicsSource { X, Y, Angle } + + /// + /// Normalization range for mapping raw parameter values to normalized physics inputs. + /// + public struct CubismPhysicsNormalization { public float Minimum; public float Maximum; public float Default; } + + /// + /// 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. + /// + 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; + } + + /// + /// 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. + /// + public struct CubismPhysicsSubRig + { + public int InputCount, OutputCount, ParticleCount; + public int BaseInputIndex, BaseOutputIndex, BaseParticleIndex; + public CubismPhysicsNormalization NormalizationPosition; + public CubismPhysicsNormalization NormalizationAngle; + } + + /// + /// Maps a model parameter to a physics chain input (X, Y, or angle) + /// with a configurable weight and optional reflection. + /// + public struct CubismPhysicsInput + { + public string ParameterId; + public int SourceParameterIndex; + public float Weight; + public CubismPhysicsSource Type; + public bool Reflect; + } + + /// + /// 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. + /// + 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; + } + + /// + /// Complete physics rig data structure. Holds all sub-rigs, flattened + /// input/output/particle arrays, and global gravity/wind/fps settings. + /// Used by as its internal state. + /// + public class CubismPhysicsRig + { + public List SubRigs = new(); + public List Inputs = new(); + public List Outputs = new(); + public CubismPhysicsParticle[] Particles = System.Array.Empty(); + public float GravityX, GravityY; + public float WindX, WindY; + public float Fps; + } + + /// + /// 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. + /// + public class CubismPhysicsJson + { + private readonly JsonDocument _json; + + /// + /// Parses the given .physics3.json bytes. + /// + public CubismPhysicsJson(byte[] jsonBytes) + { + _json = JsonDocument.Parse(jsonBytes); + } + + /// Physics simulation FPS from the Meta section, default 30. + public float GetFps() + { + var meta = _json.RootElement.GetProperty("Meta"); + return meta.TryGetProperty("Fps", out var v) ? v.GetSingle() : 30f; + } + + /// Number of sub-rig entries in PhysicsSettings. + public int GetSubRigCount() => + _json.RootElement.TryGetProperty("PhysicsSettings", out var arr) ? arr.GetArrayLength() : 0; + + /// Reads gravity vector from Meta.EffectiveForces.Gravity. Defaults to (0, -1). + 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; + } + } + + /// Reads wind vector from Meta.EffectiveForces.Wind. Defaults to (0, 0). + public void GetWind(out float x, out float y) + { + x = 0; y = 0; + var meta = _json.RootElement.GetProperty("Meta"); + if (meta.TryGetProperty("EffectiveForces", out var ef) && + ef.TryGetProperty("Wind", out var w)) + { + x = w.TryGetProperty("X", out var wx) ? wx.GetSingle() : 0f; + y = w.TryGetProperty("Y", out var wy) ? wy.GetSingle() : 0f; + } + } + + /// Number of input entries in the given sub-rig. + public int GetInputCount(int subRigIndex) => + GetSubRig(subRigIndex).GetProperty("Input").GetArrayLength(); + + /// Number of output entries in the given sub-rig. + public int GetOutputCount(int subRigIndex) => + GetSubRig(subRigIndex).GetProperty("Output").GetArrayLength(); + + /// Number of vertex/particle entries in the given sub-rig. + public int GetVertexCount(int subRigIndex) => + GetSubRig(subRigIndex).GetProperty("Vertices").GetArrayLength(); + + /// Reads position normalization (min, max, default) for the given sub-rig. + 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; + } + + /// Reads angle normalization (min, max, default) for the given sub-rig. + 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; + } + + /// Reads a single input entry: parameter ID, weight, type, reflect flag. + public void GetInput(int subRigIndex, int inputIndex, out string parameterId, out float weight, + out CubismPhysicsSource type, out bool reflect) + { + 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(); + } + + /// Reads a single output entry: destination parameter ID, vertex index, scales, type, reflect. + public void GetOutput(int subRigIndex, int outputIndex, out string parameterId, out int vertexIndex, + out float scaleX, out float scaleY, out float weight, out CubismPhysicsSource type, out bool reflect) + { + 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(); + } + + /// Reads a single particle/vertex: mobility, delay, acceleration, radius, position. + public void GetParticle(int subRigIndex, int vertexIndex, + out float mobility, out float delay, out float acceleration, out float radius, + out float posX, out float posY) + { + 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 + }; + } +} diff --git a/source_code/Cubism/Framework/CubismPose.cs b/source_code/Cubism/Framework/CubismPose.cs new file mode 100644 index 0000000..ed003bc --- /dev/null +++ b/source_code/Cubism/Framework/CubismPose.cs @@ -0,0 +1,218 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; + +namespace Cthangover.Live2D.Cubism.Framework +{ + /// + /// Manages pose-based part visibility from a Cubism .pose3.json file. + /// Poses organize model parts into mutual-exclusion groups — only one + /// part in each group is visible at a time. This is commonly used for + /// eye states (open/closed/surprised) and other binary/multi-state + /// part switches. + /// + /// How it works: + /// + /// On model change (detected via ModelPtr comparison), all + /// part groups are reset: the first part in each group gets + /// opacity 1, others get opacity 0 + /// Each frame, the currently visible part in each group + /// fades toward opacity 1 while all others fade toward 0 + /// Linked parts (defined by the "Link" array in pose data) + /// copy their parent's opacity, cascading visibility changes + /// + /// + /// The fade uses a constant rate (1 / fadeTimeSeconds per second). + /// A piecewise-linear alpha calculation with a crossing threshold (Phi=0.5) + /// prevents parts from being partially visible during transitions. + /// + public class CubismPose + { + private struct PartData + { + public string PartId; + public int PartIndex; + public int ParameterIndex; + public List Link; + } + + private readonly List> _partGroups = new(); + private readonly float _fadeTimeSeconds; + private IntPtr _lastModel = IntPtr.Zero; + + /// + /// Parses a .pose3.json byte buffer. + /// Extracts the global fade time and all part groups with their + /// linked-part chains. + /// + 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(); + foreach (var part in group.EnumerateArray()) + { + var pd = new PartData + { + PartId = part.GetProperty("Id").GetString(), + PartIndex = -1, + ParameterIndex = -1, + Link = new List() + }; + + 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); + } + } + + /// + /// 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 after expressions. + /// + 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 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; + } + } + } + } + } + } + } +} diff --git a/source_code/Cubism/Framework/CubismRenderer2D.cs b/source_code/Cubism/Framework/CubismRenderer2D.cs new file mode 100644 index 0000000..45d0d10 --- /dev/null +++ b/source_code/Cubism/Framework/CubismRenderer2D.cs @@ -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 +{ + /// + /// Builds and updates 2D mesh instances for rendering a Cubism model in Godot. + /// + /// Handles the full rendering pipeline: + /// + /// BuildModel — Creates instances from + /// native vertex/index data, applies textures, resolves shaders based + /// on drawable blend flags and mask configuration + /// UpdateModel — Updates visibility, opacity, and vertex + /// positions each frame by reading CubismCore dynamic flags + /// Mask support — 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 + /// + /// + /// Shader resolution delegates to . + /// 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. + /// + 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 _maskGroups = new(); + private int[] _drawableMaskGroupIndex; + private Color[] _drawableMaskChannel; + + /// + /// Creates a renderer that attaches mesh instances as children of + /// the given parent node. + /// + public CubismRenderer2D(Node2D parentNode) + { + _parentNode = parentNode; + } + + /// + /// 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. + /// + 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"); + } + + /// + /// Returns the union AABB of all drawable meshes in local pixel space. + /// Valid after has completed. + /// + 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(); + + 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(); + 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) + }; + + /// + /// 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. + /// + 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; + } + } +} diff --git a/source_code/Cubism/Framework/CubismTargetPoint.cs b/source_code/Cubism/Framework/CubismTargetPoint.cs new file mode 100644 index 0000000..a2c7976 --- /dev/null +++ b/source_code/Cubism/Framework/CubismTargetPoint.cs @@ -0,0 +1,100 @@ +using System; + +namespace Cthangover.Live2D.Cubism.Framework +{ + /// + /// Smooth target-point tracking for face/eye movement parameters. + /// Drives the ParamAngleX/Y and ParamEyeBallX/Y model parameters in + /// . + /// + /// Uses a velocity-based smoothing approach: each update computes a + /// desired velocity toward the target, applies acceleration limits, + /// enforces a maximum safe approach speed to prevent overshoot near + /// the target, and integrates velocity into position. + /// + /// The tracking is frame-rate independent — delta time is scaled by + /// a factor of 30 (matching Cubism's internal 30fps reference). + /// Acceleration and max velocity are clamped per Cubism SDK conventions + /// (FaceParamMaxV = 40, FrameToMaxSpeed = 4.5). + /// + 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; + + /// Current smoothed X position. + public float X => _faceX; + + /// Current smoothed Y position. + public float Y => _faceY; + + /// + /// Sets the target point immediately. The actual position + /// will smoothly approach this target over subsequent calls to . + /// + public void Set(float x, float y) + { + _faceTargetX = x; + _faceTargetY = y; + } + + /// + /// Advances the smoothing simulation by one frame. + /// Computes delta-time-weighted velocity toward target, applies + /// acceleration clamping and safe approach speed limiting, then + /// integrates position. + /// + /// Called each frame from + /// before injecting face-tracking parameters. + /// + 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; + } + } +} diff --git a/source_code/Cubism/Handlers/Live2DService.cs b/source_code/Cubism/Handlers/Live2DService.cs new file mode 100644 index 0000000..f4dc3de --- /dev/null +++ b/source_code/Cubism/Handlers/Live2DService.cs @@ -0,0 +1,85 @@ +using Godot; +using Cthangover.Live2D.Cubism.Framework; + +namespace Cthangover.Live2D.Cubism.Services +{ + /// + /// Configuration for . + /// When is set, defaults to + /// to disable automatic layout, giving manual + /// coordinates full control. Otherwise is used. + /// + 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; + } + + /// + /// High-level factory for creating Live2D model nodes and attaching them + /// to the scene tree. Encapsulates the boilerplate of instantiating + /// , adding to a parent, and calling + /// . + /// + public static class Live2DService + { + /// + /// Creates a , adds it to , + /// and loads the model from the specified mod. + /// + /// Parent node to attach the model to. + /// Path to the .model3.json file, relative to the mod root. + /// Registered mod ID containing the model assets. + /// Godot node name for the model instance. + /// The created and loaded model node. + public static CubismModelNode CreateModel( + Node parent, + string modelPath, + string nodeName, + string modId = null) + { + return CreateModel(parent, new Live2DModelConfig + { + ModelPath = modelPath, + ModId = modId, + NodeName = nodeName + }); + } + + /// + /// Creates a using full configuration. + /// When is provided, + /// is forced to + /// — manual coordinates take priority over + /// the automatic . + /// + 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; + } + } +} diff --git a/source_code/Live2D.csproj b/source_code/Live2D.csproj new file mode 100644 index 0000000..e699475 --- /dev/null +++ b/source_code/Live2D.csproj @@ -0,0 +1,14 @@ + + + net8.0 + Live2D + Live2D.Cubism + disable + 12 + true + + + + + + diff --git a/source_code/build.bat b/source_code/build.bat new file mode 100644 index 0000000..f41edf9 --- /dev/null +++ b/source_code/build.bat @@ -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 diff --git a/src/AddLive2DModelAction.cs b/src/AddLive2DModelAction.cs new file mode 100644 index 0000000..d1e00e8 --- /dev/null +++ b/src/AddLive2DModelAction.cs @@ -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("ViewBox"); + if (viewBox == null) + { + ctx.LogWarning("LIVE2D", "AddLive2DModelAction: ViewBox not found"); + return; + } + + var existing = viewBox.GetNodeOrNull(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}'"); + } + } +}