Characters converted from PMX to VRM

PMX to VRM Pipeline

PMX conversion and internal validation for an existing VRM import path

Internal R&D · Python · TypeScript · glTF 2.0

The pipeline completed internal testing and technical validation. It was not integrated into the Cinev Studio product.

Problem

After adding external VRM support, the product needed an import path for PMX models. I built a conversion route that could reuse the existing VRM loader and needed a way to distinguish source-model issues from converter issues.

Solution

I proposed a PMX → glTF → VRM conversion path to reuse the existing loader. I implemented bone mapping, preserved physics-bone groups for hair, skirts, and ribbons, and added metadata for outline-disable flags. The pipeline was implemented as a Python CLI, a web app, and a serverless browser SPA.

Pipeline Flow

ZIP → PMX Parse → glTF Build → VRM Output
Tool Interaction Method
Python CLICommand line / BatchBatch-convert multiple models at once
Next.js Web AppWeb upload / DownloadUpload ZIP in browser, download VRM
Browser SPARuns without a serverClient-only conversion
pmx2vrm Web App — ZIP Upload to VRM Conversion + 6-Layer Validation

Web app — ZIP in, VRM out

Result

Category Result
Conversion workflowImplemented ZIP input, PMX parsing, VRM output, and structural validation
File pathsHandled encoding issues in Japanese and Chinese file paths
Conversion Speed393ms (Saber.pmx, 28K vertices)
Before (Star Rail PMX) / After (VRM conversion result)

Left: Original game screenshot — Right: Viewer rendering after PMX to VRM conversion


Why This Was Hard

Every texture comes out white

Problem: Converting models with CJK filenames produced all-white 1×1 fallback textures. Console flooded with Failed to load texture.

Cause: Texture paths had two sources. ZIP entry names are encoded in the OS codepage (Shift-JIS, GBK, etc.), while PMX binary stores paths precisely in UTF-16LE. Extracting the ZIP creates mojibake filenames on disk, but PMX looks for the correct name — file not found.

Fix: When a texture file is missing, try 6 encoding round-trips (GBK↔EUC-KR, GBK↔Shift-JIS, etc.) to reverse-map the filename to the mojibake version on disk. TS does proactive decoding at extraction time; Python does reverse matching at load time — different runtimes, different solutions.

ZIP extraction produces garbled filenames like this

PMX binary stores the correct CJK texture filenames

Chinese models' spring bones are all frozen

Problem: Japanese PMX (Miku, Archer) had natural hair movement, but Chinese PMX (HoYoverse, etc.) had completely frozen spring bones.

Cause: Japanese and Chinese modelers follow different physics conventions. Japanese models use spring_constant for restoring force with 0.5–0.8 damping. Chinese models set spring_constant=0, control motion via rotation limits, and use 0.99+ damping. The converter didn't pass rotation limits and had no dragForce cap, so Chinese models got dragForce≈1.0 — effectively frozen.

Fix: Restored rotation limits as a parameter source, capped dragForce at 0.8, and skipped spring bone generation for chains with all-zero rotation limits (accessories), fixing them to parent bones. Spring bones went from 571 to 120.

Legs don't move on certain models

Problem: Certain models had completely immobile legs. Models like Miku worked fine, so the bug went unnoticed for a while.

Cause: PMX has a "D-bone" (double bone) structure. 左足 (left leg) and 左足D coexist, but vertex weights are assigned only to the D-bone. The converter always picked the regular bone (左足) — mapping to an empty bone with zero weights. Models without D-bones showed no symptoms, making the root cause hard to trace.

Fix: Pass skinnedBoneIndices (list of bones with actual vertex weights) to the bone mapping function so D-bones are prioritized when they exist. The priority logic was already implemented — it just wasn't being called with the right parameter.

MMD Player Live UE Scene Bridge