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;
}
}
}