WebGAL icon indicating copy to clipboard operation
WebGAL copied to clipboard

关于新版live2d(Cubism3+)的表情动效混合问题

Open BadArgument opened this issue 1 month ago • 2 comments

在Live2D Cubism 3及以上版本的部分模型中,motion(动作)和expression(表情)在导出的模型中不再区分,可以将表情和动作统一作为动效进行混合处理。以Project Sekai的模型为例,其表情文件同样以motion3.json格式存储,运行时采用先应用动效、再叠加表情的渲染顺序。 而WebGAL采用的解析方式无法识别作为动效储存的表情,导致在设置时,“Live2D表情”只能留空(如图)

Image

因此,若想要实现类似Project Sekai的live2d渲染,在目前的WebGAL版本中必须将Live2D分为两步,第一步定义动作,第二步叠加表情。 然而这引出了新的问题:如果按照上图所示开启“连续执行”,则后一步的表情将会覆盖前一步的动作,导致动作被重置,无法实现叠加。

(正确渲染:关闭“连续执行”,手动触发模型更新) Image (错误渲染:开启“连续执行”) Image

请问是否有方法解决动效的自动混合问题?

BadArgument avatar Dec 05 '25 16:12 BadArgument

在Live2D Cubism 3及以上版本的部分模型中,motion(动作)和expression(表情)在导出的模型中不再区分,可以将表情和动作统一作为动效进行混合处理。以Project Sekai的模型为例,其表情文件同样以motion3.json格式存储,运行时采用先应用动效、再叠加表情的渲染顺序。 而WebGAL采用的解析方式无法识别作为动效储存的表情,导致在设置时,“Live2D表情”只能留空(如图)

  1. 其实 model 3 也是有表情的,至少在 API 里确实存在(Expressions 对象),应该是 PJSK 的模型采用了“表情也用动作”的方案。所以现行的编辑器没有什么问题。

因此,若想要实现类似Project Sekai的live2d渲染,在目前的WebGAL版本中必须将Live2D分为两步,第一步定义动作,第二步叠加表情。 然而这引出了新的问题:如果按照上图所示开启“连续执行”,则后一步的表情将会覆盖前一步的动作,导致动作被重置,无法实现叠加。

  1. 这个问题的根本在于,目前 WebGAL 使用的这个 Live2D 库不支持平行播放多个动作,此外还有 WebGAL 的数据驱动的逻辑。

它能被修复吗

可能有点困难。

代替方案

我试了下让 AI 生成了一个批处理脚本,提取 face_ 开头的动作文件,解析最后一帧并转换为表情文件,它似乎运行的很好。

Image

如果您需要这段代码的话

这是一个名为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"

HardyNLee avatar Dec 08 '25 18:12 HardyNLee

感谢您的回答。 我这个Issue提出的本意是想要抛砖引玉,寻找一种live2d动态组合motion的一种实现方法。周末花了些时间读了读Live2d Cubism3 Framework的源码,看了看Live2d的参数调整与播放,如果不是大学课程繁忙,我可能会抽空试着自己写一版,试着提一个PR去解决这个问题。 再次感谢您抽空回复这个Issue。

BadArgument avatar Dec 09 '25 02:12 BadArgument