前言
我完全没用过Live2D呃
想用raylib渲染Live2D。把Renderer改好以后,准备参考Demo导出点类似于Spine的C API,然后就被LAppLive2DManager、LAppView之类的搞得头晕目眩。
此文章是边敲代码边写的,可能会前后矛盾+意义不明。
计划有哪些API
- 初始化Live2D相关环境
- 加载模型
- 更新并渲染模型
- 模型参数读取与更改
- 传入互动参数
- safe exit
你这Renderer有点问题啊(还是我有问题)
渲染是核心,于是我从LAppDelegate::Run()追踪了一下主要工作的调用链。
LAppDelegate::Run()
_view->Render()
Live2DManager->OnUpdate()
foreach _models do
LAppDelegate::GetInstance()->GetView()->PreModelDraw(*model)
model->Update()
model->Draw(projection)
GetRenderer()->DrawModel()
[get CubismModel*]
LAppDelegate::GetInstance()->GetView()->PostModelDraw(*model)
大概清楚了以下细节:
- 每个LAppModel有一个特定实现的CubismRenderer
- 每个CubismRenderer有一个CubismModel
那看看LAppModel的成员(可能与渲染有关的):
Csm::ICubismModelSetting* _modelSetting; // (译)模型设定信息
Csm::Rendering::CubismOffscreenFrame_OpenGLES2 _renderBuffer; // (译)帧缓冲器以外的绘制目标
LAppModel继承Csm::CubismUserModel
void CreateRenderer();
看起来自己修改LAppModel的必要性没那么大了……大部分是继承的闭源的(?)Csm::CubismUserModel。那接下来看看LAppModel方法的实现。
(一段时间后)看起来LAppModel的大部分方法都是挺独立的,和其他LAppXXX关联不大。使用了LAppPal输出日志和获取DeltaTime(记录一下导出的API要看看在哪里更新下这个东西),还使用了LAppDelegate单例的TextureManager加载材质,其他的就没了。还挺好,挺独立的,估计模型相关的导出函数几乎都是和LAppModel打交道了。
看起来形式一片大好,那么我们看看渲染时哪里和LAppModel打交道了:
- LAppLive2DManager::OnUpdate()中model->GetModel()->GetCanvasWidth() > 1.0f && width < height时model->GetModelMatrix()->SetWidth(2.0f)(现在还不懂是干什么的)
- LAppDelegate::GetInstance()->GetView()->PreModelDraw(*model)
- model->Update();
- model->Draw(projection);
- LAppDelegate::GetInstance()->GetView()->PostModelDraw(*model);
下面是一些发现和思考:
- Draw的projection参数为View-Projection矩阵
- PreModelDraw、PostModelDraw是用来把模型画在一单独材质上方便半透明的
补补课:
MVP矩阵(Model-View-Projection矩阵):
- Model:顶点坐标从模型空间到世界空间
- View:顶点坐标从世界空间到视觉空间(以camera为中心的坐标系)
- Projection:顶点坐标从视觉空间到裁剪空间(判断顶点是否在可见范围内)
我们再看看Draw的实现:
void LAppModel::Draw(CubismMatrix44& matrix) {
matrix.MultiplyByMatrix(_modelMatrix);
GetRenderer<...>()->SetMvpMatrix(&matrix);
DoDraw();
}
嗯!修改模型的缩放、平移等直接调用CubismUserModel::GetModelMatrix()修改Model矩阵就好了。
渲染先看到这里,半透明啥的先不管。对渲染已经了解一二以后就要考虑一下初始化啥的了。
Demo是自己创建的窗口,但是我们写成库的话自然就不用管这些东西。顺着LAppDelegate::Initialize()看看就好了。
bool LAppDelegate::Initialize()
{
//窗口创建相关
//int width, height;
//glfwGetWindowSize(LAppDelegate::GetInstance()->GetWindow(), &width, &height);
//_windowWidth = width;
//_windowHeight = height;
//初始化AppView
_view->Initialize();
//初始化Cubism SDK
InitializeCubism();
return 1;
}
void LAppDelegate::InitializeCubism()
{
//setup cubism
_cubismOption.LogFunction = LAppPal::PrintMessage;
_cubismOption.LoggingLevel = LAppDefine::CubismLoggingLevel;
Csm::CubismFramework::StartUp(&_cubismAllocator, &_cubismOption);
//Initialize cubism
CubismFramework::Initialize();
//加载模型
LAppLive2DManager::GetInstance();
//初始化计时器
LAppPal::UpdateTime();
//窗口的其他sprite
_view->InitializeSprite();
}
第二个注释块专门留着是因为之前看代码看到了很多遍_windowWidth 之类的东西。
本来把LAppView::Initialize()也粘上来了,但是里面大部分都是初始化View矩阵,现在不咋感兴趣,就删了。
那么就不管AppView,先简单实现一下init吧!
void l2dInit() {
auto cubismAllocator = new LAppAllocator();
auto cubismOption = new CubismFramework::Option();
cubismOption->LogFunction = LAppPal::PrintMessage;
cubismOption->LoggingLevel = LAppDefine::CubismLoggingLevel;
Csm::CubismFramework::StartUp(cubismAllocator, cubismOption);
//初始化cubism
CubismFramework::Initialize();
//初始化计时器
LAppPal::UpdateTime();
}
先mark一下LAppAllocator。感觉这样写写init就差不多了,那么接下来就是加载模型了。正好别人注释写着LAppLive2DManager::GetInstance()加载了模型,那就跟进去看一下。
加载模型的主要代码在LAppLive2DManager::ChangeScene(Csm::csmInt32 index)里面,稍微简化一下:
// ModelDir[]に保持したディレクトリ名から
// model3.jsonのパスを決定する.
// ディレクトリ名とmodel3.jsonの名前を一致させておくこと.
std::string model = ModelDir[index];
std::string modelPath = ResourcesPath + model + "/";
std::string modelJsonName = ModelDir[index];
modelJsonName += ".model3.json";
ReleaseAllModel();
_models.PushBack(new LAppModel());
_models[0]->LoadAssets(modelPath.c_str(), modelJsonName.c_str());
// 下面有一下好像是关于半透明的代码,还有和AppView相关的代码
很简单的逻辑,参数也是在代码里面拼好的。直接开干:(参数注释已忽略)
void* l2dLoadModel(const char* dir, const char* filename) {
auto model = new LAppModel();
model->LoadAssets(dir, filename);
return model;
}
渲染的话,projection直接传个单位矩阵试试吧……
void* l2dUpdateModel(void* model) {
auto typedModel = static_cast<LAppModel*>(model);
CubismMatrix44 matrix;
matrix.LoadIdentity();
typedModel->Draw(matrix);
}
当然,为了能编译出来,代码里面对OpenGL的依赖已经完全移除了。
主程序:
#include "raylib.h"
#include "dll.hpp"
int main() {
InitWindow(800, 600, "Live2D Test");
l2dInit();
void* model = l2dLoadModel("Resources/Mao/", "Mao.model3.json");
while (!WindowShouldClose()) {
BeginDrawing();
ClearBackground(RAYWHITE);
l2dUpdateModel(model);
DrawFPS(20, 20);
EndDrawing();
}
}
至少从可以看到的部分来说还是让人欣慰的。不过奇怪的是帧率数字没出来。后面又在loop里面画了点图形一样没出来。而且AppPal的时间忘记更新了并且模型没update于是动画也没有。先看看动画能不能出来?(敲键盘)动画正常了现在?
(后续:发现问题在于之前偷懒在renderer里面硬编码了个窗口大小,导致viewport设置错了。但是这也不能怪我啊,谁知道save/restore配对的情况下里面的东西在Draw里面用到了啊)
呃……被压扁了。
渲染的话,projection直接传个单位矩阵试试吧……
回头看AppView里面的代码,果然projection不能直接单位矩阵……加了以下代码(直接从AppView复制过来的):
Csm::CubismMatrix44 projection;
auto width = GetScreenWidth();
auto height = GetScreenHeight();
if (typedModel->GetModel()->GetCanvasWidth() > 1.0f && width < height)
{
// 横に長いモデルを縦長ウィンドウに表示する際モデルの横サイズでscaleを算出する
typedModel->GetModelMatrix()->SetWidth(2.0f);
projection.Scale(1.0f, static_cast<float>(width) / static_cast<float>(height));
}
else
{
projection.Scale(static_cast<float>(height) / static_cast<float>(width), 1.0f);
}
加上以后渲染就完全正常了(懒得截图了)。
简单理解(猜测)了一下:这里的MVP矩阵(初始状态)具体作用是:
- M矩阵:将模型画布以正确的宽高比正确放置在归一化的”世界”坐标系下面;
- VP(projection)矩阵:将模型认为的”世界”坐标系以正确的比例放置在归一化的世界坐标系下面。
世界?
这就让我很疑惑了……因为按照Demo处理鼠标坐标的方法,把鼠标放置在屏幕(归一化坐标)的(1, 0)处时使用Demo中_deviceToScreen得到的坐标是(1.33, 0)(窗口为4:3)……这个”世界”让人又迷惑起来了,不正确处理好这个”世界”的话感觉鼠标坐标啥的也得乱套……
(打开原始Demo)(点击窗口最右侧中部)谔谔,仍然不是(1,0)……
现在有两种选择:
- 保持原状,好好维护VP矩阵和各种变换;
- 改改Model矩阵。
我更偏向于选择2……先去看看鼠标变换吧……我们拿HitTest做实践吧……
现有:OK
我用了这段代码让模型向右平移:
typedModel->GetModelMatrix()->TranslateRelative(50.0 / 1600 * LAppPal::GetDeltaTime(), 0);
然后这样进行Hit Tesit:(x、y都是用deviceToScreen变换了的)
TraceLog(LOG_INFO, "%f %f", x, y);
if (typedModel->HitTest(HitAreaNameHead, x, y)) {
TraceLog(LOG_INFO, "Hit Test");
}
嗯……模型移动的时候HitTest也有好好工作呢……所以说HitTest过程应该是用到了ModelMatrix的。
接下来的目标,我们想把现在的projection矩阵乘到model里面,然后鼠标位置就可以用我想象的归一化坐标了!
具体代码就不展现了。现在Model矩阵的含义就大致符合了我对Model矩阵的想象了。比如说像右边移动10px(窗体宽800px):
model->GetModelMatrix()->TranslateRelative(10.0 / 800 * LAppPal::GetDeltaTime(), 0);
移动与缩放功能
最后通过种种分析与debug,完成了移动与缩放的Model矩阵建立。下面放上未优化的代码:
void l2dUpdateModelMatrix(Live2DManagedData* data) {
auto model = static_cast<LAppModel*>(data->model);
auto lmodel = model->GetModel();
float mw = lmodel->GetCanvasWidthPixel();
float mh = lmodel->GetCanvasHeightPixel();
float sw = GetScreenWidth();
float sh = GetScreenHeight();
CubismMatrix44 mat;
// 1. fill the window
if (mw > mh) {
mat.Scale(1, mw / mh);
} else {
mat.Scale(mh / mw, 1);
}
// 2. calculate current scale
float scaleX0 = sw / mw;
float scaleY0 = sh / mh;
// 3. scale to user settings
mat.ScaleRelative(data->scaleX / scaleX0, data->scaleX / scaleY0);
// 4. calculate current coordinates (left, top) (normalized)
float x0 = -(mw * data->scaleX) / sw;
float y0 = (mh * data->scaleY) / sh;
// 5. trnasform (data->x, data->y) into normalized coordinates
float x1 = (data->x / sw) * 2 - 1;
float y1 = -((data->y / sh) * 2 - 1);
// 5. move the model
mat.Translate(x1 - x0, y1 - y0);
model->GetModelMatrix()->SetMatrix(mat.GetArray());
}
第一步是最奇怪的。似乎Live2D的库会用一个最小的正方形来容纳画布,然后以这个正方形作为世界范围建立均一化坐标并且映射到实际的窗口上。而第一步就是让整个画布来代替那个正方形。
自此大概坐标系就差不多了。
Update
接下来看看互动相关的,比如说Update。
LAppModel::Update()大概干了这些事情:
- _model->LoadParameters(); // 加载上次被保存的状态
- … // 随机动画、保存状态
- if (…) _eyeBlink->UpdateParameters(…);
- _expressionManager?->UpdateMotion(_model, deltaTimeSeconds);
- _model->AddParameterValue(…, _drag…);
- _breath?->UpdateParameters(_model, deltaTimeSeconds);
- _physics?->Evaluate(_model, deltaTimeSeconds);
- _lipSync…
- _pose?->UpdateParameters(_model, deltaTimeSeconds);
- _model->Update();
我们知道了:
- 每次(managed)Update开始会加载保存好的状态,这个保存好的状态是加载motion以后保存的;
- 跟随鼠标什么的是自行计算的(5.);
- Cubism Framework自带了很多辅助计算ParameterValue的东西。
其他的没啥好说的,除开2、3、5其他大概直接用就行了。2、3就开放几个设置motion的API就好了。
但是这个鼠标追踪有点问题……
- 它用的是Default Parameter Names,会出现问题吗?
- 鼠标原点是(0, 0),而不是头部,也就是把鼠标放在头中间其实模型是往上抬头的……
如果要去掉这个鼠标跟踪的话,我们就得看看LAppModel的哪些东西是仅与这个东西相关的,方便我们把这个东西独立出来。
首先注意到几个Default Parameter Name。我本以为只有这里用到了这几个参数名,但是Ctrl+K, R以后我就傻了,因为看到了以下代码(LAppModel::SetupModel):
//Breath
{
_breath = CubismBreath::Create();
csmVector<CubismBreath::BreathParameterData> breathParameters;
breathParameters.PushBack(CubismBreath::BreathParameterData(_idParamAngleX, 0.0f, 15.0f, 6.5345f, 0.5f));
breathParameters.PushBack(CubismBreath::BreathParameterData(_idParamAngleY, 0.0f, 8.0f, 3.5345f, 0.5f));
breathParameters.PushBack(CubismBreath::BreathParameterData(_idParamAngleZ, 0.0f, 10.0f, 5.5345f, 0.5f));
breathParameters.PushBack(CubismBreath::BreathParameterData(_idParamBodyAngleX, 0.0f, 4.0f, 15.5345f, 0.5f));
breathParameters.PushBack(CubismBreath::BreathParameterData(CubismFramework::GetIdManager()->GetId(ParamBreath), 0.5f, 0.5f, 3.2345f, 0.5f));
_breath->SetParameters(breathParameters);
}
魔法数字?好了现在LAppModel::SetupModel也得看一遍了……
理一理这个函数做的事情:
- 加载moc文件,以此文件为参考加载模型数据
- 加载所有exp文件,即expression
- 加载物理
- 加载pose
- CubismEyeBlink::Create(_modelSetting) // 使用了_modelSetting->GetEyeBlinkParameterId
- breath(上面的代码)
- 可选的加载UserData
- 自己又读了一份_eyeBlinkIds
- 自己读了一份_lipSyncIds
- _modelMatrix->SetupFromLayout(_modelSetting->GetLayoutMap())
- SaveParameters,PreloadMotionGroups,StopAllMotions
(顺便吐槽一下,这样的代码很有意思吗……我确实不明白这为什么是个成员函数)
ACubismMotion* CubismUserModel::LoadExpression(const csmByte* buffer, csmSizeInt size, const csmChar* name)
{
return CubismExpressionMotion::Create(buffer, size);
}
(哦我浅薄了,这是virtual CubismUserModel::LoadExpression,是LAppModel override的,太高级了)
大概离谱的地方就是呼吸参数了,我大概也理解了一下,原因大概在于没有呼吸参数的加载……其他参数都是直接读取的文件。这个画风的加载我估计就不大敢用了,独立出来算了……
大概就是这样了,Update的过程:
- Update Environment:更新delta time等全局信息
- Model Pre Update:更新上面除开呼吸的其他东西
- Model User Update:用户此时可以更新参数
- Model Update & Draw:_model->Update()和Draw()
具体的函数定义在LightBuild的live2d/lib/src/dll.c可以看到。大概就这样吧,其他也就是杂活了。
这事好的