关于新版live2d(Cubism3+)的表情动效混合问题
在Live2D Cubism 3及以上版本的部分模型中,motion(动作)和expression(表情)在导出的模型中不再区分,可以将表情和动作统一作为动效进行混合处理。以Project Sekai的模型为例,其表情文件同样以motion3.json格式存储,运行时采用先应用动效、再叠加表情的渲染顺序。 而WebGAL采用的解析方式无法识别作为动效储存的表情,导致在设置时,“Live2D表情”只能留空(如图)
因此,若想要实现类似Project Sekai的live2d渲染,在目前的WebGAL版本中必须将Live2D分为两步,第一步定义动作,第二步叠加表情。 然而这引出了新的问题:如果按照上图所示开启“连续执行”,则后一步的表情将会覆盖前一步的动作,导致动作被重置,无法实现叠加。
(正确渲染:关闭“连续执行”,手动触发模型更新)
(错误渲染:开启“连续执行”)
请问是否有方法解决动效的自动混合问题?
在Live2D Cubism 3及以上版本的部分模型中,motion(动作)和expression(表情)在导出的模型中不再区分,可以将表情和动作统一作为动效进行混合处理。以Project Sekai的模型为例,其表情文件同样以motion3.json格式存储,运行时采用先应用动效、再叠加表情的渲染顺序。 而WebGAL采用的解析方式无法识别作为动效储存的表情,导致在设置时,“Live2D表情”只能留空(如图)
- 其实 model 3 也是有表情的,至少在 API 里确实存在(Expressions 对象),应该是 PJSK 的模型采用了“表情也用动作”的方案。所以现行的编辑器没有什么问题。
因此,若想要实现类似Project Sekai的live2d渲染,在目前的WebGAL版本中必须将Live2D分为两步,第一步定义动作,第二步叠加表情。 然而这引出了新的问题:如果按照上图所示开启“连续执行”,则后一步的表情将会覆盖前一步的动作,导致动作被重置,无法实现叠加。
- 这个问题的根本在于,目前 WebGAL 使用的这个 Live2D 库不支持平行播放多个动作,此外还有 WebGAL 的数据驱动的逻辑。
它能被修复吗
可能有点困难。
代替方案
我试了下让 AI 生成了一个批处理脚本,提取 face_ 开头的动作文件,解析最后一帧并转换为表情文件,它似乎运行的很好。
如果您需要这段代码的话
这是一个名为motion-to-expression.ps1的文件
# Live2D Motion to Expression Converter (Pure PowerShell)
param(
[string]$ModelPath = ""
)
# Set console encoding
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$Host.UI.RawUI.WindowTitle = "Live2D Motion to Expression Tool"
function Show-Header {
Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " Live2D Motion to Expression Tool" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
}
function Read-JsonFile {
param([string]$Path)
try {
$content = Get-Content -Path $Path -Raw -Encoding UTF8
return $content | ConvertFrom-Json
}
catch {
Write-Host "Error: Cannot read JSON file: $($_.Exception.Message)" -ForegroundColor Red
return $null
}
}
function Write-JsonFile {
param(
[string]$Path,
[object]$Data
)
try {
# Use Depth 100 to handle deeply nested structures
# Compress to remove extra whitespace, then format with 2-space indent
$json = $Data | ConvertTo-Json -Depth 100 -Compress
# Format JSON with 2-space indentation
$indent = 0
$formatted = ""
$inString = $false
$escapeNext = $false
for ($i = 0; $i -lt $json.Length; $i++) {
$char = $json[$i]
if ($escapeNext) {
$formatted += $char
$escapeNext = $false
continue
}
if ($char -eq '\') {
$formatted += $char
$escapeNext = $true
continue
}
if ($char -eq '"') {
$formatted += $char
$inString = -not $inString
continue
}
if (-not $inString) {
if ($char -eq '{' -or $char -eq '[') {
$formatted += $char
$indent++
if ($i + 1 -lt $json.Length -and $json[$i + 1] -ne '}' -and $json[$i + 1] -ne ']') {
$formatted += "`r`n" + (" " * $indent)
}
}
elseif ($char -eq '}' -or $char -eq ']') {
if ($formatted[-1] -ne '{' -and $formatted[-1] -ne '[') {
$indent--
$formatted += "`r`n" + (" " * $indent)
}
else {
$indent--
}
$formatted += $char
}
elseif ($char -eq ',') {
$formatted += $char + "`r`n" + (" " * $indent)
}
elseif ($char -eq ':') {
$formatted += $char + " "
}
else {
$formatted += $char
}
}
else {
$formatted += $char
}
}
[System.IO.File]::WriteAllText($Path, $formatted, [System.Text.Encoding]::UTF8)
return $true
}
catch {
Write-Host "Error: Cannot write file: $($_.Exception.Message)" -ForegroundColor Red
return $false
}
}
function Extract-MotionParameters {
param(
[object]$MotionData,
[string]$ExtractMode,
[string]$BlendMode
)
$curves = $MotionData.Curves
if (-not $curves) {
return @()
}
$duration = $MotionData.Meta.Duration
if (-not $duration) {
$duration = 0
}
$parameters = @()
# Determine target time
if ($ExtractMode -eq "start") {
$targetTime = 0
}
elseif ($ExtractMode -eq "end") {
$targetTime = $duration
}
else {
$targetTime = [double]$ExtractMode
}
foreach ($curve in $curves) {
if ($curve.Target -ne "Parameter") {
continue
}
$segments = $curve.Segments
if (-not $segments -or $segments.Count -lt 2) {
continue
}
# Parse keyframe points
$points = @()
$pos = 0
# First point
$pointObj = New-Object PSObject -Property @{
time = $segments[0]
value = $segments[1]
}
$points += $pointObj
$pos = 2
# Parse remaining segments
while ($pos -lt $segments.Count) {
$segmentType = $segments[$pos]
$pos++
if ($segmentType -eq 0 -or $segmentType -eq 2 -or $segmentType -eq 3) {
# Linear, Stepped, InverseStepped
if (($pos + 1) -lt $segments.Count) {
$pointObj = New-Object PSObject -Property @{
time = $segments[$pos]
value = $segments[$pos + 1]
}
$points += $pointObj
$pos += 2
}
else {
break
}
}
elseif ($segmentType -eq 1) {
# Bezier
if (($pos + 5) -lt $segments.Count) {
$pointObj = New-Object PSObject -Property @{
time = $segments[$pos + 4]
value = $segments[$pos + 5]
}
$points += $pointObj
$pos += 6
}
else {
break
}
}
else {
break
}
}
# Select value
$value = $null
if ($ExtractMode -eq "end") {
if ($points.Count -gt 0) {
$value = $points[$points.Count - 1].value
}
}
elseif ($ExtractMode -eq "start") {
$value = $points[0].value
}
else {
foreach ($point in $points) {
if ($point.time -ge $targetTime) {
$value = $point.value
break
}
$value = $point.value
}
}
if ($null -ne $value) {
$paramObj = New-Object PSObject -Property @{
Id = $curve.Id
Value = $value
Blend = $BlendMode
}
$parameters += $paramObj
}
}
return $parameters
}
function Convert-MotionToExpression {
param(
[string]$MotionPath,
[string]$OutputPath,
[string]$ExtractMode,
[string]$BlendMode
)
$motionData = Read-JsonFile -Path $MotionPath
if (-not $motionData) {
$result = New-Object PSObject -Property @{
Success = $false
Reason = "Cannot read file"
}
return $result
}
$parameters = Extract-MotionParameters -MotionData $motionData -ExtractMode $ExtractMode -BlendMode $BlendMode
if ($parameters.Count -eq 0) {
$result = New-Object PSObject -Property @{
Success = $false
Reason = "No parameters found"
}
return $result
}
$expressionData = New-Object PSObject -Property @{
Type = "Live2D Expression"
FadeInTime = 0.5
FadeOutTime = 0.5
Parameters = $parameters
}
$success = Write-JsonFile -Path $OutputPath -Data $expressionData
if ($success) {
$result = New-Object PSObject -Property @{
Success = $true
ParamCount = $parameters.Count
}
return $result
}
else {
$result = New-Object PSObject -Property @{
Success = $false
Reason = "Write failed"
}
return $result
}
}
# Main Program
Show-Header
# 1. Select model file
if (-not $ModelPath) {
Write-Host "Please enter the full path of model3.json file" -ForegroundColor Yellow
Write-Host "(or drag and drop the file here):" -ForegroundColor Yellow
Write-Host ""
$ModelPath = Read-Host
}
$ModelPath = $ModelPath.Trim().Trim('"').Trim("'")
if (-not (Test-Path $ModelPath)) {
Write-Host ""
Write-Host "Error: File does not exist!" -ForegroundColor Red
Read-Host "`nPress Enter to exit"
exit
}
if (-not $ModelPath.EndsWith(".model3.json")) {
Write-Host ""
Write-Host "Error: Please select a .model3.json file!" -ForegroundColor Red
Read-Host "`nPress Enter to exit"
exit
}
Write-Host ""
Write-Host "Selected: $(Split-Path $ModelPath -Leaf)" -ForegroundColor Green
Write-Host ""
# 2. Read model file
$modelData = Read-JsonFile -Path $ModelPath
if (-not $modelData) {
Read-Host "`nPress Enter to exit"
exit
}
# 3. Get motion list
$motions = $modelData.FileReferences.Motions
if (-not $motions) {
Write-Host "Error: No motion definitions found in model file!" -ForegroundColor Red
Read-Host "`nPress Enter to exit"
exit
}
$allMotionFiles = @()
foreach ($group in $motions.PSObject.Properties) {
foreach ($motion in $group.Value) {
if ($motion.File) {
$motionObj = New-Object PSObject -Property @{
Group = $group.Name
File = $motion.File
}
$allMotionFiles += $motionObj
}
}
}
if ($allMotionFiles.Count -eq 0) {
Write-Host "Error: No motion files found!" -ForegroundColor Red
Read-Host "`nPress Enter to exit"
exit
}
Write-Host "Found $($allMotionFiles.Count) motion files" -ForegroundColor Cyan
Write-Host ""
# 4. Select filter
Write-Host "Select motions to convert:" -ForegroundColor Yellow
Write-Host "1. All motions"
Write-Host "2. Only files containing 'face' (recommended)"
Write-Host "3. Custom filter (enter keyword)"
Write-Host ""
$filterChoice = Read-Host "Enter option (1-3, default 2)"
$filterKeyword = ""
if ($filterChoice -eq "1") {
$filterKeyword = ""
}
elseif ($filterChoice -eq "3") {
$filterKeyword = Read-Host "Enter filename keyword (e.g. face, smile)"
}
else {
$filterKeyword = "face"
}
$filteredMotions = if ($filterKeyword) {
$allMotionFiles | Where-Object { $_.File -like "*$filterKeyword*" }
} else {
$allMotionFiles
}
if ($filteredMotions.Count -eq 0) {
Write-Host ""
Write-Host "Error: No motion files found containing '$filterKeyword'!" -ForegroundColor Red
Read-Host "`nPress Enter to exit"
exit
}
Write-Host ""
Write-Host "Will convert $($filteredMotions.Count) motions" -ForegroundColor Green
Write-Host ""
# 5. Select extraction mode
Write-Host "Select parameter extraction mode:" -ForegroundColor Yellow
Write-Host "1. Extract last frame (end state, recommended)"
Write-Host "2. Extract first frame (start state)"
Write-Host "3. Custom time point (enter seconds)"
Write-Host ""
$modeChoice = Read-Host "Enter option (1-3, default 1)"
$extractMode = "end"
if ($modeChoice -eq "2") {
$extractMode = "start"
}
elseif ($modeChoice -eq "3") {
$timeInput = Read-Host "Enter time point (seconds)"
if ($timeInput) {
$extractMode = $timeInput
}
else {
$extractMode = "0"
}
}
# 6. Select blend mode
Write-Host ""
Write-Host "Select parameter blend mode:" -ForegroundColor Yellow
Write-Host "1. Add - Additive (recommended)"
Write-Host "2. Multiply - Multiplicative"
Write-Host "3. Overwrite - Replace"
Write-Host ""
$blendChoice = Read-Host "Enter option (1-3, default 1)"
$blendMode = "Add"
if ($blendChoice -eq "2") {
$blendMode = "Multiply"
}
elseif ($blendChoice -eq "3") {
$blendMode = "Overwrite"
}
Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan
Write-Host "Starting conversion..." -ForegroundColor Cyan
Write-Host ""
# 7. Create output directory
$modelDir = Split-Path $ModelPath -Parent
$expressionsDir = Join-Path $modelDir "expressions"
if (-not (Test-Path $expressionsDir)) {
New-Item -Path $expressionsDir -ItemType Directory -Force | Out-Null
Write-Host "Created directory: expressions/" -ForegroundColor Green
Write-Host ""
}
# 8. Convert motion files
$successCount = 0
$convertedExpressions = @()
foreach ($motion in $filteredMotions) {
$motionFullPath = Join-Path $modelDir $motion.File
$motionFileName = Split-Path $motion.File -Leaf
$expressionFileName = $motionFileName -replace '\.motion3\.json$', '.exp3.json'
$expressionPath = Join-Path $expressionsDir $expressionFileName
if (-not (Test-Path $motionFullPath)) {
Write-Host "Skipped: $motionFileName (file not found)" -ForegroundColor Yellow
continue
}
$result = Convert-MotionToExpression -MotionPath $motionFullPath -OutputPath $expressionPath -ExtractMode $extractMode -BlendMode $blendMode
if ($result.Success) {
Write-Host "OK: $motionFileName -> $expressionFileName ($($result.ParamCount) params)" -ForegroundColor Green
$name = $expressionFileName -replace '\.exp3\.json$', '' -replace '^face_', ''
$expObj = New-Object PSObject -Property @{
Name = $name
File = "expressions/$expressionFileName"
}
$convertedExpressions += $expObj
$successCount++
}
else {
Write-Host "Failed: $motionFileName - $($result.Reason)" -ForegroundColor Red
}
}
Write-Host ""
Write-Host "Conversion completed: $successCount/$($filteredMotions.Count)" -ForegroundColor Cyan
Write-Host ""
# 9. Update model file
if ($successCount -gt 0) {
$updateModel = Read-Host "Update model3.json with expression references? (Y/n)"
if ($updateModel -ne "n" -and $updateModel -ne "N") {
# Merge existing expressions
$expressionMap = @{}
if ($modelData.FileReferences.Expressions) {
foreach ($exp in $modelData.FileReferences.Expressions) {
$expressionMap[$exp.Name] = $exp
}
}
foreach ($exp in $convertedExpressions) {
$expObj = New-Object PSObject -Property @{
Name = $exp.Name
File = $exp.File
}
$expressionMap[$exp.Name] = $expObj
}
# Create Expressions array
$expressionsArray = @($expressionMap.Values)
# Update model data - need to use Add-Member for PSObject
if (-not $modelData.FileReferences) {
$modelData | Add-Member -NotePropertyName "FileReferences" -NotePropertyValue (New-Object PSObject) -Force
}
if ($modelData.FileReferences.PSObject.Properties['Expressions']) {
$modelData.FileReferences.Expressions = $expressionsArray
}
else {
$modelData.FileReferences | Add-Member -NotePropertyName "Expressions" -NotePropertyValue $expressionsArray -Force
}
if (Write-JsonFile -Path $ModelPath -Data $modelData) {
Write-Host "Updated $(Split-Path $ModelPath -Leaf)" -ForegroundColor Green
Write-Host "Total expressions: $($expressionsArray.Count)" -ForegroundColor Green
Write-Host ""
}
}
}
Write-Host "========================================" -ForegroundColor Cyan
Write-Host "All done!" -ForegroundColor Green
Write-Host "========================================" -ForegroundColor Cyan
Read-Host "`nPress Enter to exit"
感谢您的回答。 我这个Issue提出的本意是想要抛砖引玉,寻找一种live2d动态组合motion的一种实现方法。周末花了些时间读了读Live2d Cubism3 Framework的源码,看了看Live2d的参数调整与播放,如果不是大学课程繁忙,我可能会抽空试着自己写一版,试着提一个PR去解决这个问题。 再次感谢您抽空回复这个Issue。