Skip to content

loader/vrm: add VRM/GLB loader#321

Open
raa0121 wants to merge 1 commit into
g3n:masterfrom
raa0121:vrm
Open

loader/vrm: add VRM/GLB loader#321
raa0121 wants to merge 1 commit into
g3n:masterfrom
raa0121:vrm

Conversation

@raa0121

@raa0121 raa0121 commented Jun 26, 2026

Copy link
Copy Markdown

Summary

This PR adds a new loader/vrm package that loads VRM and GLB files.
VRM is a glTF 2.0–based file format for 3D humanoid avatars, widely used for
VTuber / metaverse applications. The package is derived from the existing
loader/gltf package and extends it with the handling required to render
rigged VRM avatars correctly.

What's included

  • New package loader/vrm with the following files:
    • loader.go — glTF/GLB parsing and scene/node/mesh/material loading
    • vrm.go — glTF + VRM type definitions
    • material_pbr.go — PBR (metallic-roughness) material loading
    • material_common.go — common/legacy material loading
    • logger.go — package logger
  • Public API mirrors loader/gltf:
    • ParseJSON / ParseJSONReader — load .gltf (JSON)
    • ParseBin / ParseBinReader — load .glb / .vrm (binary)
    • (*GLTF).LoadScene, LoadNode, LoadMesh, LoadSkin,
      LoadAnimation, LoadMaterial, LoadTexture, LoadImage

Key differences from loader/gltf

  • Skinned mesh support: skinned primitives are wrapped in
    graphic.NewRiggedMesh and wired to their graphic.Skeleton via
    SetSkeleton, including the multi-primitive case (each primitive gets its
    own RiggedMesh sharing the same skeleton). This is what makes rigged
    humanoid VRM models render and animate correctly.
  • VRM / extension handling: the root-level VRM extension and
    KHR_materials_unlit are recognized; unlit is treated as advisory and falls
    back to PBR data.

Notes / open questions for maintainers

  • It is implemented as a separate package rather than extending
    loader/gltf, to avoid changing existing glTF behavior. Happy to discuss
    merging the skinned-mesh fix back into loader/gltf if preferred.
  • A few TODOs remain (e.g. viewport aspect ratio for cameras), carried over
    from the gltf loader.
  • Tested manually by loading sample .vrm / .glb avatars. No automated
    tests or demo program are included in this PR — guidance welcome on what the
    project would like to see here.

example

// VRM viewer example: loads a .vrm file and displays it with orbit control.
// Usage: vrm_viewer <path/to/model.vrm>
package main

import (
	"fmt"
	"os"
	"time"

	"github.com/g3n/engine/app"
	"github.com/g3n/engine/camera"
	"github.com/g3n/engine/core"
	"github.com/g3n/engine/gls"
	"github.com/g3n/engine/light"
	"github.com/g3n/engine/loader/vrm"
	"github.com/g3n/engine/math32"
	"github.com/g3n/engine/renderer"
	"github.com/g3n/engine/window"
)

func main() {
	if len(os.Args) < 2 {
		fmt.Fprintln(os.Stderr, "usage: vrm_viewer <model.vrm>")
		os.Exit(1)
	}
	vrmPath := os.Args[1]

	a := app.App(800, 600, "VRM Viewer")
	gs := a.Gls()

	scene := core.NewNode()

	// Lighting
	ambLight := light.NewAmbient(&math32.Color{R: 1, G: 1, B: 1}, 0.8)
	scene.Add(ambLight)
	dirLight := light.NewDirectional(&math32.Color{R: 1, G: 1, B: 1}, 1.0)
	dirLight.SetPosition(0, 5, 5)
	scene.Add(dirLight)

	// Load VRM
	fmt.Printf("Loading: %s\n", vrmPath)
	gltf, err := vrm.ParseBin(vrmPath)
	if err != nil {
		fmt.Fprintf(os.Stderr, "failed to parse VRM: %v\n", err)
		os.Exit(1)
	}
	fmt.Printf("Scenes: %d, Nodes: %d, Meshes: %d, Materials: %d\n",
		len(gltf.Scenes), len(gltf.Nodes), len(gltf.Meshes), len(gltf.Materials))

	var model core.INode
	if gltf.Scene != nil {
		model, err = gltf.LoadScene(*gltf.Scene)
	} else if len(gltf.Scenes) > 0 {
		model, err = gltf.LoadScene(0)
	} else {
		fmt.Fprintln(os.Stderr, "VRM has no scenes")
		os.Exit(1)
	}
	if err != nil {
		fmt.Fprintf(os.Stderr, "failed to load scene: %v\n", err)
		os.Exit(1)
	}

	// Print node positions for debugging
	printTree(model, 0)

	scene.Add(model)

	// Camera: position above and in front of origin
	width, height := a.GetSize()
	aspect := float32(width) / float32(height)
	cam := camera.NewPerspective(aspect, 0.01, 500, 30, camera.Vertical)
	cam.SetPosition(0, 1, 5)
	cam.LookAt(&math32.Vector3{X: 0, Y: 0.8, Z: 0}, &math32.Vector3{X: 0, Y: 1, Z: 0})

	// Orbit control
	camera.NewOrbitControl(cam)

	// Resize handler
	a.Subscribe(window.OnWindowSize, func(evname string, ev interface{}) {
		w, h := a.GetSize()
		gs.Viewport(0, 0, int32(w), int32(h))
		cam.SetAspect(float32(w) / float32(h))
	})

	gs.ClearColor(0.3, 0.3, 0.35, 1.0)

	frameCount := 0
	a.Run(func(rend *renderer.Renderer, delta time.Duration) {
		gs.Clear(gls.DEPTH_BUFFER_BIT | gls.COLOR_BUFFER_BIT)
		if err := rend.Render(scene, cam); err != nil && frameCount < 3 {
			fmt.Fprintf(os.Stderr, "render error: %v\n", err)
		}
		frameCount++
	})
}

func printTree(n core.INode, depth int) {
	node := n.GetNode()
	pos := node.Position()
	prefix := ""
	for i := 0; i < depth; i++ {
		prefix += "  "
	}
	fmt.Printf("%s[%s] pos=(%.2f, %.2f, %.2f) children=%d\n",
		prefix, node.Name(), pos.X, pos.Y, pos.Z, len(node.Children()))
	if depth < 2 {
		for _, child := range node.Children() {
			printTree(child, depth+1)
		}
	}
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant