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 CLI | Command line / Batch | Batch-convert multiple models at once |
| Next.js Web App | Web upload / Download | Upload ZIP in browser, download VRM |
| Browser SPA | Runs without a server | Client-only conversion |
Web app — ZIP in, VRM out
Result
| Category | Result |
|---|---|
| Conversion workflow | Implemented ZIP input, PMX parsing, VRM output, and structural validation |
| File paths | Handled encoding issues in Japanese and Chinese file paths |
| Conversion Speed | 393ms (Saber.pmx, 28K vertices) |
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.
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.