feat(stage-tamagotchi): add experimental Godot stage sidecar (#1830)

## Summary

Adds the experimental Godot stage sidecar path for `stage-tamagotchi`.

This PR wires the existing Tamagotchi model selection flow into an
external Godot runtime window. The renderer gates Godot scene input to
VRM models, Electron main materialises the selected model bytes to a
local file, and the Godot sidecar receives the native path over a local
WebSocket bridge before importing and displaying the avatar at runtime.

## What Changed

- Added a typed Godot scene input contract with `format: "vrm"`.
- Added renderer-side VRM-only gating before sending selected model data
to Electron main.
- Added Electron main sidecar management for:
  - launching Godot
  - starting the local WebSocket bridge
  - materialising selected VRM bytes under app `userData`
  - forwarding scene apply messages to Godot
  - optional remote debugging support
- Added Godot runtime scripts for:
  - sidecar startup and WebSocket orchestration
  - message envelope parsing
  - avatar import and atomic replacement
  - runtime VRM import through Godot `GLTFDocument`
- Added engine-local docs for runtime import, live debugging, vendor
patches, and current VRM support boundaries.
- Removed temporary tests after using them to verify the glue behaviour
locally, to keep the review surface smaller.

## Vendor Code Note

A large part of this PR is vendored Godot add-on code, not AIRI business
logic.

The bulk of the added files under:

- `engines/stage-tamagotchi-godot/addons/vrm/**`
- `engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/**`

comes from V-Sekai Godot VRM / MToon add-ons. These files are required
because Godot plugins are project-local source/assets rather than
package-manager dependencies.

The intended review scope for vendor code is limited to:

- source baseline metadata
- license/plugin config
- Godot-generated metadata notes
- the documented local patch in `addons/vrm/vrm_extension.gd`

The application/runtime code to review is mainly under:

- `apps/stage-tamagotchi/src/shared/eventa/index.ts`
- `apps/stage-tamagotchi/src/renderer/pages/settings/models/`
- `apps/stage-tamagotchi/src/main/services/airi/godot-stage/`
- `engines/stage-tamagotchi-godot/scripts/`

## Current Boundary

This is still an experimental G1 Godot sidecar path.

The runtime scene input contract accepts `.vrm` files only. The current
Godot runtime importer covers the VRM 0.x path used by the local fixture
through AIRI’s runtime bridge over the vendored VRM extension. VRM 1.0
editor import support exists in the vendored add-on, but the sidecar
runtime importer does not yet register the full `VRMC_*` extension set,
so this PR does not claim full VRM 1.0 runtime support.

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Lilia_Chen
2026-05-15 14:37:32 +08:00
committed by GitHub
co-authored by autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
parent eada7e8c4e
commit bc7dda3d5f
119 changed files with 8518 additions and 465 deletions
@@ -1,310 +0,0 @@
import { EventEmitter } from 'node:events'
import { beforeEach, describe, expect, it, vi } from 'vitest'
interface TestWebSocketMessage {
text: () => string
}
interface TestWebSocketPeer {
close: ReturnType<typeof vi.fn>
id: string
request: {
url?: string
}
send: ReturnType<typeof vi.fn>
}
interface TestWebSocketHooks {
close?: (peer: TestWebSocketPeer) => void
message?: (peer: TestWebSocketPeer, message: TestWebSocketMessage) => void
open?: (peer: TestWebSocketPeer) => void
}
const appMock = vi.hoisted(() => ({
getPath: vi.fn((name: string) => `/tmp/airi/${name}`),
isPackaged: false,
}))
const serverState = vi.hoisted(() => ({
close: vi.fn(async () => {}),
serve: vi.fn(async () => {}),
webSocketHooks: undefined as TestWebSocketHooks | undefined,
}))
const spawnMock = vi.hoisted(() => vi.fn())
const logMock = vi.hoisted(() => {
const logger = {
debug: vi.fn(),
log: vi.fn(),
warn: vi.fn(),
withError: vi.fn(),
withFields: vi.fn(),
}
logger.withError.mockReturnValue(logger)
logger.withFields.mockReturnValue(logger)
return logger
})
vi.mock('electron', () => ({
app: appMock,
}))
vi.mock('node:child_process', () => ({
spawn: spawnMock,
}))
vi.mock('node:fs/promises', () => ({
access: vi.fn(async () => {}),
mkdir: vi.fn(async () => {}),
stat: vi.fn(async () => ({ isFile: () => true })),
writeFile: vi.fn(async () => {}),
}))
vi.mock('@guiiai/logg', () => ({
useLogg: () => ({
useGlobalConfig: () => logMock,
}),
}))
vi.mock('crossws/server', () => ({
plugin: vi.fn(() => ({})),
}))
vi.mock('get-port-please', () => ({
getRandomPort: vi.fn(async () => 48123),
}))
vi.mock('h3', () => ({
H3: class {
get = vi.fn()
},
defineWebSocketHandler: vi.fn((hooks: TestWebSocketHooks) => {
serverState.webSocketHooks = hooks
return hooks
}),
serve: vi.fn(() => ({
close: serverState.close,
serve: serverState.serve,
})),
}))
vi.mock('../../../libs/bootkit/lifecycle', () => ({
onAppBeforeQuit: vi.fn(),
}))
vi.mock('../../../libs/electron/location', () => ({
getElectronMainDirname: () => '/tmp/airi/out/main',
}))
function createFakeGodotProcess() {
const processHandle = new EventEmitter() as EventEmitter & {
kill: ReturnType<typeof vi.fn>
pid: number
stderr: EventEmitter
stdout: EventEmitter
}
processHandle.pid = 4321
processHandle.stdout = new EventEmitter()
processHandle.stderr = new EventEmitter()
processHandle.kill = vi.fn(() => {
queueMicrotask(() => processHandle.emit('close', null, 'SIGTERM'))
return true
})
return processHandle
}
function createTestPeer(url: string): TestWebSocketPeer {
return {
id: 'godot-test-peer',
request: { url },
send: vi.fn(),
close: vi.fn(),
}
}
function readSpawnedWebSocketUrl() {
const spawnArgs = spawnMock.mock.calls.at(-1)?.[1]
if (!Array.isArray(spawnArgs)) {
throw new TypeError('Expected Godot spawn arguments to be recorded.')
}
const websocketArgument = spawnArgs.find((arg): arg is string => (
typeof arg === 'string' && arg.startsWith('--airi-ws-url=')
))
if (!websocketArgument) {
throw new Error('Expected Godot spawn arguments to include --airi-ws-url.')
}
return websocketArgument.slice('--airi-ws-url='.length)
}
async function waitForSpawnedGodotProcess() {
await waitForSpawnedGodotProcessCount(1)
}
async function waitForSpawnedGodotProcessCount(expectedCount: number) {
for (let attempt = 0; attempt < 100; attempt++) {
if (spawnMock.mock.calls.length >= expectedCount) {
return
}
await Promise.resolve()
}
throw new Error('Expected Godot process to be spawned.')
}
async function startRunningGodotStage() {
const { createGodotStageManager } = await import('./index')
const manager = createGodotStageManager()
const startPromise = manager.start()
await waitForSpawnedGodotProcess()
const peer = createTestPeer(readSpawnedWebSocketUrl())
serverState.webSocketHooks?.open?.(peer)
serverState.webSocketHooks?.message?.(peer, {
text: () => JSON.stringify({ type: 'stage.ready' }),
})
await startPromise
return {
manager,
peer,
}
}
describe('createGodotStageManager lifecycle cleanup', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useRealTimers()
appMock.isPackaged = false
serverState.webSocketHooks = undefined
delete process.env.GODOT4
})
it('closes the websocket runtime when dev-mode Godot binary resolution fails', async () => {
// ROOT CAUSE:
//
// `start()` creates the websocket runtime before resolving the Godot binary.
// If `GODOT4` is missing, binary resolution throws and previously left the
// websocket server alive until the next start attempt or app quit.
const { createGodotStageManager } = await import('./index')
const manager = createGodotStageManager()
await expect(manager.start()).rejects.toThrow('GODOT4 is required')
expect(serverState.close).toHaveBeenCalledWith(true)
expect(manager.getStatus()).toMatchObject({
state: 'error',
pid: null,
lastError: expect.stringContaining('GODOT4 is required'),
})
})
it('kills the Godot process and closes the websocket runtime when startup readiness times out', async () => {
// ROOT CAUSE:
//
// If Godot starts but never sends `stage.ready`, `start()` rejects after the
// readiness timeout. The startup transaction must still release the process
// and websocket runtime created for that failed attempt.
vi.useFakeTimers()
process.env.GODOT4 = '/tmp/godot'
const processHandle = createFakeGodotProcess()
spawnMock.mockReturnValue(processHandle)
const { createGodotStageManager } = await import('./index')
const manager = createGodotStageManager()
const startPromise = manager.start()
const startExpectation = expect(startPromise).rejects.toThrow('Godot stage did not report ready in time.')
await vi.advanceTimersByTimeAsync(20_000)
await startExpectation
expect(processHandle.kill).toHaveBeenCalled()
expect(serverState.close).toHaveBeenCalledWith(true)
expect(manager.getStatus()).toMatchObject({
state: 'error',
pid: null,
lastError: expect.stringContaining('Godot stage did not report ready in time.'),
})
})
it('closes the websocket runtime when stop fails while force-killing Godot', async () => {
// ROOT CAUSE:
//
// `stop()` can enter the force-kill path after waiting for graceful shutdown.
// Cleanup must not depend on that branch completing successfully; the
// websocket runtime belongs to the stopping session and must be released.
vi.useFakeTimers()
process.env.GODOT4 = '/tmp/godot'
const processHandle = createFakeGodotProcess()
processHandle.kill.mockImplementation(() => {
throw new Error('kill failed')
})
spawnMock.mockReturnValue(processHandle)
const { manager } = await startRunningGodotStage()
const stopPromise = manager.stop()
const stopExpectation = expect(stopPromise).rejects.toThrow('kill failed')
await vi.advanceTimersByTimeAsync(2_000)
await stopExpectation
expect(serverState.close).toHaveBeenCalledWith(true)
expect(manager.getStatus()).toMatchObject({
state: 'error',
pid: processHandle.pid,
lastError: expect.stringContaining('kill failed'),
})
})
it('does not spawn a second process while a failed startup process is still shutting down', async () => {
// ROOT CAUSE:
//
// A timed-out startup kills the old Godot process, but only waits a bounded
// 2 seconds for its close event. A retry can start a new process before the
// old process emits close. The retry must not spawn another child process
// while the previous process is still tracked by the manager.
vi.useFakeTimers()
process.env.GODOT4 = '/tmp/godot'
const staleProcess = createFakeGodotProcess()
staleProcess.pid = 1001
staleProcess.kill.mockImplementation(() => true)
const unexpectedProcess = createFakeGodotProcess()
unexpectedProcess.pid = 1002
spawnMock.mockReturnValueOnce(staleProcess).mockReturnValueOnce(unexpectedProcess)
const { createGodotStageManager } = await import('./index')
const manager = createGodotStageManager()
const failedStartPromise = manager.start()
const failedStartExpectation = expect(failedStartPromise).rejects.toThrow('Godot stage did not report ready in time.')
await waitForSpawnedGodotProcess()
await vi.advanceTimersByTimeAsync(20_000)
await vi.advanceTimersByTimeAsync(2_000)
await failedStartExpectation
const retryStartPromise = manager.start()
const retryStartExpectation = expect(retryStartPromise).rejects.toThrow('Previous Godot stage process is still shutting down')
await vi.advanceTimersByTimeAsync(20_000)
await vi.advanceTimersByTimeAsync(2_000)
await retryStartExpectation
expect(spawnMock).toHaveBeenCalledTimes(1)
expect(manager.getStatus()).toMatchObject({
state: 'error',
pid: null,
lastError: expect.stringContaining('Previous Godot stage process is still shutting down'),
})
})
})
@@ -3,6 +3,7 @@ import type { Readable } from 'node:stream'
import type { createContext } from '@moeru/eventa/adapters/electron/main'
import type { BrowserWindow } from 'electron'
import type { InferOutput } from 'valibot'
import type {
ElectronGodotStageSceneInputPayload,
@@ -21,9 +22,11 @@ import { defineInvokeHandler } from '@moeru/eventa'
import { errorMessageFrom } from '@moeru/std'
import { Mutex } from 'async-mutex'
import { plugin as ws } from 'crossws/server'
import { safeDestr } from 'destr'
import { app } from 'electron'
import { getRandomPort } from 'get-port-please'
import { defineWebSocketHandler, H3, serve } from 'h3'
import { instance, literal, object, optional, safeParse, string, unknown as unknownSchema } from 'valibot'
import {
electronGodotStageApplySceneInput,
@@ -41,6 +44,8 @@ type GodotStagePeer = Parameters<NonNullable<GodotStageWebSocketHooks['open']>>[
type GodotStageMessage = Parameters<NonNullable<GodotStageWebSocketHooks['message']>>[1]
type GodotStageProcess = ChildProcessByStdio<null, Readable, Readable>
const DEFAULT_GODOT_REMOTE_DEBUG_URI = 'tcp://127.0.0.1:6007'
interface Deferred<T> {
promise: Promise<T>
reject: (error?: unknown) => void
@@ -54,16 +59,30 @@ interface GodotStageSocketRuntime {
}
interface GodotStageSceneApplyPayload {
format: string
format: 'vrm'
modelId: string
name: string
path: string
}
interface GodotStageSocketEnvelope {
payload?: unknown
type: string
}
const godotStageSceneInputPayloadSchema = object({
modelId: string(),
format: literal('vrm'),
name: string(),
fileName: string(),
data: instance(Uint8Array),
})
const godotStageSocketEnvelopeSchema = object({
payload: optional(unknownSchema()),
type: string(),
})
const godotStagePayloadMessageSchema = object({
message: string(),
})
type GodotStageSocketEnvelope = InferOutput<typeof godotStageSocketEnvelopeSchema>
/**
* Godot sidecar lifecycle controller owned by Electron main.
@@ -122,17 +141,59 @@ function normalizeFileName(fileName: string) {
}
function parseSocketMessage(message: GodotStageMessage): GodotStageSocketEnvelope {
const text = message.text()
return JSON.parse(text) as GodotStageSocketEnvelope
const parsed = safeDestr<unknown>(message.text(), { strict: true })
const result = safeParse(godotStageSocketEnvelopeSchema, parsed)
if (!result.success)
throw new Error('Invalid Godot stage WebSocket envelope.')
return result.output
}
function getPayloadMessage(payload: unknown) {
if (!payload || typeof payload !== 'object') {
const result = safeParse(godotStagePayloadMessageSchema, payload)
if (!result.success) {
return undefined
}
const message = (payload as Record<string, unknown>).message
return typeof message === 'string' ? message : undefined
return result.output.message
}
function parseSceneInputPayload(payload: unknown): ElectronGodotStageSceneInputPayload {
const result = safeParse(godotStageSceneInputPayloadSchema, payload)
if (!result.success)
throw new Error('Invalid Godot stage scene input payload.')
return result.output
}
/**
* Resolves Godot launch arguments used only for live editor debugging.
*
* Use when:
* - Electron main launches the Godot sidecar in development.
* - The Godot editor should inspect the sidecar runtime through Remote scene tree.
*
* Expects:
* - `GODOT_STAGE_REMOTE_DEBUG=1` enables the extra launch arguments.
* - `GODOT_STAGE_REMOTE_DEBUG_URI` optionally overrides Godot's default editor debug URI.
*
* Returns:
* - Engine arguments that must appear before Godot's `--` separator.
*/
function resolveGodotStageDebugLaunchOptions() {
const remoteDebugEnabled = ['1', 'true', 'yes', 'on'].includes(
(process.env.GODOT_STAGE_REMOTE_DEBUG ?? '').trim().toLowerCase(),
)
const remoteDebugUri = remoteDebugEnabled
? process.env.GODOT_STAGE_REMOTE_DEBUG_URI?.trim() || DEFAULT_GODOT_REMOTE_DEBUG_URI
: undefined
// Godot engine/debugger flags must stay before `--`; StageRoot arguments stay
// after it and are assembled next to the WebSocket URL.
return {
engineArgs: remoteDebugUri ? ['--remote-debug', remoteDebugUri] : [],
remoteDebugUri,
}
}
interface GodotBinaryResolution {
@@ -625,17 +686,23 @@ export function createGodotStageManager(): GodotStageManager {
let spawnArgs: string[]
let spawnCwd: string | undefined
const debugLaunchOptions = resolveGodotStageDebugLaunchOptions()
const sidecarArgs = [...debugLaunchOptions.engineArgs, '--', `--airi-ws-url=${websocketUrl}`]
if (godotBinary.mode === 'engine') {
const godotProjectPath = await resolveGodotProjectPath()
spawnArgs = ['--path', godotProjectPath, '--', `--airi-ws-url=${websocketUrl}`]
spawnArgs = ['--path', godotProjectPath, ...sidecarArgs]
spawnCwd = godotProjectPath
}
else {
spawnArgs = ['--', `--airi-ws-url=${websocketUrl}`]
spawnArgs = sidecarArgs
}
log.withFields({ executable: godotBinary.executable, mode: godotBinary.mode }).log('spawning Godot stage')
log.withFields({
executable: godotBinary.executable,
mode: godotBinary.mode,
remoteDebugUri: debugLaunchOptions.remoteDebugUri,
}).log('spawning Godot stage')
const processHandle = spawn(
godotBinary.executable,
@@ -744,17 +811,19 @@ export function createGodotStageManager(): GodotStageManager {
throw new Error('Godot stage is not running.')
}
const fileName = normalizeFileName(payload.fileName)
const modelDirectory = join(app.getPath('userData'), 'godot-stage', 'models', payload.modelId)
const sceneInputPayload = parseSceneInputPayload(payload)
const fileName = normalizeFileName(sceneInputPayload.fileName)
const modelDirectory = join(app.getPath('userData'), 'godot-stage', 'models', sceneInputPayload.modelId)
const materializedPath = join(modelDirectory, fileName)
await mkdir(modelDirectory, { recursive: true })
await writeFile(materializedPath, payload.data)
await writeFile(materializedPath, sceneInputPayload.data)
await sendSceneInputToGodot({
modelId: payload.modelId,
format: payload.format,
name: payload.name,
modelId: sceneInputPayload.modelId,
format: sceneInputPayload.format,
name: sceneInputPayload.name,
path: materializedPath,
})
})
@@ -0,0 +1,37 @@
import type { DisplayModel } from '@proj-airi/stage-ui/stores/display-models'
import { DisplayModelFormat } from '@proj-airi/stage-ui/stores/display-models'
/**
* Checks whether a display model can be sent to the Godot stage scene input path.
*
* Use when:
* - Settings needs to decide whether to materialize a selected display model for Godot
* - Godot stage mode needs to reject formats outside the G1.1 VRM baseline
*
* Expects:
* - The display model comes from the shared display model store
*
* Returns:
* - `true` only for VRM display models
*/
export function isGodotSceneInputSupportedDisplayModel(model: DisplayModel): boolean {
return model.format === DisplayModelFormat.VRM
}
/**
* Rejects display models that the Godot stage G1.1 scene input path cannot load.
*
* Use when:
* - A renderer side-effect is about to read model bytes and invoke Electron main
*
* Expects:
* - The display model has already been resolved from the selected model id
*
* Returns:
* - Nothing when the model is supported
*/
export function assertGodotSceneInputSupportedDisplayModel(model: DisplayModel): void {
if (!isGodotSceneInputSupportedDisplayModel(model))
throw new Error('Godot Stage currently supports VRM models only.')
}
@@ -22,6 +22,7 @@ import {
electronGodotStageStop,
} from '../../../../shared/eventa'
import { useModelSettingsRuntimeSnapshot } from '../../../composables/model-settings-runtime-snapshot'
import { assertGodotSceneInputSupportedDisplayModel } from './godot-scene-input'
const settingsStore = useSettings()
const { stageModelRenderer, stageModelSelectedDisplayModel } = storeToRefs(settingsStore)
@@ -110,9 +111,11 @@ async function readSceneInputData(model: DisplayModel) {
}
async function createSceneInputPayload(model: DisplayModel): Promise<ElectronGodotStageSceneInputPayload> {
assertGodotSceneInputSupportedDisplayModel(model)
return {
modelId: model.id,
format: model.format,
format: DisplayModelFormat.VRM,
name: model.name,
fileName: inferModelFileName(model),
data: await readSceneInputData(model),
@@ -328,7 +328,7 @@ export interface ElectronGodotStageStatus {
*/
export interface ElectronGodotStageSceneInputPayload {
modelId: string
format: string
format: 'vrm'
name: string
fileName: string
data: Uint8Array
@@ -60,8 +60,8 @@ Server `characters` 表([`apps/server/src/schemas/characters.ts`](../../../app
新建文件 `apps/server/src/schemas/user-characters.ts`
```ts
import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'
import type { AiriCard } from '@proj-airi/stage-ui/types/airi-card'
import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'
import { index, jsonb, pgTable, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core'
@@ -112,11 +112,11 @@ export type UserActiveCharacter = InferSelectModel<typeof userActiveCharacter>
`useAiriCardStore` 现有 `cards: Map<string, AiriCard>` + `activeCardId: string` 不变。新增 internal
```ts
type SyncOp = { kind: 'upsert' | 'delete', clientId: string }
interface SyncOp { kind: 'upsert' | 'delete', clientId: string }
interface SyncState {
status: 'offline' | 'unauthenticated' | 'syncing' | 'synced' | 'error'
pendingOps: Map<string, SyncOp> // by clientId, 最后一笔操作覆盖前面
pendingOps: Map<string, SyncOp> // by clientId, 最后一笔操作覆盖前面
lastSyncedAt: number | null
lastError: string | null
}
@@ -1,3 +1,7 @@
# Godot 4+ specific ignores
.godot/
/android/
# Developer-provided local VRM fixtures for editor preview scenes.
assets/fixtures/vrm/*
!assets/fixtures/vrm/.gitkeep
+96
View File
@@ -19,6 +19,8 @@ Godot-native desktop stage runtime project for `stage-tamagotchi`.
- Desktop-only Godot sidecar runtime exploration for `stage-tamagotchi`.
- Godot C# project structure and minimal runtime skeleton.
- Early-stage scene and runtime validation work.
- G1.1 VRM-only scene input baseline: Electron materializes the selected `.vrm`
file, sends its native file path, and Godot imports it at runtime.
## Directory Layout
@@ -82,6 +84,100 @@ Keep machine-specific Godot paths outside the repository. The current Electron
main service reads `process.env.GODOT4`, so the shell or local development
environment must provide it before starting `pnpm dev:tamagotchi`.
## Editor Static Preview
Use this path when working on camera, lighting, rendering, scene composition,
animation state-machine experiments, or other stage behavior that should not
depend on Electron or runtime VRM import.
Place a local `.vrm` file under:
```text
engines/stage-tamagotchi-godot/assets/fixtures/vrm/
```
This directory is ignored by git. Do not commit model files.
`EditorPreviewRoot` in `scenes/stage-root.tscn` is intentionally committed as an
empty node. In the Godot editor, instantiate a local model under that node when
you need a concrete avatar in the 3D viewport. Keep the local scene change and
the model file out of commits unless a repo-owned fixture policy is introduced.
Runtime startup hides `EditorPreviewRoot` automatically. Product runtime avatars
still belong under `AvatarRoot`, where `StageSceneController` applies models
received from Electron.
This preview does not test `VrmRuntimeImporter.gd`. Use the runtime import path
for importer and materialized-path bugs.
## VRM Runtime Import
G1.1 vendors V-Sekai Godot add-ons through `git-subrepo` metadata:
- `addons/vrm`: VRM importer add-on, plugin version `2.0.1`,
`only-addon` commit `651205484c35f5cd7ba56475ff636e10db8ad674`.
- `addons/Godot-MToon-Shader`: MToon shader add-on, plugin version `3.4.0`,
`main` commit `268c0d3b19c0885698b7bd39e21a16c9c2af448f`.
Runtime import is routed through `scripts/vrm/VrmRuntimeImporter.gd` because the
Godot editor import plugin is not active when the exported sidecar receives a
model path from Electron. The current runtime bridge covers the VRM 0.x path by
wrapping the vendored `addons/vrm/vrm_extension.gd`; it does not yet register the
vendored VRM 1.0 `addons/vrm/1.0/VRMC_*.gd` extension set.
Godot owns the active avatar node lifetime: a newly imported avatar is added
under `AvatarRoot` first, then the previous avatar is removed and queued for
freeing. Failed imports keep the previous avatar visible.
Runtime import details live in [`docs/vrm-runtime-import.md`](docs/vrm-runtime-import.md).
Vendored add-on local patches and generated metadata differences are tracked in
[`docs/vendor-patches.md`](docs/vendor-patches.md).
## Live Debugging From The Godot Editor
Use this path when Electron is the real host and the model is selected from the
Tamagotchi settings window, but the running Godot scene needs to be inspected in
the Godot editor. The detailed workflow lives in
[`docs/live-debugging.md`](docs/live-debugging.md).
Start the Godot editor against this project:
```powershell
& $env:GODOT4 -e --path .\engines\stage-tamagotchi-godot
```
In the Godot editor, enable:
```text
Debug -> Keep Debug Server Open
```
Then start the Electron development app with Godot remote debugging enabled:
```powershell
$env:GODOT_STAGE_REMOTE_DEBUG = "1"
$env:GODOT_STAGE_REMOTE_DEBUG_URI = "tcp://127.0.0.1:6007"
nr dev:tamagotchi
```
`GODOT_STAGE_REMOTE_DEBUG_URI` is optional and defaults to
`tcp://127.0.0.1:6007`, which is Godot's standard local editor debug endpoint.
When Tamagotchi starts the Godot stage, Electron launches the sidecar with
`--remote-debug` before Godot's `--` separator. The sidecar still receives
`--airi-ws-url` after the separator so it can connect back to Electron main.
After selecting a VRM model in the Tamagotchi settings window, inspect the
running scene in the Godot editor:
```text
Scene dock -> Remote -> /root/Node3D/AvatarRoot/Avatar_<modelId>
```
Do not use the editor's Run button for this integration path. The editor-run
process does not receive Electron's `--airi-ws-url`, so it cannot show the model
that Tamagotchi materialized and sent over the sidecar WebSocket.
## Exporting
Export presets produce the sidecar runtime that Electron packages for release:
@@ -0,0 +1,2 @@
# Normalize EOL for all files that Git considers text files.
* text=auto eol=lf
@@ -0,0 +1,18 @@
*.vrm.res
# Godot-specific ignores
.import/
.godot/
export.cfg
export_presets.cfg
# Imported translations (automatically generated from CSV files)
*.translation
# Mono-specific ignores
.mono/
data_*/
# System/tool-specific ignores
.DS_Store
*~
@@ -0,0 +1,12 @@
; DO NOT EDIT (unless you know what you are doing)
;
; This subdirectory is a git "subrepo", and this file is maintained by the
; git-subrepo command. See https://github.com/ingydotnet/git-subrepo#readme
;
[subrepo]
remote = https://github.com/V-Sekai/Godot-MToon-Shader
branch = main
commit = 268c0d3b19c0885698b7bd39e21a16c9c2af448f
parent = ea5d414bf9fd9001f5bce91a1e03a04c887c453c
method = merge
cmdver = 0.4.6
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020-2022 V-Sekai Contributors (see credits in README.md)
Copyright (c) 2018 Masataka SUMI
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,650 @@
@tool
extends EditorInspectorPlugin
const mtoon: Shader = preload("mtoon.gdshader")
const mtoon_cull_off: Shader = preload("mtoon_cull_off.gdshader")
const mtoon_trans: Shader = preload("mtoon_trans.gdshader")
const mtoon_trans_cull_off: Shader = preload("mtoon_trans_cull_off.gdshader")
const mtoon_trans_zwrite: Shader = preload("mtoon_trans_zwrite.gdshader")
const mtoon_trans_zwrite_cull_off: Shader = preload("mtoon_trans_zwrite_cull_off.gdshader")
const mtoon_outline: Shader = preload("mtoon_outline.gdshader")
func _can_handle(object: Object) -> bool:
if object != null and object is ShaderMaterial:
if object.shader != null and object.shader.resource_path.find("/mtoon") != -1 and object.shader.resource_path.find("/mtoon_outline") == -1:
return true
return false
var first_property: EditorProperty = null
var last_tex_property: String = ""
var property_name_to_editor: Dictionary = {}.duplicate()
#func _dump_tree(n: Node, ind: String="") -> void:
# print(ind + n.name)
# for chld in n.get_children():
# _dump_tree(chld, ind + " ")
const color_properties: Array = ["_Color", "_ShadeColor", "_RimColor", "_EmissionColor", "_OutlineColor", "_MatcapColor"]
const property_headers: Dictionary = {
"_Color": "Color",
"_ShadeToony": "Lighting",
"_ShadeShift": "Advanced Lighting Settings",
"_EmissionColor": "Emission",
"_RimColor": "Rim Light",
"_OutlineWidthMode": "Outline",
"_OutlineColorMode": "Outline Color",
"_MainTex_ST": "UV Coordinates",
"_UvAnimMaskTexture": "Auto Animation",
"_DebugMode": "Debugging Options",
}
const property_text: Dictionary = {
"_AlphaCutoutEnable": ["Rendering Type", "TransparentWithZWrite mode can cause problems with rendering."],
"_Color": ["Lit Color, Alpha", "Lit (RGB), Alpha (A)"],
"_ShadeColor": ["Shade Color", "Shade (RGB)"],
"_Cutoff": ["Alpha Cutoff", "Discard pixels below this value in Cutout mode"],
"_MatcapColor": ["MatCap Color", "Color multiplied with Additive Sphere map / MatCap Texture (RGB)"],
"_ShadeToony": ["Shading Toony", "0.0 is Lambert. Higher value get toony shading."],
"_BumpScale": ["Normal Map", "Normal Map and Multiplier for normals in tangent space"],
"_ShadeShift": ["Shading Shift", "Zero is Default. Negative value increase lit area. Positive value increase shade area."],
"_ReceiveShadowRate": ["Shadow Receive", "Texture (R) * Rate. White is Default. Black attenuates shadows."],
"_ShadingGradeRate": ["Shading Grade", "Lit & Shade Mixing Multiplier: Texture (R) * Rate. Compatible with UTS2 ShadingGradeMap. White is Default. Black amplifies shade."],
"_LightColorAttenuation": ["Light Color Atten", "Light Color Attenuation"],
"_IndirectLightIntensity": ["GI Intensity", "Indirect Light Intensity"],
"_EmissionColor": ["Emission", "Emission Color (RGB)"],
"_RimColor": ["Rim Color", "Rim Color (RGB)"],
"_RimLightingMix": ["Lighting Mix", "Rim Lighting Mix"],
"_RimFresnelPower": ["Fresnel Power", "If you increase this value, you get sharper rim light."],
"_RimLift": ["Rim Lift", "If you increase this value, you can lift rim light."],
"_OutlineWidthMode": ["Mode", "None = outline pass disabled; World = outline in world coordinates; Screen = screen pixel thickness"],
"_OutlineWidth": ["Width", "Outline Width"],
"_OutlineScaledMaxDistance": ["Outline Scaled Dist", "Width Scaled Max Distance"],
"_OutlineColorMode": ["Color Mode", "FixedColor = unshaded; MixedLighting = match environment light (recommended)"],
"_OutlineColor": ["Outline Color", "Outline Color (RGB)"],
"_OutlineLightingMix": ["Outline Mix", "Outline Lighting Mix"],
"_MainTex_ST": ["Offset", "UV Scale (X,Y), UV Offset (X,Y)"],
"_UvAnimMaskTexture": ["UV Anim Mask", "Auto Animation Mask Texture (R)", false],
"_UvAnimScrollX": ["UV Scroll X", "Scroll X (per second)"],
"_UvAnimScrollY": ["UV Scroll Y", "Scroll Y (per second)"],
"_UvAnimRotation": ["UV Rotation", "Rotation value (per second)"],
"_DebugMode": ["Visualize", "Debugging Visualization: Normal or Lighting"],
}
const single_line_properties = {
"_MainTex": "_Color",
"_ShadeTexture": "_ShadeColor",
"_BumpMap": "_BumpScale",
"_ReceiveShadowTexture": "_ReceiveShadowRate",
"_ShadingGradeTexture": "_ShadingGradeRate",
"_RimTexture": "_RimColor",
"_EmissionMap": "_EmissionColor",
"_OutlineWidthTexture": "_OutlineWidth",
"_SphereAdd": "_MatcapColor",
}
const single_line_after_properties = {
# "_SphereAdd": "_EmissionColor",
"_UvAnimMaskTexture": "_MainTex_ST",
}
const mins = {
"_ShadeShift": -1.0,
"_OutlineWidth": 0.01,
"_OutlineScaledMaxDistance": 1.0,
"_UvAnimScrollX": -100.0,
"_UvAnimScrollY": -100.0,
"_UvAnimRotation": -100.0,
}
const maxes = {
"_RimFresnelPower": 100.0,
"_OutlineScaledMaxDistance": 10.0,
"_UvAnimScrollX": 100.0,
"_UvAnimScrollY": 100.0,
"_UvAnimRotation": 100.0,
}
const steps = {
"_RimFresnelPower": -0.001,
"_BumpScale": 0.0,
"_UvAnimScrollX": 0.0,
"_UvAnimScrollY": 0.0,
"_UvAnimRotation": 0.0,
"_OutlineWidth": 0.0,
"_OutlineScaledMaxDistance": 0.0,
}
func merge_single_line_properties(label: String, outer_prop: Control, inner_prop: Control) -> void:
var parent_vbox: Control = outer_prop.get_parent()
parent_vbox.remove_child(inner_prop)
inner_prop.label = ""
outer_prop.label = label
var sub_picker: Control = outer_prop.get_child(outer_prop.get_child_count() - 1)
outer_prop.remove_child(sub_picker)
sub_picker.size_flags_horizontal = Control.SIZE_EXPAND_FILL
sub_picker.size_flags_vertical = 0
var new_hbox: HBoxContainer = HBoxContainer.new()
new_hbox.mouse_filter = Control.MOUSE_FILTER_IGNORE
new_hbox.add_child(sub_picker, true)
var new_control: Control = Control.new()
new_control.mouse_filter = Control.MOUSE_FILTER_IGNORE
new_control.size_flags_horizontal = Control.SIZE_EXPAND_FILL
new_hbox.add_child(new_control, true)
# Is it a bad idea to constrain the inspector like this?
new_hbox.custom_minimum_size = Vector2(155, 20)
outer_prop.custom_minimum_size = Vector2(190, 20)
outer_prop.add_child(inner_prop, true)
outer_prop.add_child(new_hbox, true)
# Copy texture modifications to next_pass material
func _texture_property_changed(texture_property: EditorProperty, object: ShaderMaterial, property_name: StringName, value: Variant) -> void:
if MToonProperty.has_outline_pass_static(object):
object.next_pass[texture_property.get_edited_property()] = value
func _process_tex_property(object: ShaderMaterial) -> void:
var prop = last_tex_property
var parent_vbox = first_property.get_parent()
var texture_property: EditorProperty = parent_vbox.get_child(parent_vbox.get_child_count() - 1)
texture_property.property_changed.connect(self._texture_property_changed.bind(texture_property, object))
if single_line_properties.has(prop):
var color_property: EditorProperty = property_name_to_editor.get(single_line_properties[prop])
if color_property != null:
merge_single_line_properties(color_property.label, color_property, texture_property)
elif single_line_after_properties.has(prop):
var new_parent: Node = property_name_to_editor.get(single_line_after_properties[prop])
if new_parent != null:
parent_vbox.remove_child(texture_property)
new_parent.add_sibling(texture_property)
texture_property.label = property_text.get(prop, ["texture_property.label", ""])[0]
property_name_to_editor[prop] = texture_property
func do_unfold_section(editor_inspector_section: Node) -> void:
editor_inspector_section.unfold()
#func parse_category(object_: Object, category: String) -> void:
# print("Category " + str(category))
func _parse_end(object_: Object) -> void:
var object: ShaderMaterial = object_
if not last_tex_property.is_empty():
_process_tex_property(object)
last_tex_property = ""
if first_property != null:
var parent_vbox: Control = first_property.get_parent()
do_unfold_section(parent_vbox.get_parent())
for prop in property_name_to_editor:
property_name_to_editor[prop].set_tooltip_text("shader_parameter/" + prop + "\n" + property_text.get(prop, ["", ""])[1])
for param in property_headers:
var property_editor: Control = property_name_to_editor.get(param)
if property_editor != null:
var scale_label: Label = Label.new()
scale_label.text = " "
var label: Label = Label.new()
label.text = property_headers[param]
var pos = parent_vbox.get_children().find(property_editor)
if param.ends_with("_ST"):
pos -= 1
var hbox_container: Container = HBoxContainer.new()
var label_container: Container = Container.new()
label_container.add_child(label, true)
hbox_container.add_child(label_container, true)
hbox_container.add_child(scale_label, true)
parent_vbox.add_child(hbox_container, true)
parent_vbox.move_child(hbox_container, pos)
label_container.size_flags_horizontal = Control.SIZE_FILL
label_container.size_flags_vertical = Control.SIZE_FILL
label.scale = Vector2(1.15, 1.05)
label.offset_left = -10
label.offset_top = 1
var c: Color = label.get_theme_color("font_color")
label.add_theme_color_override("font_color", Color(round(c.r), round(c.g), round(c.b), 1.0))
property_name_to_editor[label.text] = hbox_container
property_name_to_editor["_OutlineWidthMode"].hide_if_value = {
0:
[
property_name_to_editor["_OutlineColorMode"],
property_name_to_editor["_OutlineColor"],
property_name_to_editor["_OutlineLightingMix"],
property_name_to_editor["_OutlineWidth"],
property_name_to_editor["_OutlineScaledMaxDistance"],
property_name_to_editor["Outline Color"],
],
1:
[
property_name_to_editor["_OutlineScaledMaxDistance"],
],
2: [],
}
property_name_to_editor["_OutlineWidthMode"]._update_property()
property_name_to_editor["_AlphaCutoutEnable"].hide_if_value = {
0:
[
property_name_to_editor["_Cutoff"],
],
1: [],
}
property_name_to_editor["_AlphaCutoutEnable"]._update_property()
first_property = null
property_name_to_editor = {}.duplicate()
func is_a_shader_parameter(path: String) -> bool:
return path.begins_with("shader_parameter/")
func _parse_property(object_: Object, type, path: String, hint, hint_text: String, usage, wide: bool) -> bool:
var object: ShaderMaterial = object_
if not last_tex_property.is_empty():
_process_tex_property(object)
last_tex_property = ""
if path == "shader_parameter/_AlphaCutoutEnable":
for param in property_text:
if len(property_text[param]) == 3:
continue
var this_type: int = typeof(object.get_shader_parameter(param))
var property_editor: EditorProperty = null
var tooltip: String = property_text[param][1]
if param == "_AlphaCutoutEnable":
first_property = RenderingTypeInspector.new(tooltip)
property_editor = first_property
elif param == "_OutlineWidthMode":
property_editor = OutlineModeInspector.new(tooltip)
elif param == "_OutlineColorMode":
property_editor = OutlineColorModeInspector.new(tooltip)
elif param == "_DebugMode":
property_editor = DebugModeInspector.new(tooltip)
elif color_properties.has(param):
property_editor = LinearColorInspector.new(tooltip, param == "_Color")
elif param == "_MToonVersion":
return true
elif param.ends_with("_ST"):
var reserve: ReserveInspector = ReserveInspector.new(tooltip)
add_property_editor_for_multiple_properties("Scale", PackedStringArray(["nothing_to_see_here"]), reserve)
property_editor = ScaleOffsetInspector.new(tooltip, reserve)
else:
property_editor = SpinInspector.new(tooltip, mins.get(param, 0.0), maxes.get(param, 1.0), steps.get(param, 0.001))
property_editor.edited_object = object
property_name_to_editor[param] = property_editor
var path_arr = PackedStringArray(["shader_parameter/" + param])
add_property_editor_for_multiple_properties(property_text[param][0], path_arr, property_editor)
return true
elif is_a_shader_parameter(str(path)):
var param: String = str(path).split("/")[-1]
if type == TYPE_OBJECT and str(hint_text).find("Texture") != -1:
last_tex_property = param
return false
elif property_text.has(param):
return true
elif str(path) == "next_pass":
return true
return false
class MToonProperty:
extends EditorProperty
var edited_object: ShaderMaterial = null
func get_edited_object_hack() -> ShaderMaterial:
return edited_object
var updating: bool = false
var tooltip: String = ""
var hide_if_value: Dictionary = {}
# Tooltips do not seem to be functional for Godot properties
func _make_custom_tooltip(text: String) -> Object:
var label: Label = Label.new()
label.text = text + self.tooltip
label.custom_minimum_size = Vector2(200, 30)
return label
func get_tooltip_text() -> String:
if not tooltip.is_empty():
return tooltip
else:
return str(get_edited_property())
func has_outline_pass() -> bool:
return has_outline_pass_static(get_edited_object_hack())
static func has_outline_pass_static(edited_mat: Material) -> bool:
var next_pass: Material = edited_mat.next_pass
var shader_name: String = ""
if next_pass != null:
shader_name = next_pass.shader.resource_path.split("/")[-1]
return shader_name.find("mtoon_outline") != -1
func set_outline_prop(prop: String, val) -> void:
if has_outline_pass():
get_edited_object_hack().next_pass[prop] = val
update_hidden_props(val)
func update_hidden_props(val) -> void:
if hide_if_value.has(val):
for prop in hide_if_value[0]:
prop.visible = true
for prop in hide_if_value[val]:
prop.visible = false
func _setup_slider(slider: Range, name: String) -> void:
slider.label = name
slider.allow_lesser = true
slider.allow_greater = true
slider.step = 0.001
slider.rounded = false
slider.min_value = 0.0
slider.max_value = 1.0
slider.size_flags_horizontal = SIZE_EXPAND_FILL
slider.custom_minimum_size = Vector2(50.0, 20.0)
slider.value_changed.connect(self._value_changed)
func emit_changed(prop: StringName, val: Variant, field: StringName = &"", changing: bool = false) -> void:
get_edited_object_hack()[prop] = val
class RenderingTypeInspector:
extends MToonProperty
var dropdown: OptionButton = OptionButton.new()
var cull_off_checkbox: CheckBox = CheckBox.new()
var rendering_type_box: VBoxContainer = VBoxContainer.new()
func _init(tooltip: String) -> void:
self.tooltip = tooltip
add_child(rendering_type_box, true)
dropdown.add_item("Opaque")
dropdown.add_item("Cutout")
dropdown.add_item("Transparent")
dropdown.add_item("Trans ZWrite")
rendering_type_box.add_child(dropdown, true)
add_focusable(dropdown)
cull_off_checkbox.text = "Cull Disabled"
cull_off_checkbox.toggled.connect(self._cull_toggled)
rendering_type_box.add_child(cull_off_checkbox, true)
add_focusable(cull_off_checkbox)
dropdown.item_selected.connect(self._item_selected)
func _cull_toggled(value: bool) -> void:
if updating:
return
_update_shader(dropdown.selected, value)
func _item_selected(option_idx: int) -> void:
if updating:
return
_update_shader(option_idx, cull_off_checkbox.button_pressed)
func _update_shader(option_idx: int, cull_off: bool) -> void:
var shader_name: String = get_edited_object_hack().shader.resource_path.split("/")[-1]
match option_idx:
0: # Opaque
emit_changed(get_edited_property(), 0)
set_outline_prop(get_edited_property(), 0)
get_edited_object_hack().shader = mtoon_cull_off if cull_off else mtoon
1: # Cutout
emit_changed(get_edited_property(), 1)
set_outline_prop(get_edited_property(), 1)
get_edited_object_hack().shader = mtoon_cull_off if cull_off else mtoon
2: # Transparent
emit_changed(get_edited_property(), 0)
set_outline_prop(get_edited_property(), 0)
get_edited_object_hack().shader = mtoon_trans_cull_off if cull_off else mtoon_trans
3: # TransparentWithZWrite
emit_changed(get_edited_property(), 0)
set_outline_prop(get_edited_property(), 0)
get_edited_object_hack().shader = mtoon_trans_zwrite_cull_off if cull_off else mtoon_trans_zwrite
func _update_property() -> void:
var val: Variant = get_edited_object_hack()[get_edited_property()]
if typeof(val) == TYPE_NIL:
val = 0.0
updating = true
var shader_name = get_edited_object_hack().shader.resource_path.split("/")[-1]
var cull_off: bool = shader_name.find("_cull_off") != -1
if shader_name.find("mtoon_trans_zwrite") != -1:
val = 3
elif shader_name.find("mtoon_trans") != -1:
val = 2
update_hidden_props(1 if val == 1 else 0)
dropdown.selected = val
cull_off_checkbox.button_pressed = cull_off
updating = false
class OutlineModeInspector:
extends MToonProperty
var dropdown: OptionButton = OptionButton.new()
func _init(tooltip: String) -> void:
self.tooltip = tooltip
dropdown.add_item("None")
dropdown.add_item("WorldCoordinates")
dropdown.add_item("ScreenCoordinates")
add_child(dropdown, true)
add_focusable(dropdown)
dropdown.item_selected.connect(self._item_selected)
func _item_selected(option_idx: int) -> void:
if updating:
return
var next_pass: Material = get_edited_object_hack().next_pass
var has_outline: bool = has_outline_pass()
if option_idx == 0 and has_outline:
emit_changed("next_pass", get_edited_object_hack().next_pass.next_pass)
if option_idx != 0 and not has_outline:
next_pass = get_edited_object_hack().duplicate()
next_pass.shader = mtoon_outline
next_pass.next_pass = get_edited_object_hack().next_pass
emit_changed("next_pass", next_pass)
emit_changed(get_edited_property(), option_idx)
set_outline_prop(get_edited_property(), option_idx)
func _update_property() -> void:
var val: Variant = get_edited_object_hack()[get_edited_property()]
if typeof(val) == TYPE_NIL:
val = 0
if has_outline_pass() and val == 0:
val = 1
updating = true
dropdown.selected = val
update_hidden_props(val)
updating = false
class OutlineColorModeInspector:
extends MToonProperty
var dropdown: OptionButton = OptionButton.new()
func _init(tooltip: String) -> void:
self.tooltip = tooltip
dropdown.add_item("FixedColor")
dropdown.add_item("MixedLighting")
add_child(dropdown, true)
add_focusable(dropdown)
dropdown.item_selected.connect(self._item_selected)
func _item_selected(option_idx: int) -> void:
if updating:
return
emit_changed(get_edited_property(), option_idx)
set_outline_prop(get_edited_property(), option_idx)
func _update_property() -> void:
var val: Variant = get_edited_object_hack()[get_edited_property()]
if typeof(val) == TYPE_NIL:
val = 0
updating = true
dropdown.selected = val
updating = false
class DebugModeInspector:
extends MToonProperty
var dropdown: OptionButton = OptionButton.new()
func _init(tooltip: String) -> void:
self.tooltip = tooltip
dropdown.add_item("None")
dropdown.add_item("Normal")
dropdown.add_item("LitShadeRate")
add_child(dropdown, true)
add_focusable(dropdown)
dropdown.item_selected.connect(self._item_selected)
func _item_selected(option_idx: int) -> void:
if updating:
return
emit_changed(get_edited_property(), option_idx)
set_outline_prop(get_edited_property(), option_idx)
func _update_property() -> void:
var val: Variant = get_edited_object_hack()[get_edited_property()]
if typeof(val) == TYPE_NIL:
val = 0
updating = true
dropdown.selected = val
updating = false
class ReserveInspector:
extends MToonProperty
var hbox: HBoxContainer = HBoxContainer.new()
func _init(tooltip: String) -> void:
self.tooltip = tooltip
add_child(hbox, true)
func _update_property() -> void:
pass
class SpinInspector:
extends MToonProperty
var x_input: Range = EditorSpinSlider.new()
func _init(tooltip: String, minval: float, maxval: float, step: float) -> void:
self.tooltip = tooltip
_setup_slider(x_input, "")
x_input.min_value = minval
x_input.max_value = maxval
if step != 0.0:
x_input.step = abs(step)
x_input.allow_lesser = false
x_input.allow_greater = false
if step < 0:
x_input.exp_edit = true
add_child(x_input, true)
add_focusable(x_input)
func _value_changed(value: float) -> void:
emit_changed(get_edited_property(), x_input.value)
set_outline_prop(get_edited_property(), x_input.value)
func _update_property() -> void:
var this_value: Variant = get_edited_object_hack()[get_edited_property()]
if typeof(this_value) == TYPE_NIL:
const defaults = {
"_Cutoff": 0.5,
"_BumpScale": 1.0,
"_ReceiveShadowRate": 1.0,
"_ShadingGradeRate": 1.0,
"_ShadeToony": 0.9,
"_RimFresnelPower": 1.0,
"_OutlineWidth": 0.5,
"_OutlineScaledMaxDistance": 1.0,
"_IndirectLightIntensity": 0.1,
}
this_value = defaults.get(str(get_edited_property()).split("/")[-1], 0.0)
updating = true
x_input.value = this_value
updating = false
class ScaleOffsetInspector:
extends MToonProperty
var hbox: HBoxContainer = HBoxContainer.new()
var x_input: Range = EditorSpinSlider.new()
var y_input: Range = EditorSpinSlider.new()
var z_input: Range = EditorSpinSlider.new()
var w_input: Range = EditorSpinSlider.new()
func _init(tooltip: String, reserve: ReserveInspector) -> void:
self.tooltip = tooltip
var hbox_scale = reserve.hbox
_setup_slider(x_input, "x")
_setup_slider(y_input, "y")
hbox_scale.add_child(x_input, true)
reserve.add_focusable(x_input)
hbox_scale.add_child(y_input, true)
reserve.add_focusable(y_input)
add_child(hbox, true)
_setup_slider(z_input, "z")
_setup_slider(w_input, "w")
hbox.add_child(z_input, true)
add_focusable(z_input)
hbox.add_child(w_input, true)
add_focusable(w_input)
func _value_changed(value: float) -> void:
var new_val: Vector4 = Vector4(x_input.value, y_input.value, z_input.value, w_input.value)
emit_changed(get_edited_property(), new_val)
set_outline_prop(get_edited_property(), new_val)
func _update_property() -> void:
var st_value: Variant = get_edited_object_hack()[get_edited_property()]
if typeof(st_value) == TYPE_NIL:
st_value = Vector4(1, 1, 0, 0)
updating = true
x_input.value = st_value.x
y_input.value = st_value.y
z_input.value = st_value.z
w_input.value = st_value.w
updating = false
class LinearColorInspector:
extends MToonProperty
var color_picker: ColorPickerButton = ColorPickerButton.new()
var color_picker2: ColorPickerButton = ColorPickerButton.new()
var picker_box: HBoxContainer = HBoxContainer.new()
func _init(tooltip: String, allow_alpha: bool) -> void:
self.tooltip = tooltip
add_child(color_picker) # picker_box)
#picker_box.add_child(color_picker, true)
add_focusable(color_picker)
#picker_box.add_child(color_picker2, true)
#add_focusable(color_picker2)
color_picker.edit_alpha = allow_alpha
color_picker.custom_minimum_size = Vector2(40.0, 40.0)
#color_picker2.custom_minimum_size = Vector2(40.0, 40.0)
color_picker.color_changed.connect(self._color_changed)
func _color_changed(new_color: Color) -> void:
if updating:
return
var new_val: Color = Color(new_color.r, new_color.g, new_color.b, new_color.a)
emit_changed(get_edited_property(), new_val)
set_outline_prop(get_edited_property(), new_val)
func _update_property() -> void:
var linear_color: Variant = get_edited_object_hack()[get_edited_property()]
if typeof(linear_color) == TYPE_NIL:
const defaults = {
"_Color": Color(1.0, 1.0, 1.0, 1.0),
"_ShadeColor": Color(0.97, 0.81, 0.86, 1.0),
}
linear_color = defaults.get(str(get_edited_property()).split("/")[-1], Color(0, 0, 0, 1))
updating = true
color_picker.color = linear_color
updating = false
@@ -0,0 +1 @@
uid://cvupko56q73jf
@@ -0,0 +1,3 @@
shader_type spatial;
#include "./mtoon_common.gdshaderinc"
@@ -0,0 +1 @@
uid://bxvypa3u1tln0
@@ -0,0 +1,398 @@
render_mode skip_vertex_transform;
//render_mode specular_disabled,ambient_light_disabled;
// VARIANTS:
// DEFAULT_MODE:
#ifdef IS_OUTLINE
const float isOutline = 1.0;
#else
const float isOutline = 0.0;
#endif
// OUTLINE:
// // Comment `const float isOutline = 0.0;`
// render_mode cull_front;
// const float isOutline = 1.0;
// // Uncomment `ALPHA = alpha;` and comment `if (alpha < _Cutoff) { discard; }` at end of fragment()
// TRANSPARENT:
// // Uncomment `ALPHA = alpha;` and comment `if (alpha < _Cutoff) { discard; }` at end of fragment()
// TRANSPARENT_WITH_ZWRITE:
//render_mode depth_draw_always;
// // Uncomment `ALPHA = alpha;` and comment `if (alpha < _Cutoff) { discard; }` at end of fragment()
// CULL_OFF:
// render_mode cull_disabled;
// TRANSPARENT_CULL_OFF:
// render_mode cull_disabled;
// // Uncomment `ALPHA = alpha;` and comment `if (alpha < _Cutoff) { discard; }` at end of fragment()
// TRANSPARENT_WITH_ZWRITE_CULL_OFF:
// render_mode cull_disabled,depth_draw_always;
// // Uncomment `ALPHA = alpha;` and comment `if (alpha < _Cutoff) { discard; }` at end of fragment()
const bool CALCULATE_LIGHTING_IN_FRAGMENT = true;
uniform float _AlphaCutoutEnable : hint_range(0,1,1) = 0.0;
uniform float _Cutoff : hint_range(0,1) = 0.5;
uniform vec4 _Color : source_color = vec4(1.0,1.0,1.0,1.0); // "Lit Texture + Alpha"
uniform vec4 _ShadeColor : source_color = vec4(0.97, 0.81, 0.86, 1); // "Shade Color"
uniform sampler2D _MainTex : source_color, hint_default_white;
uniform vec4 _MainTex_ST = vec4(1.0,1.0,0.0,0.0);
uniform sampler2D _ShadeTexture : source_color, hint_default_white;
uniform float _BumpScale : hint_range(-16,16) = 1.0; // "Normal Scale"
uniform sampler2D _BumpMap : hint_normal; // "Normal Texture"
uniform sampler2D _ReceiveShadowTexture : hint_default_white;
uniform float _ReceiveShadowRate = 1.0; // "Receive Shadow"
uniform sampler2D _ShadingGradeTexture : hint_default_white;
uniform float _ShadingGradeRate = 1.0; // "Shading Grade"
uniform float _ShadeShift : hint_range(-1.0, 1.0) = 0.0;
uniform float _ShadeToony : hint_range(0.0, 1.0) = 0.9;
uniform float _LightColorAttenuation : hint_range(0.0, 1.0) = 0.0;
uniform float _IndirectLightIntensity : hint_range(0.0, 1.0) = 0.1;
uniform sampler2D _RimTexture : source_color, hint_default_white;
uniform vec4 _RimColor : source_color = vec4(0,0,0,1);
uniform float _RimLightingMix : hint_range(0.0, 1.0) = 0.0;
uniform float _RimFresnelPower : hint_range(0.0, 100.0) = 1.0;
uniform float _RimLift : hint_range(0.0, 1.0) = 0.0;
uniform vec4 _MatcapColor : source_color = vec4(0,0,0,1);
uniform sampler2D _SphereAdd : source_color, hint_default_black; // "Sphere Texture(Add)"
uniform vec4 _EmissionColor : source_color = vec4(0,0,0,1); // "Color"
uniform float _EmissionMultiplier = 1.0;
uniform sampler2D _EmissionMap : source_color, hint_default_white;
// Not implemented:
uniform float _OutlineWidthMode : hint_range(0,2,1) = 1;
uniform sampler2D _OutlineWidthTexture : hint_default_white;
uniform float _OutlineWidth : hint_range(0.01, 1.0) = 0.5;
uniform float _OutlineScaledMaxDistance : hint_range(1,10) = 1;
uniform float _OutlineColorMode : hint_range(0,1,1) = 0;
uniform vec4 _OutlineColor : source_color = vec4(0,0,0,1);
uniform float _OutlineLightingMix : hint_range(0,1) = 0;
uniform sampler2D _UvAnimMaskTexture : hint_default_white;
uniform float _UvAnimScrollX = 0;
uniform float _UvAnimRotation = 0;
uniform float _UvAnimScrollY = 0;
uniform float _DebugMode : hint_range(0,3,1) = 0.0;
uniform float _MToonVersion = 33;
// const
const float PI_2 = 6.283185307180;
const float EPS_COL = 0.00001;
varying vec3 tspace0; // : TEXCOORD1;
varying vec3 tspace1; // : TEXCOORD2;
varying vec3 tspace2; // : TEXCOORD3;
void vertex() {
UV=UV*_MainTex_ST.xy+_MainTex_ST.zw;
COLOR=COLOR;
if (isOutline == 1.0) {
float outlineTex = textureLod(_OutlineWidthTexture, UV, 0).r;
vec3 worldNormalLength = vec3(1.0/length(mat3(transpose(MODEL_MATRIX)) * NORMAL));
vec3 outlineOffset = 0.01 * _OutlineWidth * outlineTex * worldNormalLength * NORMAL;
vec3 conventional_outlined_vertex = (MODELVIEW_MATRIX * vec4(VERTEX + outlineOffset, 1.0)).xyz;
if (_OutlineWidthMode < 1.5) {
VERTEX = conventional_outlined_vertex;
} else { // #elif defined(MTOON_OUTLINE_WIDTH_SCREEN)
// 1. Create the clip-space position
// 2. Get the normal direction, normalized, in clip space.
// 3. Get the aspect ratio of the currently rendering camera.
// 4. Scale the normals by the distance to the camera
// 5. Add the normals to the clip-space XY position scaled by the width.
VERTEX = (MODELVIEW_MATRIX * vec4(VERTEX, 1.0)).xyz;
vec4 clipPos = PROJECTION_MATRIX * vec4(VERTEX, 1.0);
vec4 nearUpperRight = (INV_PROJECTION_MATRIX * vec4(1, 1, 0, 1));
float aspect = abs(nearUpperRight.y / nearUpperRight.x);
vec3 viewNormal = mat3(MODELVIEW_MATRIX) * NORMAL.xyz;
vec3 clipNormal = mat3(PROJECTION_MATRIX) * viewNormal.xyz;
vec2 projectedNormal = normalize(clipNormal.xy);
projectedNormal *= min(clipPos.w, _OutlineScaledMaxDistance);
projectedNormal.x *= aspect;
clipPos.xy += 0.01 * _OutlineWidth * outlineTex * projectedNormal.xy * clamp(1.0 - abs(normalize(viewNormal).z), 0.0, 1.0); // ignore offset when normal toward camera
VERTEX = vec3((INV_PROJECTION_MATRIX * clipPos).xy, conventional_outlined_vertex.z);
}
} else {
VERTEX = (MODELVIEW_MATRIX * vec4(VERTEX, 1.0)).xyz;
}
// posWorld = (MODELVIEW_MATRIX*vec4(VERTEX.xyz, 1.0));
vec3 worldNormal = mat3(MODELVIEW_MATRIX)*NORMAL;
vec3 worldTangent = mat3(MODELVIEW_MATRIX)*TANGENT;
vec3 worldBitangent = mat3(MODELVIEW_MATRIX)*BINORMAL;
tspace0 = vec3(worldTangent.x, worldBitangent.x, worldNormal.x);
tspace1 = vec3(worldTangent.y, worldBitangent.y, worldNormal.y);
tspace2 = vec3(worldTangent.z, worldBitangent.z, worldNormal.z);
}
vec3 UnpackScaleNormal(vec4 normalmap, float scale) {
normalmap.xy = scale * (normalmap.xy * 2.0 - 1.0);
normalmap.z = sqrt(max(0.0, 1.0 - dot(normalmap.xy, normalmap.xy))); //always ignore Z, as it can be RG packed, Z may be pos/neg, etc.
return normalmap.xyz;
}
vec3 calculateLighting(vec2 mainUv, float dotNL, float lightAttenuation, vec4 shade_arg, vec4 lit_arg, vec3 lightColor, out vec3 col, out float lightIntensity) {
// Decide albedo color rate from Direct Light
float shadingGrade = 1.0 - _ShadingGradeRate * (1.0 - texture(_ShadingGradeTexture, mainUv).r);
lightIntensity = dotNL; // [-1, +1]
lightIntensity = lightIntensity * 0.5 + 0.5; // from [-1, +1] to [0, 1]
lightIntensity = lightIntensity * lightAttenuation; // receive shadow
lightIntensity = lightIntensity * shadingGrade; // darker
lightIntensity = lightIntensity * 2.0 - 1.0; // from [0, 1] to [-1, +1]
// tooned. mapping from [minIntensityThreshold, maxIntensityThreshold] to [0, 1]
float maxIntensityThreshold = mix(1, _ShadeShift, _ShadeToony);
float minIntensityThreshold = _ShadeShift;
lightIntensity = clamp((lightIntensity - minIntensityThreshold) / max(EPS_COL, (maxIntensityThreshold - minIntensityThreshold)),0.0,1.0);
col = mix(shade_arg.rgb, lit_arg.rgb, lightIntensity);
// Direct Light
vec3 lighting = lightColor / 3.14159;
lighting = mix(lighting, max(vec3(EPS_COL), max(lighting.x, max(lighting.y, lighting.z))), _LightColorAttenuation); // color atten
return lighting;
}
vec3 calculateAddLighting(vec2 mainUv, float dotNL, float dotNV, float shadowAttenuation, vec3 lighting, vec3 col, out vec3 specularLight) {
// UNITY_LIGHT_ATTENUATION(shadowAttenuation, i, posWorld.xyz);
//#ifdef _ALPHABLEND_ON
// lighting *= step(0, dotNL); // darken if transparent. Because Unity's transparent material can't receive shadowAttenuation.
//#endif
float lightAtten = shadowAttenuation;
// Godot specific code to deal with clustering artifacts: We balance out the light attenuation to make it appear toony so we can hard-cut it when ATTENUATION == 0
float maxIntensityThreshold = mix(1, _ShadeShift, _ShadeToony);
float minIntensityThreshold = _ShadeShift;
lightAtten = clamp((shadowAttenuation - minIntensityThreshold) / max(EPS_COL, (maxIntensityThreshold - minIntensityThreshold)),0.0,1.0);
// End Godot Specific code
lighting *= 0.5; // darken if additional light.
lighting *= min(0.0, dotNL) + 1.0; // darken dotNL < 0 area by using float lambert
lighting *= lightAtten; // darken if receiving shadow
col *= lighting;
// parametric rim lighting
vec3 staticRimLighting = vec3(0.0);
vec3 mixedRimLighting = lighting;
vec3 rimLighting = mix(staticRimLighting, mixedRimLighting, _RimLightingMix);
vec3 rimuru = pow(clamp(1.0 - dotNV + _RimLift, 0.0, 1.0), _RimFresnelPower) * _RimColor.rgb * texture(_RimTexture, mainUv).rgb;
specularLight = mix(rimuru * rimLighting, vec3(0.0), isOutline);
return col;
}
vec4 GammaToLinearSpace (vec4 sRGB)
{
// Approximate version from http://chilliant.blogspot.com.au/2012/08/srgb-approximations-for-hlsl.html?m=1
return vec4(sRGB.rgb * (sRGB.rgb * (sRGB.rgb * 0.305306011 + 0.682171111) + 0.012522878), sRGB.a);
}
vec3 mix_normal(vec3 X, vec3 Y, float factor) {
float new_x = (1.0 - factor + factor * dot(X, Y));
return normalize(cross(cross(X, Y), X)) * sqrt(1.0 - new_x * new_x) + new_x * normalize(X);
}
varying vec4 lit;
varying vec4 shade;
varying vec3 rim;
varying vec3 fragment_albedo_output;
varying vec2 mainUv;
varying vec3 viewNormal;
void fragment() {
bool _NORMALMAP = textureSize(_BumpMap, 0).x > 8;
bool MTOON_OUTLINE_COLOR_FIXED = _OutlineColorMode == 0.0;
bool MTOON_OUTLINE_COLOR_MIXED = _OutlineColorMode == 1.0;
ROUGHNESS = 1.0; // for now
SPECULAR = 0.0; // for now
RIM = 1.0;
// uv
mainUv = UV; //TRANSFORM_TEX(i.uv0, _MainTex);
// uv anim
float uvAnim = texture(_UvAnimMaskTexture, mainUv).r * TIME;
// translate uv in bottom-left origin coordinates.
mainUv += vec2(_UvAnimScrollX, -_UvAnimScrollY) * uvAnim;
// rotate uv counter-clockwise around (0.5, 0.5) in bottom-left origin coordinates.
float rotateRad = _UvAnimRotation * PI_2 * uvAnim;
const vec2 rotatePivot = vec2(0.5, 0.5);
mainUv = mat2(vec2(cos(rotateRad), sin(rotateRad)), vec2(-sin(rotateRad), cos(rotateRad))) * (mainUv - rotatePivot) + rotatePivot;
// main tex
vec4 mainTex = texture(_MainTex, mainUv);
// alpha
float alpha = _Color.a * mainTex.a;
// Albedo color
shade = texture(_ShadeTexture, mainUv);
lit = mainTex;
vec3 emission = texture(_EmissionMap, mainUv).rgb * _EmissionColor.rgb * _EmissionMultiplier;
vec3 tangentNormal = vec3(0.0,0.0,1.0);
if (_NORMALMAP) {
tangentNormal = UnpackScaleNormal(texture(_BumpMap, mainUv), _BumpScale);
}
shade *= _ShadeColor;
lit *= _Color;
//shade = min(shade, lit); ///// Mimic look of non-PBR min() clamp we commented out below.
// normal
viewNormal = vec3(0.0);
if (_NORMALMAP) {
viewNormal.x = dot(tspace0, tangentNormal);
viewNormal.y = dot(tspace1, tangentNormal);
viewNormal.z = dot(tspace2, tangentNormal);
} else {
viewNormal = vec3(tspace0.z, tspace1.z, tspace2.z);
}
vec3 viewView = VIEW;
viewNormal *= step(0.0, dot(viewView, viewNormal)) * 2.0 - 1.0; // flip if projection matrix is flipped
viewNormal *= mix(+1.0, -1.0, isOutline);
viewNormal = normalize(viewNormal);
// Unity lighting
// Indirect Light
vec3 up_normal = mat3(VIEW_MATRIX) * vec3(0.0,1.0,0.0);
float LIGHT_COME_FROM_UP_RATIO = mix(0.8, 1.0, sin(_IndirectLightIntensity));
fragment_albedo_output = max(lit.rgb, vec3(0.0001));
rim = pow(clamp(1.0 - dot(viewNormal, viewView) + _RimLift, 0.0, 1.0), max(_RimFresnelPower, 0.001)) * _RimColor.rgb;
// additive matcap
vec3 viewCameraUp = vec3(0.0,1.0,0.0);//normalize(INV_VIEW_MATRIX[1].xyz); // FIXME!!
vec3 viewViewUp = normalize(viewCameraUp - viewView * dot(viewView, viewCameraUp));
vec3 viewViewRight = normalize(cross(viewView, viewViewUp));
vec2 matcapUv = vec2(-dot(viewViewRight, viewNormal), -dot(viewViewUp, viewNormal)) * 0.5 + 0.5;
vec3 matcapLighting = _MatcapColor.rgb * texture(_SphereAdd, matcapUv).rgb;
rim = (rim + matcapLighting) * texture(_RimTexture, mainUv).rgb;
emission += mix(rim * (1.0 - _RimLightingMix), vec3(0, 0, 0), isOutline);
fragment_albedo_output += mix(rim * _RimLightingMix, vec3(0, 0, 0), isOutline);
vec3 albedo = LIGHT_COME_FROM_UP_RATIO * fragment_albedo_output;
NORMAL = normalize(mix_normal(up_normal, viewNormal, _IndirectLightIntensity));
// Emission
emission = mix(emission, vec3(0, 0, 0), isOutline);
// outline
if (isOutline == 1.0) {
vec3 outlineColor = _OutlineColor.rgb;
if (MTOON_OUTLINE_COLOR_FIXED) {
albedo = vec3(0.0);
emission = outlineColor;
} else if (MTOON_OUTLINE_COLOR_MIXED) {
emission = outlineColor.rgb * (1.0 - _OutlineLightingMix);
// ALBEDO *= _OutlineLightingMix;
lit.rgb *= outlineColor.rgb * _OutlineLightingMix;
shade.rgb = lit.rgb;
fragment_albedo_output = lit.rgb;
albedo = LIGHT_COME_FROM_UP_RATIO * fragment_albedo_output;
}
}
// debug
if (_DebugMode >= 1.0) {
shade = vec4(0.0);
lit = vec4(0.0);
// ALBEDO = vec3(0.0);
emission = vec3(0.0);
albedo = vec3(0.0);
if (_DebugMode == 1.0) { //MTOON_DEBUG_NORMAL
emission = ((mat3(INV_VIEW_MATRIX) * viewNormal * vec3(1.0,1.0,-1.0)) * 0.5 + vec3(0.5));
} else if (_DebugMode == 2.0) { //MTOON_DEBUG_LITSHADERATE
albedo = vec3(1.0) * LIGHT_COME_FROM_UP_RATIO; // lightIntensity * lighting;
} else if (_DebugMode == 3.0) { // Add pass lighting
emission = vec3(0.0); //addLightIntensity;
}
}
//if (!LM_SCENEDATA_BOOL(lm_macro_system_enabled_)) {
// col.rgb = vec3(0.5 + 0.5 * sin(TIME +UV.x+UV.y),0.0,1.0);4
//}
fragment_albedo_output = max(fragment_albedo_output.rgb, vec3(0.0001));
ALBEDO = albedo;
EMISSION = emission;
ROUGHNESS = 1.0;
METALLIC = 0.0;
#if defined(ALPHA_BLEND)
ALPHA = alpha;
#elif defined(ALPHA_CUTOUT)
if (_AlphaCutoutEnable > 0.5 && alpha < _Cutoff) { discard; }
#endif
//METALLIC = metallic;
//ROUGHNESS = roughness;
//SPECULAR = specular;
}
float SchlickFresnel(float u) {
float m = 1.0 - u;
float m2 = m * m;
return m2 * m2 * m; // pow(m,5)
}
void light() {
bool isDirectional = true; // TODO: Generates clustered forward artifacts unless godotegnine/godot#48012 is applied
// isDirectional = (RIM == 1.0);
vec3 indirectLighting = vec3(0.0);
// TODO if isFirstLight:
//indirectLighting = DIFFUSE_LIGHT.rgb / fragment_albedo_output;
//indirectLighting = mix(indirectLighting, max(vec3(EPS_COL), max(indirectLighting.x, max(indirectLighting.y, indirectLighting.z))), _LightColorAttenuation); // color atten
// in light():
float addDotNL = dot(normalize(viewNormal), LIGHT);
vec3 lighting = vec3(0.0);
float lightIntensity = 0.0;
vec3 addLightIntensity = vec3(0.0);
vec3 diffuse_output = vec3(0.0);
if (LIGHT_IS_DIRECTIONAL) {
//UNITY_LIGHT_ATTENUATION(shadowAttenuation, i, posWorld.xyz);
float lightAttenuation = mix(1.0, length(vec3(ATTENUATION))/length(vec3(1.0)), _ReceiveShadowRate * texture(_ReceiveShadowTexture, mainUv).r);
vec3 col;
// shade
lighting = calculateLighting(mainUv, addDotNL, lightAttenuation, shade, lit, LIGHT_COLOR, col, lightIntensity);
// base light does not darken.
diffuse_output = col * lighting + indirectLighting * lit.rgb;
//col = min(col, lit.rgb); // comment out if you want to PBR absolutely.
// parametric rim lighting
vec3 staticRimLighting = vec3(0.0);
vec3 mixedRimLighting = lighting + indirectLighting;
vec3 rimLighting = mix(staticRimLighting, mixedRimLighting, _RimLightingMix);
SPECULAR_LIGHT += mix(rim * rimLighting, vec3(0, 0, 0), isOutline);
} else {
vec3 addCol = vec3(0.0);
float addTmp;
vec3 addLighting = calculateLighting(mainUv, addDotNL, 1.0, vec4(0.0), lit, LIGHT_COLOR, addCol, addTmp);
vec3 specLight;
// addLighting *= step(0, addDotNL); // darken if transparent. Because Unity's transparent material can't receive shadowAttenuation.
float attenuation_multiplier = pow(length(vec3(ATTENUATION))/length(vec3(1.0)), 0.1); // Godot specific code to prevent clustering artifacts.
diffuse_output = attenuation_multiplier * calculateAddLighting(mainUv, addDotNL, dot(viewNormal, VIEW), length(vec3(ATTENUATION))/length(vec3(1.0)), addLighting, addCol, specLight);
SPECULAR_LIGHT += attenuation_multiplier * specLight;
}
if (_DebugMode >= 1.0) {
if (_DebugMode == 2.0) { //MTOON_DEBUG_LITSHADERATE
diffuse_output = lightIntensity * lighting;
} else if (_DebugMode == 3.0) { // Add pass lighting
diffuse_output = addLightIntensity;
}
SPECULAR_LIGHT = vec3(0.0);
}
DIFFUSE_LIGHT += diffuse_output / fragment_albedo_output;
}
@@ -0,0 +1 @@
uid://r646bqak6bbl
@@ -0,0 +1,4 @@
shader_type spatial;
render_mode cull_disabled;
#include "./mtoon_common.gdshaderinc"
@@ -0,0 +1 @@
uid://nr34471gd4py
@@ -0,0 +1,5 @@
shader_type spatial;
#define ALPHA_CUTOUT
#include "./mtoon_common.gdshaderinc"
@@ -0,0 +1 @@
uid://hqxi3at3xsjk
@@ -0,0 +1,6 @@
shader_type spatial;
render_mode cull_disabled;
#define ALPHA_CUTOUT
#include "./mtoon_common.gdshaderinc"
@@ -0,0 +1,6 @@
shader_type spatial;
render_mode cull_front;
#define IS_OUTLINE
#include "./mtoon_common.gdshaderinc"
@@ -0,0 +1 @@
uid://bkcfsxvugy7wy
@@ -0,0 +1,7 @@
shader_type spatial;
render_mode cull_front;
#define IS_OUTLINE
#define ALPHA_CUTOUT
#include "./mtoon_common.gdshaderinc"
@@ -0,0 +1 @@
uid://cebyolqcmekmu
@@ -0,0 +1,7 @@
shader_type spatial;
render_mode cull_front;
#define IS_OUTLINE
#define ALPHA_BLEND
#include "./mtoon_common.gdshaderinc"
@@ -0,0 +1 @@
uid://cvvurrhpom6hl
@@ -0,0 +1,8 @@
shader_type spatial;
render_mode depth_draw_always;
render_mode cull_front;
#define IS_OUTLINE
#define ALPHA_BLEND
#include "./mtoon_common.gdshaderinc"
@@ -0,0 +1,4 @@
shader_type spatial;
#define ALPHA_BLEND
#include "./mtoon_common.gdshaderinc"
@@ -0,0 +1 @@
uid://c6us0qt7nt2t1
@@ -0,0 +1,5 @@
shader_type spatial;
render_mode cull_disabled;
#define ALPHA_BLEND
#include "./mtoon_common.gdshaderinc"
@@ -0,0 +1,5 @@
shader_type spatial;
render_mode depth_draw_always;
#define ALPHA_BLEND
#include "./mtoon_common.gdshaderinc"
@@ -0,0 +1 @@
uid://cffy3w65iybgf
@@ -0,0 +1,5 @@
shader_type spatial;
render_mode depth_draw_always, cull_disabled;
#define ALPHA_BLEND
#include "./mtoon_common.gdshaderinc"
@@ -0,0 +1,7 @@
[plugin]
name="MToon Shader"
description="MToon Shader Inspector for Godot 4.x"
author="V-Sekai"
version="3.4.0"
script="plugin.gd"
@@ -0,0 +1,15 @@
@tool
extends EditorPlugin
const inspector_plugin_class = preload("./inspector_mtoon.gd")
var inspector_plugin: Object = null
func _enter_tree() -> void:
inspector_plugin = inspector_plugin_class.new()
add_inspector_plugin(inspector_plugin)
func _exit_tree() -> void:
remove_inspector_plugin(inspector_plugin)
inspector_plugin = null
@@ -0,0 +1 @@
uid://ciukb8qi1l782
@@ -0,0 +1,12 @@
; DO NOT EDIT (unless you know what you are doing)
;
; This subdirectory is a git "subrepo", and this file is maintained by the
; git-subrepo command. See https://github.com/ingydotnet/git-subrepo#readme
;
[subrepo]
remote = https://github.com/V-Sekai/godot-vrm
branch = only-addon
commit = 651205484c35f5cd7ba56475ff636e10db8ad674
parent = 432057e6005d847ef1f6a429f8c9a9ab0885195d
method = merge
cmdver = 0.4.6
@@ -0,0 +1,45 @@
extends GLTFDocumentExtension
func _import_preflight(state: GLTFState, extensions = PackedStringArray()) -> Error:
if extensions.has("VRMC_materials_hdr_emissiveMultiplier") or extensions.has("KHR_materials_emissive_strength"):
return OK
return ERR_INVALID_DATA
# Called when the node enters the scene tree for the first time.
func _import_post(state, root):
var materials = state.materials
for i in range(materials.size()):
var material: Material = materials[i]
if material is BaseMaterial3D:
var json_material = state.json["materials"][i]
var extensions: Dictionary = json_material.get("extensions", {})
var vrmc_emissive: Dictionary = extensions.get("VRMC_materials_hdr_emissiveMultiplier", {})
var khr_emissive: Dictionary = extensions.get("KHR_materials_emissive_strength", {})
if khr_emissive.has("emissiveStrength"):
material.emission_energy_multiplier = khr_emissive["emissiveStrength"]
elif vrmc_emissive.has("emissiveMultiplier"):
material.emission_energy_multiplier = vrmc_emissive["emissiveMultiplier"]
func _export(state: GLTFState, extensions = PackedStringArray()) -> Error:
if extensions.has("VRMC_materials_hdr_emissiveMultiplier") or extensions.has("KHR_materials_emissive_strength"):
return OK
return ERR_INVALID_DATA
# Called when the node enters the scene tree for the first time.
func _export_post(state: GLTFState):
var materials = state.materials
for i in range(materials.size()):
var material: Material = materials[i]
if material is BaseMaterial3D:
var json_material: Dictionary = state.json["materials"][i]
if !is_equal_approx(material.emission_energy_multiplier, 1.0):
state.add_used_extension("KHR_materials_emissive_strength", false)
if "extensions" not in json_material:
json_material["extensions"] = {}
json_material["extensions"]["KHR_materials_emissive_strength"] = {
"emissiveStrength": material.emission_energy_multiplier,
}
@@ -0,0 +1 @@
uid://d0j7qhde6lke1
@@ -0,0 +1,521 @@
extends GLTFDocumentExtension
func _import_preflight(state: GLTFState, extensions = PackedStringArray()) -> Error:
if extensions.has("VRMC_materials_mtoon"):
return OK
return ERR_INVALID_DATA
func _prepare_gltf_texture(gltf_samplers: Array[GLTFTextureSampler], gltf_textures: Array[GLTFTexture], texdic: Dictionary, tex: Texture2D) -> int:
var gltf_sampler: GLTFTextureSampler = GLTFTextureSampler.new()
# FIXME: We do not currently have a way to set texture wrap / repeat settings for each shader, so we use defaults for now
var sampler_idx: int = len(gltf_samplers)
gltf_samplers.push_back(gltf_sampler)
var gltf_tex: GLTFTexture = GLTFTexture.new()
# Ok so this is is yucky and gross. There is no way to intercept between creation of Standard Materials
# and craetion of the images array, and also no way to alter the cached images array.
# So, all GLTFTexture objects point to 0. Then, we fill these in post, since some images may reference
# textures which were added internally, and we can't know their index until later.
gltf_tex.src_image = 0
#gltf_tex.src_image = len(gltf_images)
#gltf_images.push_back(tex)
gltf_tex.sampler = sampler_idx
var texture_idx: int = len(gltf_textures)
gltf_textures.push_back(gltf_tex)
texdic[texture_idx] = tex
return texture_idx
func _prepare_material_for_export(gltf_samp: Array[GLTFTextureSampler], gltf_tex: Array[GLTFTexture], texdic: Dictionary, standard_textures: Dictionary, mtoon_material: ShaderMaterial) -> StandardMaterial3D:
var shader_name = mtoon_material.shader.resource_path.get_file().get_basename()
var has_cutout = shader_name.find("_cutout") > 0
var has_trans = shader_name.find("_trans") > 0
var has_zwrite = shader_name.find("_zwrite") > 0
var has_cull_off = shader_name.find("_cull_off") > 0
var has_outline = false
if mtoon_material.next_pass != null and mtoon_material.next_pass.shader != null:
var outline_shader = mtoon_material.next_pass.shader.resource_path.get_file().get_basename()
has_outline = outline_shader.find("mtoon_outline") > 0
var standard_mat: StandardMaterial3D = StandardMaterial3D.new()
var col: Variant = mtoon_material.get_shader_parameter("_Color")
if typeof(col) == TYPE_VECTOR4:
col = Color(col.x, col.y, col.z, col.w)
if typeof(col) == TYPE_PLANE:
col = Color(col.x, col.y, col.z, col.d)
standard_mat.albedo_color = col
standard_mat.albedo_texture = mtoon_material.get_shader_parameter("_MainTex")
standard_textures[standard_mat.albedo_texture] = true
col = mtoon_material.get_shader_parameter("_EmissionColor")
if typeof(col) == TYPE_VECTOR4:
col = Color(col.x, col.y, col.z, col.w)
if typeof(col) == TYPE_PLANE:
col = Color(col.x, col.y, col.z, col.d)
if typeof(col) == TYPE_COLOR:
col.a = 1.0
standard_mat.emission_enabled = mtoon_material.get_shader_parameter("_EmissionMap") != null or !col.is_equal_approx(Color.BLACK)
standard_mat.emission_texture = mtoon_material.get_shader_parameter("_EmissionMap")
standard_mat.emission_energy_multiplier = mtoon_material.get_shader_parameter("_EmissionMultiplier")
standard_textures[standard_mat.emission_texture] = true
standard_mat.emission = col
standard_mat.normal_texture = mtoon_material.get_shader_parameter("_BumpMap")
standard_textures[standard_mat.normal_texture] = true
standard_mat.normal_enabled = mtoon_material.get_shader_parameter("_BumpMap") != null
standard_mat.normal_scale = mtoon_material.get_shader_parameter("_BumpScale")
if has_trans:
standard_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
elif has_cutout:
standard_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR
standard_mat.alpha_scissor_threshold = mtoon_material.get_shader_parameter("_Cutoff")
else:
standard_mat.transparency = BaseMaterial3D.TRANSPARENCY_DISABLED
var tex_repeat: Variant = mtoon_material.get_shader_parameter("_MainTex_ST")
if typeof(tex_repeat) == TYPE_PLANE:
standard_mat.uv1_scale = Vector3(tex_repeat.x, tex_repeat.y, 0)
standard_mat.uv1_offset = Vector3(tex_repeat.z, tex_repeat.d, 0)
elif typeof(tex_repeat) == TYPE_VECTOR4:
standard_mat.uv1_scale = Vector3(tex_repeat.x, tex_repeat.y, 0)
standard_mat.uv1_offset = Vector3(tex_repeat.z, tex_repeat.w, 0)
var additional_textures = {}
if mtoon_material.get_shader_parameter("_ShadeTexture") != null:
additional_textures["shadeMultiplyTexture"] = _prepare_gltf_texture(gltf_samp, gltf_tex, texdic, mtoon_material.get_shader_parameter("_ShadeTexture"))
if mtoon_material.get_shader_parameter("_ShadingGradeTexture") != null:
additional_textures["shadingShiftTexture"] = _prepare_gltf_texture(gltf_samp, gltf_tex, texdic, mtoon_material.get_shader_parameter("_ShadingGradeTexture"))
if mtoon_material.get_shader_parameter("_RimTexture") != null:
additional_textures["rimMultiplyTexture"] = _prepare_gltf_texture(gltf_samp, gltf_tex, texdic, mtoon_material.get_shader_parameter("_RimTexture"))
if mtoon_material.get_shader_parameter("_SphereAdd") != null:
additional_textures["matcapTexture"] = _prepare_gltf_texture(gltf_samp, gltf_tex, texdic, mtoon_material.get_shader_parameter("_SphereAdd"))
if mtoon_material.get_shader_parameter("_UvAnimMaskTexture") != null:
additional_textures["uvAnimationMaskTexture"] = _prepare_gltf_texture(gltf_samp, gltf_tex, texdic, mtoon_material.get_shader_parameter("_UvAnimMaskTexture"))
if mtoon_material.get_shader_parameter("_OutlineWidthTexture") != null:
additional_textures["outlineWidthMultiplyTexture"] = _prepare_gltf_texture(gltf_samp, gltf_tex, texdic, mtoon_material.get_shader_parameter("_OutlineWidthTexture"))
standard_mat.set_meta("mtoon_material", mtoon_material)
standard_mat.set_meta("additional_textures", additional_textures)
standard_mat.set_meta("has_zwrite", has_zwrite)
standard_mat.set_meta("has_cull_off", has_cull_off)
return standard_mat
func _export_preflight(state: GLTFState, root: Node) -> Error:
var materials: Dictionary = {}
var meshes = root.find_children("*", "ImporterMeshInstance3D")
var texdic: Dictionary = {}
var standard_textures: Dictionary = {}
var gltf_samp: Array[GLTFTextureSampler] = state.texture_samplers
var gltf_tex: Array[GLTFTexture] = state.textures
var uses_mtoon: bool = false
for meshx in meshes:
var mesh: ImporterMeshInstance3D = meshx
for m in range(mesh.mesh.get_surface_count()):
var mat: Material = mesh.mesh.get_surface_material(m)
if mat is ShaderMaterial:
if mat.shader != null and mat.shader.resource_path.get_file().begins_with("mtoon"):
uses_mtoon = true
if not materials.has(mat):
materials[mat] = _prepare_material_for_export(gltf_samp, gltf_tex, texdic, standard_textures, mat)
mesh.mesh.set_surface_material(m, materials[mat])
meshes = root.find_children("*", "MeshInstance3D")
for meshx in meshes:
var mesh: MeshInstance3D = meshx
if mesh.mesh == null:
continue
for m in range(mesh.mesh.get_surface_count()):
var mat: Material = mesh.get_surface_override_material(m)
if mat == null:
mat = mesh.mesh.surface_get_material(m)
if mat is ShaderMaterial:
if mat.shader != null and mat.shader.resource_path.get_file().begins_with("mtoon"):
uses_mtoon = true
if not materials.has(mat):
materials[mat] = _prepare_material_for_export(gltf_samp, gltf_tex, texdic, standard_textures, mat)
mesh.set_surface_override_material(m, materials[mat])
if uses_mtoon:
state.add_used_extension("VRMC_materials_mtoon", false)
state.texture_samplers = gltf_samp
state.textures = gltf_tex
var unique_images_to_add: Dictionary = {}
for tex in texdic.values():
if not standard_textures.has(tex):
unique_images_to_add[tex] = true
var gltf_images: Array[Texture2D] = state.images
for tex in unique_images_to_add:
gltf_images.push_back(tex)
state.images = gltf_images # Any textures not used by a StandardMaterial3D are our responsibility.
state.set_meta("texture_dictionary", texdic)
state.set_meta("shader_to_standard_material", materials)
return OK
func _to_gltf_color(c: Variant):
if typeof(c) == TYPE_VECTOR4:
return [c.x, c.y, c.z]
if typeof(c) == TYPE_PLANE:
return [c.x, c.y, c.z]
if typeof(c) == TYPE_NIL:
return [0, 0, 0]
return [c.r, c.g, c.b]
func _cm_to_m(m: float) -> float:
return m / 100.0
func _m_to_cm(cm: float) -> float:
return cm * 100.0
func _export_mtoon_texture(texture_index, vrm_mat_props, key):
if texture_index >= 0:
vrm_mat_props[key] = {"index": texture_index}
func _export_mtoon_properties(standard: StandardMaterial3D, mat_props: Dictionary, texture_to_index: Dictionary):
if "extensions" not in mat_props:
mat_props["extensions"] = {}
if not standard.has_meta("mtoon_material"):
# Not a toon material. Ignore
return
var new_mat: ShaderMaterial = standard.get_meta("mtoon_material")
var vrm_mat_props: Dictionary = {}
mat_props["extensions"]["VRMC_materials_mtoon"] = vrm_mat_props
vrm_mat_props["specVersion"] = "1.0"
if standard.get_meta("has_zwrite"):
vrm_mat_props["transparentWithZWrite"] = true
var outline_width: float = new_mat.get_shader_parameter("_OutlineWidth")
if new_mat.get_shader_parameter("_OutlineWidthMode") == 1:
vrm_mat_props["outlineWidthMode"] = "worldCoordinates"
outline_width = _cm_to_m(outline_width)
if new_mat.get_shader_parameter("_OutlineWidthMode") == 2:
vrm_mat_props["outlineWidthMode"] = "screenCoordinates"
if standard.get_meta("has_cull_off"):
mat_props["doubleSided"] = true
_export_mtoon_texture(texture_to_index.get(new_mat.get_shader_parameter("_ShadeTexture"), -1), vrm_mat_props, "shadeMultiplyTexture")
_export_mtoon_texture(texture_to_index.get(new_mat.get_shader_parameter("_RimTexture"), -1), vrm_mat_props, "rimMultiplyTexture")
_export_mtoon_texture(texture_to_index.get(new_mat.get_shader_parameter("_SphereAdd"), -1), vrm_mat_props, "matcapTexture")
_export_mtoon_texture(texture_to_index.get(new_mat.get_shader_parameter("_UvAnimMaskTexture"), -1), vrm_mat_props, "uvAnimationMaskTexture")
_export_mtoon_texture(texture_to_index.get(new_mat.get_shader_parameter("_OutlineWidthTexture"), -1), vrm_mat_props, "outlineWidthMultiplyTexture")
vrm_mat_props["shadeColorFactor"] = _to_gltf_color(new_mat.get_shader_parameter("_ShadeColor"))
vrm_mat_props["parametricRimColorFactor"] = _to_gltf_color(new_mat.get_shader_parameter("_RimColor"))
vrm_mat_props["matcapFactor"] = _to_gltf_color(new_mat.get_shader_parameter("_MatcapColor"))
vrm_mat_props["outlineColorFactor"] = _to_gltf_color(new_mat.get_shader_parameter("_OutlineColor"))
vrm_mat_props["shadingToonyFactor"] = new_mat.get_shader_parameter("_ShadeToony")
vrm_mat_props["shadingShiftFactor"] = new_mat.get_shader_parameter("_ShadeShift")
if vrm_mat_props.has("shadingShiftTexture"):
vrm_mat_props["shadingShiftTexture"]["scale"] = new_mat.get_shader_parameter("_ShadingGradeRate")
vrm_mat_props["giEqualizationFactor"] = 1.0 - new_mat.get_shader_parameter("_IndirectLightIntensity")
vrm_mat_props["rimLightingMixFactor"] = new_mat.get_shader_parameter("_RimLightingMix")
vrm_mat_props["parametricRimFresnelPowerFactor"] = new_mat.get_shader_parameter("_RimFresnelPower")
vrm_mat_props["parametricRimLiftFactor"] = new_mat.get_shader_parameter("_RimLift")
vrm_mat_props["outlineWidthFactor"] = outline_width
vrm_mat_props["outlineLightingMixFactor"] = new_mat.get_shader_parameter("_OutlineLightingMix")
vrm_mat_props["uvAnimationScrollXSpeedFactor"] = new_mat.get_shader_parameter("_UvAnimScrollX")
vrm_mat_props["uvAnimationScrollYSpeedFactor"] = new_mat.get_shader_parameter("_UvAnimScrollY")
vrm_mat_props["uvAnimationRotationSpeedFactor"] = new_mat.get_shader_parameter("_UvAnimRotation")
if mat_props.get("alphaMode", "OPAQUE") == "BLEND":
var delta_render_queue = new_mat.render_priority
if standard.get_meta("has_zwrite"):
# These numbers are set negative if zwrite, to comply with the spec
delta_render_queue += 19
delta_render_queue = clampi(delta_render_queue + 19, 0, 9)
else:
delta_render_queue = clampi(delta_render_queue, -9, 0)
# render_priority only makes sense for transparent materials.
vrm_mat_props["renderQueueOffsetNumber"] = delta_render_queue
func _export_post(state: GLTFState) -> Error:
var texdic = state.get_meta("texture_dictionary")
var texture_to_gltf_image_idx: Dictionary = {}
var gltf_image_idx_to_first_gltf_texture_idx: Dictionary = {}
var json = state.json
var gltf_images: Array[Texture2D] = state.images
var gltf_tex: Array[GLTFTexture] = state.textures
var gltf_samp: Array[GLTFTextureSampler] = state.texture_samplers
for i in range(len(gltf_images)):
texture_to_gltf_image_idx[gltf_images[i]] = i
var texture_to_index: Dictionary = {} # Texture to index in the textures array, not images array
if typeof(texdic) == TYPE_DICTIONARY:
for texture_idx in texdic:
texture_to_index[texdic[texture_idx]] = texture_idx
gltf_tex[texture_idx].src_image = texture_to_gltf_image_idx[texdic[texture_idx]]
json["textures"][texture_idx]["source"] = texture_to_gltf_image_idx[texdic[texture_idx]]
# Use the first matched index instead of the extra one we created.
for texture_idx in range(len(gltf_tex)):
if not gltf_image_idx_to_first_gltf_texture_idx.has(gltf_tex[texture_idx].src_image):
gltf_image_idx_to_first_gltf_texture_idx[gltf_tex[texture_idx].src_image] = texture_idx
if texdic.has(texture_idx):
texture_to_index[texdic[texture_idx]] = texture_idx
state.textures = gltf_tex
var gltf_materials: Array[Material] = state.materials
for i in range(len(gltf_materials)):
if gltf_materials[i] is StandardMaterial3D:
_export_mtoon_properties(gltf_materials[i], json["materials"][i], texture_to_index)
else:
print("Material index " + str(i) + " has incorrect type " + str(gltf_materials[i].get_class()))
return OK
func _vrm_get_texture_info(gstate: GLTFState, vrm_mat_props: Dictionary, unity_tex_name: String) -> Dictionary:
var gltf_images: Array = gstate.get_images()
var gltf_textures: Array = gstate.get_textures()
var texture_info: Dictionary = {}
texture_info["tex"] = null
texture_info["offset"] = Vector3(0.0, 0.0, 0.0)
texture_info["scale"] = Vector3(1.0, 1.0, 1.0)
if vrm_mat_props["textureProperties"].has(unity_tex_name):
var mainTexId: int = vrm_mat_props["textureProperties"][unity_tex_name]
var mainTexImageId = gltf_textures[mainTexId].src_image
var mainTexImage: Texture2D = gltf_images[mainTexImageId]
texture_info["tex"] = mainTexImage
if vrm_mat_props["vectorProperties"].has(unity_tex_name):
var offsetScale: Array = vrm_mat_props["vectorProperties"][unity_tex_name]
texture_info["offset"] = Vector3(offsetScale[0], offsetScale[1], 0.0)
texture_info["scale"] = Vector3(offsetScale[2], offsetScale[3], 1.0)
return texture_info
func _vrm_get_float(vrm_mat_props: Dictionary, key: String, def: float) -> float:
return vrm_mat_props["floatProperties"].get(key, def)
func _assign_property(new_mat: ShaderMaterial, property_name: String, property_value: Variant) -> void:
new_mat.set_shader_parameter(property_name, property_value)
if new_mat.next_pass != null:
new_mat.next_pass.set_shader_parameter(property_name, property_value)
func _assign_texture(new_mat: ShaderMaterial, gltf_images: Array[Texture2D], gltf_tex: Array[GLTFTexture], texture_name: String, texture_info: Dictionary) -> void:
# TODO: something with texCoord
# TODO: something with extensions[KHR_texture_transform].texCoord
# TODO: something with extensions[KHR_texture_transform].offset/scale?
var tex: Texture2D = null
if texture_info.has("index"):
tex = gltf_images[gltf_tex[texture_info["index"]].src_image]
_assign_property(new_mat, texture_name, tex)
func _assign_color(new_mat: ShaderMaterial, has_alpha: bool, property_name: String, color_array: Array) -> void:
var col: Color
if has_alpha:
col = Color(color_array[0], color_array[1], color_array[2], color_array[3])
else:
col = Color(color_array[0], color_array[1], color_array[2])
_assign_property(new_mat, property_name, col)
func _process_vrm_material(orig_mat: Material, gltf_images: Array[Texture2D], gltf_tex: Array[GLTFTexture], mat_props: Dictionary, vrm_mat_props: Dictionary) -> Material:
if vrm_mat_props.get("specVersion", "") != "1.0":
push_warning("Unsupported VRM MToon specVersion " + str(vrm_mat_props.get("specVersion", "")))
var blend_extension: String = ""
var alpha_mode: String = mat_props.get("alphaMode", "OPAQUE")
if alpha_mode == "MASK":
blend_extension = "_cutout"
if alpha_mode == "BLEND":
blend_extension = "_trans"
if vrm_mat_props.get("transparentWithZWrite", false) == true:
blend_extension += "_zwrite"
var outline_width_mode: String = vrm_mat_props.get("outlineWidthMode", "none")
var mtoon_shader_base_path: String = "res://addons/Godot-MToon-Shader/mtoon"
var godot_outline_shader_name: String = ""
if outline_width_mode != "none":
godot_outline_shader_name = mtoon_shader_base_path + "_outline" + blend_extension
var godot_shader_name = mtoon_shader_base_path + blend_extension
if mat_props.get("doubleSided", false) == true:
godot_shader_name += "_cull_off"
var godot_shader: Shader = ResourceLoader.load(godot_shader_name + ".gdshader")
var new_mat: ShaderMaterial = ShaderMaterial.new()
new_mat.resource_name = orig_mat.resource_name
new_mat.shader = godot_shader
var godot_shader_outline: Shader = null
if !godot_outline_shader_name.is_empty():
godot_shader_outline = ResourceLoader.load(godot_outline_shader_name + ".gdshader")
var outline_mat: ShaderMaterial = null
if godot_shader_outline != null:
outline_mat = ShaderMaterial.new()
outline_mat.resource_name = orig_mat.resource_name + "(Outline)"
outline_mat.shader = godot_shader_outline
new_mat.next_pass = outline_mat
var base_color_texture = mat_props.get("pbrMetallicRoughness", {}).get("baseColorTexture", {})
var khr_texture_transform = base_color_texture.get("extensions", {}).get("KHR_texture_transform", {})
var offset = khr_texture_transform.get("offset", [0.0, 0.0])
var scale = khr_texture_transform.get("scale", [1.0, 1.0])
# texCoord does not seem implemented in MToon.
# KHR_texture_transform also has its own texCoord.
# KHR_texture_transform is only supported by `baseColorTexture`
var texture_repeat = Vector4(scale[0], scale[1], offset[0], offset[1])
_assign_texture(new_mat, gltf_images, gltf_tex, "_MainTex", base_color_texture)
_assign_texture(new_mat, gltf_images, gltf_tex, "_ShadeTexture", vrm_mat_props.get("shadeMultiplyTexture", {}))
_assign_texture(new_mat, gltf_images, gltf_tex, "_ShadingGradeTexture", vrm_mat_props.get("shadingShiftTexture", {}))
_assign_texture(new_mat, gltf_images, gltf_tex, "_BumpMap", mat_props.get("normalTexture", {}))
_assign_texture(new_mat, gltf_images, gltf_tex, "_EmissionMap", mat_props.get("emissiveTexture", {}))
# TODO: implement emission factor?
# var vrmc_emissive: Dictionary = mat_props.get("extensions", {}).get("VRMC_materials_hdr_emissiveMultiplier", {})
# var khr_emissive: Dictionary = mat_props.get("extensions", {}).get("KHR_materials_emissive_strength", {})
var emission_mult = 1.0
var extensions: Dictionary = mat_props.get("extensions", {})
var vrmc_emissive: Dictionary = extensions.get("VRMC_materials_hdr_emissiveMultiplier", {})
var khr_emissive: Dictionary = extensions.get("KHR_materials_emissive_strength", {})
if khr_emissive.has("emissiveStrength"):
emission_mult = khr_emissive["emissiveStrength"]
elif vrmc_emissive.has("emissiveMultiplier"):
emission_mult = vrmc_emissive["emissiveMultiplier"]
new_mat.set_shader_parameter("_EmissionMultiplier", emission_mult)
_assign_texture(new_mat, gltf_images, gltf_tex, "_RimTexture", vrm_mat_props.get("rimMultiplyTexture", {}))
_assign_texture(new_mat, gltf_images, gltf_tex, "_SphereAdd", vrm_mat_props.get("matcapTexture", {}))
_assign_texture(new_mat, gltf_images, gltf_tex, "_UvAnimMaskTexture", vrm_mat_props.get("uvAnimationMaskTexture", {}))
_assign_texture(new_mat, gltf_images, gltf_tex, "_OutlineWidthTexture", vrm_mat_props.get("outlineWidthMultiplyTexture", {}))
_assign_color(new_mat, true, "_Color", mat_props.get("pbrMetallicRoughness", {}).get("baseColorFactor", [1, 1, 1, 1]))
_assign_color(new_mat, false, "_ShadeColor", vrm_mat_props.get("shadeColorFactor", [0, 0, 0]))
_assign_color(new_mat, false, "_RimColor", vrm_mat_props.get("parametricRimColorFactor", [0, 0, 0]))
# FIXME: _MatcapColor does not exist!!
_assign_color(new_mat, false, "_MatcapColor", vrm_mat_props.get("matcapFactor", [1, 1, 1]))
_assign_color(new_mat, false, "_OutlineColor", vrm_mat_props.get("outlineColorFactor", [0, 0, 0, 1]))
_assign_color(new_mat, false, "_EmissionColor", mat_props.get("emissiveFactor", [0, 0, 0]))
_assign_property(new_mat, "_MainTex_ST", texture_repeat)
var outline_width_idx: float = 0
var outline_width: float = vrm_mat_props.get("outlineWidthFactor", 0.0)
if outline_width_mode == "worldCoordinates":
outline_width_idx = 1
outline_width = _m_to_cm(outline_width)
elif outline_width_mode == "screenCoordinates":
outline_width_idx = 2
_assign_property(new_mat, "_OutlineWidthMode", outline_width_idx)
_assign_property(new_mat, "_OutlineWidth", outline_width)
#"_ReceiveShadowRate": ["Shadow Receive", "Texture (R) * Rate. White is Default. Black attenuates shadows."],
#"_LightColorAttenuation": ["Light Color Atten", "Light Color Attenuation"],
#"_IndirectLightIntensity": ["GI Intensity", "Indirect Light Intensity"],
#"_OutlineScaledMaxDistance": ["Outline Scaled Dist", "Width Scaled Max Distance"],
_assign_property(new_mat, "_AlphaCutoutEnable", 1.0 if alpha_mode == "MASK" else 0.0)
_assign_property(new_mat, "_BumpScale", mat_props.get("normalTexture", {}).get("scale", 1.0))
_assign_property(new_mat, "_Cutoff", mat_props.get("alphaCutoff", 0.5))
_assign_property(new_mat, "_ShadeToony", vrm_mat_props.get("shadingToonyFactor", 0.9))
_assign_property(new_mat, "_ShadeShift", vrm_mat_props.get("shadingShiftFactor", 0.0))
_assign_property(new_mat, "_ShadingGradeRate", vrm_mat_props.get("shadingShiftTexture", {}).get("scale", 1.0))
_assign_property(new_mat, "_ReceiveShadowRate", 1.0) # 0 disables directional light shadows. no longer supported?
_assign_property(new_mat, "_LightColorAttenuation", 0.0) # not useful
_assign_property(new_mat, "_IndirectLightIntensity", 1.0 - vrm_mat_props.get("giEqualizationFactor", 0.9))
_assign_property(new_mat, "_OutlineScaledMaxDistance", 99.0) # FIXME: different calulcation
_assign_property(new_mat, "_RimLightingMix", vrm_mat_props.get("rimLightingMixFactor", 0.0))
_assign_property(new_mat, "_RimFresnelPower", vrm_mat_props.get("parametricRimFresnelPowerFactor", 1.0))
_assign_property(new_mat, "_RimLift", vrm_mat_props.get("parametricRimLiftFactor", 0.0))
_assign_property(new_mat, "_OutlineColorMode", 1.0) # MixedLighting always. FixedColor if outlineLightingMixFactor==0
_assign_property(new_mat, "_OutlineLightingMix", vrm_mat_props.get("outlineLightingMixFactor", 1.0))
_assign_property(new_mat, "_UvAnimScrollX", vrm_mat_props.get("uvAnimationScrollXSpeedFactor", 0.0))
_assign_property(new_mat, "_UvAnimScrollY", vrm_mat_props.get("uvAnimationScrollYSpeedFactor", 0.0))
_assign_property(new_mat, "_UvAnimRotation", vrm_mat_props.get("uvAnimationRotationSpeedFactor", 0.0))
if alpha_mode == "BLEND":
var delta_render_queue = vrm_mat_props.get("renderQueueOffsetNumber", 0)
if vrm_mat_props.get("transparentWithZWrite", false) == true:
# renderQueueOffsetNumber range for this case is 0 to +9
# must be rendered before transparentWithZWrite==false
# transparentWithZWrite==false has renderQueueOffsetNumber between -9 and 0
# so we need these to be below that.
delta_render_queue -= 19
# render_priority only makes sense for transparent materials.
new_mat.render_priority = delta_render_queue
if outline_mat != null:
outline_mat.render_priority = delta_render_queue
else:
new_mat.render_priority = 0
if outline_mat != null:
outline_mat.render_priority = 0
return new_mat
# Called when the node enters the scene tree for the first time.
func _import_post(gstate, root):
var images: Array[Texture2D] = gstate.get_images()
var gltf_textures: Array[GLTFTexture] = gstate.get_textures()
#print(images)
var materials: Array[Material] = gstate.get_materials()
var materials_json: Array[Dictionary] = []
var materials_vrm_json: Array[Dictionary] = []
var spatial_to_shader_mat: Dictionary = {}
for i in range(materials.size()):
var material: Material = materials[i]
var json_material = gstate.json["materials"][i]
materials_json.push_back(json_material)
var extensions: Dictionary = json_material.get("extensions", {})
materials_vrm_json.push_back(extensions.get("VRMC_materials_mtoon", {}))
# Material conversions
for i in range(materials.size()):
var oldmat: Material = materials[i]
if oldmat is ShaderMaterial:
# Indicates that the user asked to keep existing materials. Avoid changing them.
# print("Material " + str(i) + ": " + str(oldmat.resource_name) + " already is shader.")
continue
var newmat: Material = oldmat
var mat_props: Dictionary = materials_json[i]
var vrm_mat_props: Dictionary = materials_vrm_json[i]
if not vrm_mat_props.has("specVersion"):
spatial_to_shader_mat[newmat] = newmat
continue
newmat = _process_vrm_material(newmat, images, gltf_textures, mat_props, vrm_mat_props)
spatial_to_shader_mat[oldmat] = newmat
spatial_to_shader_mat[newmat] = newmat
# print("Replacing shader " + str(oldmat) + "/" + str(oldmat.resource_name) + " with " + str(newmat) + "/" + str(newmat.resource_name))
materials[i] = newmat
var oldpath = oldmat.resource_path
if oldpath.is_empty():
continue
newmat.take_over_path(oldpath)
ResourceSaver.save(newmat, oldpath)
gstate.set_materials(materials)
var meshes = gstate.get_meshes()
for i in range(meshes.size()):
var gltfmesh: GLTFMesh = meshes[i]
var mesh = gltfmesh.mesh
mesh.set_blend_shape_mode(Mesh.BLEND_SHAPE_MODE_NORMALIZED)
for surf_idx in range(mesh.get_surface_count()):
var surfmat = mesh.get_surface_material(surf_idx)
if spatial_to_shader_mat.has(surfmat):
mesh.set_surface_material(surf_idx, spatial_to_shader_mat[surfmat])
else:
printerr("Mesh " + str(i) + " material " + str(surf_idx) + " name " + str(surfmat.resource_name) + " has no replacement material.")
# FIXME: due to head duplication, do we now have some meshes which are not in gltf state?
@@ -0,0 +1 @@
uid://bfewyytai2nvn
@@ -0,0 +1,139 @@
@tool
extends GLTFDocumentExtension
const bone_node_constraint = preload("../node_constraint/bone_node_constraint.gd")
const bone_node_constraint_applier = preload("../node_constraint/bone_node_constraint_applier.gd")
func _import_preflight(_state: GLTFState, extensions: PackedStringArray) -> Error:
if extensions.has("VRMC_node_constraint"):
return OK
return ERR_SKIP
func _parse_node_extensions(gltf_state: GLTFState, gltf_node: GLTFNode, node_extensions: Dictionary) -> Error:
if not node_extensions.has("VRMC_node_constraint"):
return OK
var constraint_ext: Dictionary = node_extensions["VRMC_node_constraint"]
var constraint: bone_node_constraint = bone_node_constraint.from_dictionary(constraint_ext)
gltf_node.set_additional_data(&"BoneNodeConstraint", constraint)
return OK
func _import_post_parse(gltf_state: GLTFState) -> Error:
var applier: bone_node_constraint_applier = bone_node_constraint_applier.new()
applier.name = &"BoneNodeConstraintApplier"
gltf_state.set_additional_data(&"BoneNodeConstraintApplier", applier)
return OK
func _import_post(gltf_state: GLTFState, root: Node) -> Error:
# Add the constraint applier to the real root, next to the AnimationPlayer.
var applier: bone_node_constraint_applier = gltf_state.get_additional_data(&"BoneNodeConstraintApplier")
root.add_child(applier)
applier.owner = root
# Set up the constraints.
var nodes: Array = gltf_state.nodes
var json_nodes: Array = gltf_state.json["nodes"]
for i in range(len(nodes)):
var err: Error = my_import_node(gltf_state, nodes[i], json_nodes[i], gltf_state.get_scene_node(i))
if err != OK:
return err
return OK
func my_import_node(gltf_state: GLTFState, gltf_node: GLTFNode, json: Dictionary, node: Node) -> Error:
var constraint: bone_node_constraint = gltf_node.get_additional_data(&"BoneNodeConstraint")
if not constraint:
return OK
var gltf_nodes: Array[GLTFNode] = gltf_state.nodes
var gltf_skeletons: Array[GLTFSkeleton] = gltf_state.skeletons
constraint.resource_name = str(gltf_node.resource_name) + " from " + str(gltf_nodes[constraint.source_node_index].resource_name)
# Set up the source node.
constraint.source_node = gltf_state.get_scene_node(constraint.source_node_index)
constraint.source_rest_transform = constraint.source_node.transform
if gltf_nodes[constraint.source_node_index].skeleton != -1:
var godot_skel: Skeleton3D = gltf_skeletons[gltf_nodes[constraint.source_node_index].skeleton].get_godot_skeleton()
var source_bone_name: String = gltf_nodes[constraint.source_node_index].resource_name
constraint.source_bone_name = source_bone_name
constraint.source_node = godot_skel
# Edge case: Even though we have been given the Skeleton by Godot, and
# this is almost certainly a bone, it could be the Skeleton node itself.
var source_bone_index = godot_skel.find_bone(constraint.target_bone_name)
if source_bone_index != -1:
constraint.source_rest_transform = godot_skel.get_bone_rest(source_bone_index)
# Set up the target node. NOTE: It seems similar to the source node code,
# however there are a ton of subtle differences, so it should be duplicated.
constraint.target_node = node
constraint.target_rest_transform = node.transform
if gltf_node.skeleton != -1:
var godot_skel: Skeleton3D = gltf_skeletons[gltf_node.skeleton].get_godot_skeleton()
constraint.target_bone_name = gltf_node.resource_name
constraint.target_node = godot_skel
# Edge case: Even though we have been given the Skeleton by Godot, and
# this is almost certainly a bone, it could be the Skeleton node itself.
var target_bone_index = godot_skel.find_bone(constraint.target_bone_name)
if target_bone_index != -1:
constraint.target_rest_transform = godot_skel.get_bone_rest(target_bone_index)
# Set node paths relative to the applier and save to the applier.
var applier: bone_node_constraint_applier = gltf_state.get_additional_data(&"BoneNodeConstraintApplier")
applier.constraints.append(constraint)
constraint.set_node_paths_from_references(applier)
return OK
# Export process.
func _export_preflight(gltf_state: GLTFState, root: Node) -> Error:
var applier: bone_node_constraint_applier
for scene_node in root.find_children("*", "Node", true, true):
applier = scene_node as bone_node_constraint_applier
if applier != null:
break
if applier != null:
gltf_state.set_additional_data(&"BoneNodeConstraintApplier", applier)
gltf_state.set_additional_data(&"BoneNodeConstraintApplier.parent", applier.get_parent())
applier.get_parent().remove_child(applier)
gltf_state.add_used_extension("VRMC_node_constraint", false)
for constraint in applier.constraints:
constraint.set_node_references_from_paths(applier)
return OK
return ERR_SKIP
func _export_post(gltf_state: GLTFState):
var applier: bone_node_constraint_applier = gltf_state.get_additional_data(&"BoneNodeConstraintApplier")
if applier == null:
return OK
gltf_state.get_additional_data(&"BoneNodeConstraintApplier.parent").add_child(applier)
var node_to_index: Dictionary
for i in range(gltf_state.get_nodes().size()):
var scene_node: Node = gltf_state.get_scene_node(i)
node_to_index[scene_node] = i
var skeletons: Array[GLTFSkeleton] = gltf_state.skeletons
var applier_skel = applier.get_node_or_null(applier.skeleton)
for constraint in applier.constraints:
if not constraint:
return ERR_INVALID_DATA
# TODO: Use get_node_index() once we stop supporting 4.0.x.
# See https://github.com/godotengine/godot/pull/77534
if constraint.source_bone_name != "":
for gltf_skel in skeletons:
if gltf_skel.get_godot_skeleton() == applier_skel:
constraint.source_node_index = gltf_skel.godot_bone_node[applier_skel.find_bone(constraint.source_bone_name)]
else:
constraint.source_node_index = node_to_index[applier.get_node(constraint.source_node_path)]
var target_node_index: int = -1
if constraint.target_bone_name != "":
for gltf_skel in skeletons:
if gltf_skel.get_godot_skeleton() == applier_skel:
target_node_index = gltf_skel.godot_bone_node[applier_skel.find_bone(constraint.target_bone_name)]
else:
target_node_index = node_to_index[applier.get_node(constraint.target_node_path)]
var json_nodes: Array = gltf_state.json["nodes"]
var json: Dictionary = json_nodes[target_node_index]
if not json.has("extensions"):
json["extensions"] = {}
var extensions: Dictionary = json["extensions"]
extensions["VRMC_node_constraint"] = constraint.to_dictionary()
return OK
@@ -0,0 +1 @@
uid://btarour0um0f1
@@ -0,0 +1,436 @@
extends GLTFDocumentExtension
const vrm_constants_class = preload("../vrm_constants.gd")
const vrm_meta_class = preload("../vrm_meta.gd")
const vrm_secondary = preload("../vrm_secondary.gd")
const vrm_top_level = preload("../vrm_toplevel.gd")
const vrm_spring_bone = preload("../vrm_spring_bone.gd")
const vrm_collider_group = preload("../vrm_collider_group.gd")
const vrm_collider = preload("../vrm_collider.gd")
func _get_skel_godot_node(gstate: GLTFState, nodes: Array, _skeletons: Array, skel_id: int) -> Node:
# There's no working direct way to convert from skeleton_id to node_id.
# Bugs:
# GLTFNode.parent is -1 if skeleton bone.
# skeleton_to_node is empty
# get_scene_node(skeleton bone) works though might maybe return an attachment.
# var skel_node_idx = nodes[gltfskel.roots[0]]
# return gstate.get_scene_node(skel_node_idx) # as Skeleton
for i in range(nodes.size()):
if nodes[i].skeleton == skel_id:
return gstate.get_scene_node(i)
return null
func _adjust_magnitude(pfa: PackedFloat64Array, scale: float):
if len(pfa) > 0:
if pfa.count(pfa[0]) == len(pfa):
pfa.clear()
if not is_zero_approx(scale):
for i in range(len(pfa)):
pfa[i] = pfa[i] / scale
func _parse_secondary_node(secondary_node: Node, vrm_extension: Dictionary, gstate: GLTFState) -> void:
var nodes = gstate.get_nodes()
var skeletons = gstate.get_skeletons()
var skeleton: Skeleton3D = null
if secondary_node.owner == null:
skeleton = secondary_node.get_parent().get_node("%GeneralSkeleton")
else:
skeleton = secondary_node.owner.get_node("%GeneralSkeleton")
var colliders: Array[vrm_collider] = []
var collider_groups: Array[vrm_collider_group] = []
for collider_gltf in vrm_extension.get("colliders", []):
var gltfnode: GLTFNode = nodes[int(collider_gltf["node"])]
var collider = vrm_collider.new()
var pose_diff: Basis = Basis()
if gltfnode.skeleton == -1 or skeleton == null:
var found_node: Node = gstate.get_scene_node(int(collider_gltf["node"]))
collider.node_path = secondary_node.get_path_to(found_node)
collider.bone = ""
collider.resource_name = found_node.name
else:
if skeleton != _get_skel_godot_node(gstate, nodes, skeletons, gltfnode.skeleton):
push_error("VRM1: collider points to differnt skeleton")
collider.bone = nodes[int(collider_gltf["node"])].resource_name
collider.resource_name = collider.bone
if skeleton.has_meta("vrm_pose_diffs"):
# array by bone idx.
var bone_idx: int = skeleton.find_bone(collider.bone)
if bone_idx == -1:
push_error("Unrecognized bone " + str(bone_idx) + " used by springBone")
pose_diff = skeleton.get_meta("vrm_pose_diffs")[bone_idx]
#print(str(collider.bone) + " diff " + str(pose_diff))
if collider_gltf.has("name"):
collider.resource_name = collider_gltf["name"]
var collider_shape = collider_gltf["shape"]
var is_capsule = false
var radius: float
var offset_gltf: Array = [0.0, 0.0, 0.0]
var tail_gltf: Array = [0.0, 0.0, 0.0]
if collider_shape.has("sphere"):
radius = collider_shape["sphere"]["radius"]
offset_gltf = collider_shape["sphere"]["offset"]
tail_gltf = offset_gltf
if collider_shape.has("capsule"):
is_capsule = true
radius = collider_shape["capsule"]["radius"]
offset_gltf = collider_shape["capsule"]["offset"]
tail_gltf = collider_shape["capsule"]["tail"]
var offset: Vector3 = Vector3(offset_gltf[0], offset_gltf[1], offset_gltf[2])
var tail: Vector3 = Vector3(tail_gltf[0], tail_gltf[1], tail_gltf[2])
var local_pos: Vector3 = pose_diff * offset
var local_tail_pos: Vector3 = pose_diff * tail
collider.offset = local_pos
collider.tail = local_tail_pos
collider.radius = radius
collider.is_capsule = is_capsule
colliders.append(collider)
for cgroup in vrm_extension.get("colliderGroups", []):
var collider_group: vrm_collider_group = vrm_collider_group.new()
collider_group.colliders.clear()
for collider_node in cgroup["colliders"]:
if collider_node < len(colliders):
collider_group.colliders.append(colliders[int(collider_node)])
collider_groups.append(collider_group)
var spring_bones: Array[vrm_spring_bone] = []
for sbone in vrm_extension.get("springs", []):
if sbone.get("joints", []).size() == 0:
continue
var first_joint: Dictionary = sbone["joints"][0]
var first_bone_node: int = int(first_joint["node"])
var gltfnode: GLTFNode = nodes[int(first_bone_node)]
if skeleton != _get_skel_godot_node(gstate, nodes, skeletons, gltfnode.skeleton):
push_error("VRM1: spring joint points to differnt skeleton")
var spring_bone: vrm_spring_bone = vrm_spring_bone.new()
spring_bone.comment = sbone.get("name", "")
spring_bone.hit_radius_scale = 0
spring_bone.stiffness_scale = 0
spring_bone.gravity_scale = 0
spring_bone.drag_force_scale = 0
for sjoint in sbone["joints"]:
spring_bone.hit_radius.append(float(sjoint.get("hitRadius", 0.0)))
spring_bone.hit_radius_scale = max(spring_bone.hit_radius_scale, spring_bone.hit_radius[-1])
spring_bone.stiffness_force.append(float(sjoint.get("stiffiness", 1.0)))
spring_bone.stiffness_scale = max(spring_bone.stiffness_scale, spring_bone.stiffness_force[-1])
spring_bone.gravity_power.append(float(sjoint.get("gravityPower", 0.0)))
spring_bone.gravity_scale = max(spring_bone.gravity_scale, spring_bone.gravity_power[-1])
var gravity_dir = sjoint.get("gravityDir", [0.0, -1.0, 0.0])
spring_bone.gravity_dir.append(Vector3(gravity_dir[0], gravity_dir[1], gravity_dir[2]))
spring_bone.drag_force.append(float(sjoint.get("dragForce", 0.5)))
spring_bone.drag_force_scale = max(spring_bone.drag_force_scale, spring_bone.drag_force[-1])
var bone_node: int = sjoint["node"]
var bone_name: String = nodes[int(bone_node)].resource_name
if skeleton == null or skeleton.find_bone(bone_name) == -1:
# Note that we make an assumption that a given SpringBone object is
# only part of a single Skeleton*. This error might print if a given
# SpringBone references bones from multiple Skeleton's.
printerr("Failed to find node " + str(bone_node) + " in skel " + str(skeleton))
else:
spring_bone.joint_nodes.append(bone_name)
_adjust_magnitude(spring_bone.hit_radius, spring_bone.hit_radius_scale)
_adjust_magnitude(spring_bone.stiffness_force, spring_bone.stiffness_scale)
_adjust_magnitude(spring_bone.gravity_power, spring_bone.gravity_scale)
_adjust_magnitude(spring_bone.drag_force, spring_bone.drag_force_scale)
if len(spring_bone.gravity_dir) > 0:
spring_bone.gravity_dir_default = spring_bone.gravity_dir[0]
if spring_bone.gravity_dir.count(spring_bone.gravity_dir_default) == len(spring_bone.gravity_dir):
spring_bone.gravity_dir.clear()
if not spring_bone.comment.is_empty():
spring_bone.resource_name = spring_bone.comment.split("\n")[0]
else:
spring_bone.resource_name = nodes[int(first_bone_node)].resource_name
spring_bone.collider_groups.clear()
for cgroup_idx in sbone.get("colliderGroups", []):
spring_bone.collider_groups.append(collider_groups[int(cgroup_idx)])
# Center commonly points outside of the glTF Skeleton, such as the root node.
spring_bone.center_node = NodePath()
spring_bone.center_bone = ""
var center_node_idx = sbone.get("center", -1)
if center_node_idx != -1:
var center_gltfnode: GLTFNode = nodes[int(center_node_idx)]
var bone_name: String = center_gltfnode.resource_name
if skeleton != null and center_gltfnode.skeleton == gltfnode.skeleton and skeleton.find_bone(bone_name) != -1:
spring_bone.center_bone = bone_name
spring_bone.center_node = NodePath()
else:
spring_bone.center_bone = ""
spring_bone.center_node = (secondary_node.get_path_to(gstate.get_scene_node(int(center_node_idx))))
if spring_bone.center_node == NodePath():
printerr("Failed to find center scene node " + str(center_node_idx))
spring_bone.center_node = secondary_node.get_path_to(secondary_node) # Fallback
spring_bones.append(spring_bone)
secondary_node.set_script(vrm_secondary)
secondary_node.set("skeleton", secondary_node.get_path_to(skeleton))
secondary_node.set("spring_bones", spring_bones)
func _add_joints_recursive(new_joints_set: Dictionary, gltf_nodes: Array, bone: int, include_child_meshes: bool = false) -> void:
if bone < 0:
return
var gltf_node: Dictionary = gltf_nodes[bone]
if not include_child_meshes and gltf_node.get("mesh", -1) != -1:
return
new_joints_set[bone] = true
for child_node in gltf_node.get("children", []):
if not new_joints_set.has(child_node):
_add_joints_recursive(new_joints_set, gltf_nodes, int(child_node))
func _add_joint_set_as_skin(obj: Dictionary, new_joints_set: Dictionary) -> void:
var new_joints = [].duplicate()
for node in new_joints_set:
new_joints.push_back(node)
new_joints.sort()
var new_skin: Dictionary = {"joints": new_joints}
if not obj.has("skins"):
obj["skins"] = [].duplicate()
obj["skins"].push_back(new_skin)
func _add_vrm_nodes_to_skin(obj: Dictionary) -> bool:
var vrm_extension: Dictionary = obj.get("extensions", {}).get("VRMC_springBone", {})
var new_joints_set = {}.duplicate()
for bone_group in vrm_extension.get("springs", []):
for joint in bone_group["joints"]:
_add_joints_recursive(new_joints_set, obj["nodes"], joint["node"], true)
for collider_group in vrm_extension.get("colliders", []):
if int(collider_group["node"]) >= 0:
new_joints_set[int(collider_group["node"])] = true
_add_joint_set_as_skin(obj, new_joints_set)
return true
func _import_preflight(state: GLTFState, extensions = PackedStringArray()) -> Error:
if not extensions.has("VRMC_springBone"):
return ERR_INVALID_DATA
var gltf_json_parsed: Dictionary = state.json
if not _add_vrm_nodes_to_skin(gltf_json_parsed):
push_error("Failed to find required VRMC_springBone extension properties in json")
return ERR_INVALID_DATA
return OK
# Called when the node enters the scene tree for the first time.
func _import_post(state: GLTFState, root_node: Node):
var gltf_json: Dictionary = state.json
var vrm_extension: Dictionary = gltf_json["extensions"]["VRMC_springBone"]
if vrm_extension.get("specVersion", "") != "1.0":
push_warning("Unsupported VRMC_springBone specVersion " + str(vrm_extension.get("specVersion", "")))
var secondary_node: Node
if root_node.has_node("secondary"):
secondary_node = root_node.get_node("secondary")
else:
secondary_node = Node3D.new()
root_node.add_child(secondary_node, true)
secondary_node.set_owner(root_node)
secondary_node.set_name("secondary")
_parse_secondary_node(secondary_node, vrm_extension, state)
return OK
func _export_preflight(state: GLTFState, root: Node):
if not root.has_node("secondary"):
print("No secondary node")
return ERR_INVALID_DATA
var secondary = root.get_node("secondary")
if secondary.get_script() != vrm_secondary:
print("Incorrect secondary node script")
return ERR_INVALID_DATA
state.add_used_extension("VRMC_springBone", false)
state.set_additional_data("VRMC_springBone", secondary)
#secondary_node.set_script(vrm_secondary)
#secondary_node.set("spring_bones", spring_bones)
#secondary_node.set("collider_groups", collider_groups)
return OK
static func _get_humanoid_skel(root_node: Node3D) -> Skeleton3D:
var humanoid_skeleton: Skeleton3D
if root_node.has_node("%GeneralSkeleton"):
humanoid_skeleton = root_node.get_node("%GeneralSkeleton")
else:
var skels: Array[Node] = root_node.find_children("*", "Skeleton3D", true)
if not skels.is_empty():
humanoid_skeleton = skels[0]
return humanoid_skeleton
func _export_post(state: GLTFState):
var secondary: vrm_secondary = state.get_additional_data("VRMC_springBone")
var collider_groups: Array[vrm_collider_group]
var spring_bones: Array[vrm_spring_bone] = secondary.spring_bones
var skel: Skeleton3D = secondary.get_node(secondary.skeleton)
var unique_collider_groups: Dictionary = {}
var unique_colliders: Dictionary = {}
var colliders: Array[vrm_collider] = []
for current_spring in spring_bones:
for collider_group in current_spring.collider_groups:
if unique_collider_groups.has(collider_group):
continue
unique_collider_groups[collider_group] = true
collider_groups.append(collider_group)
for collider in collider_group.colliders:
if collider not in unique_colliders:
unique_colliders[collider] = len(colliders)
colliders.push_back(collider)
var json: Dictionary = state.json
var sbone_extension: Dictionary = {}
if not json.has("extensions"):
json["extensions"] = {}
json["extensions"]["VRMC_springBone"] = sbone_extension
var skel_to_godot_bone_to_gltf_node_map: Dictionary = {}
for skely in state.skeletons:
skel_to_godot_bone_to_gltf_node_map[skely.get_godot_skeleton()] = skely.get_godot_bone_node()
var godot_node_to_idx: Dictionary = {}
var json_nodes: Array = json["nodes"]
for i in range(len(json_nodes)):
godot_node_to_idx[state.get_scene_node(i)] = i
# godot_node_to_idx[secondary.get_parent()] = godot_node_to_idx[secondary]
var json_colliders: Array = []
for collider in colliders:
var shape: Dictionary = {}
if collider.is_capsule:
shape = {
"capsule":
{
"offset": [collider.offset.x, collider.offset.y, collider.offset.z],
"radius": collider.radius,
"tail": [collider.tail.x, collider.tail.y, collider.tail.z],
}
}
else:
shape = {
"sphere":
{
"offset": [collider.offset.x, collider.offset.y, collider.offset.z],
"radius": collider.radius,
}
}
var node_idx: int
if collider.bone != "":
node_idx = skel_to_godot_bone_to_gltf_node_map.get(skel, {}).get(skel.find_bone(collider.bone), -1)
else:
# FIXME: This case should perhaps no longer be supported.
node_idx = godot_node_to_idx.get(secondary.get_node(collider.node_path), -1)
if node_idx == -1:
push_warning("Unable to find spring bone collider node " + str(collider.node_path) + "/" + str(collider.bone))
continue
json_colliders.push_back({"node": node_idx, "shape": shape})
sbone_extension["colliders"] = json_colliders
var json_collider_groups: Array = []
var collider_group_indices: Dictionary = {}
for current_group in collider_groups:
var json_collider_list: Array = []
for collider in current_group.colliders:
json_collider_list.push_back(unique_colliders[collider])
var json_collider_group: Dictionary = {}
json_collider_group["colliders"] = json_collider_list
if current_group.resource_name != "":
json_collider_group["name"] = current_group.resource_name
collider_group_indices[current_group] = len(json_collider_groups)
json_collider_groups.push_back(json_collider_group)
sbone_extension["colliderGroups"] = json_collider_groups
var json_springs: Array = []
for springbone in spring_bones:
var spring: Dictionary = {}
# var skeleton_node: Skeleton3D = secondary.get_node(secondary.skeleton)
if springbone.resource_name != "":
spring["name"] = springbone.resource_name
if springbone.center_node == NodePath() and springbone.center_bone == "":
pass
elif springbone.center_node == NodePath():
spring["center"] = skel_to_godot_bone_to_gltf_node_map[skel][skel.find_bone(springbone.center_bone)]
else:
spring["center"] = godot_node_to_idx[secondary.get_node(springbone.center_node)]
var spring_groups: Array = []
for collider_group in springbone.collider_groups:
if collider_group_indices.has(collider_group):
spring_groups.push_back(collider_group_indices[collider_group])
else:
push_warning("Missing collider_group_indices in vrm export.")
spring["colliderGroups"] = spring_groups
var joints: Array = []
var prev_node_index: int = 0
for i in range(len(springbone.joint_nodes)):
var joint: Dictionary = {}
if springbone.joint_nodes[i] == "":
var node_idx = len(json_nodes)
var delta: Vector3 = skel.get_bone_rest(skel.find_bone(springbone.joint_nodes[i - 1])).origin
var pos: Vector3 = delta.normalized() * 0.07
var prev_node_dict: Dictionary = json_nodes[prev_node_index]
json_nodes.append({"name": prev_node_dict["name"] + "_end", "translation": [pos[0], pos[1], pos[2]]})
if not prev_node_dict.has("children"):
prev_node_dict["children"] = []
var prev_node_children: Array = prev_node_dict["children"]
prev_node_children.append(node_idx)
prev_node_index = node_idx
else:
prev_node_index = skel_to_godot_bone_to_gltf_node_map.get(skel, {}).get(skel.find_bone(springbone.joint_nodes[i]), -1)
if prev_node_index == -1:
continue
joint["node"] = prev_node_index
var gravity_dir: Vector3 = (springbone.gravity_dir[i] if i < len(springbone.gravity_dir) else springbone.gravity_dir_default)
var pfa: PackedFloat64Array = springbone.gravity_power
var gravity_power: float = (1.0 if pfa.is_empty() else pfa[i] if i < len(pfa) else pfa[-1]) * springbone.gravity_scale
pfa = springbone.stiffness_force
var stiffness: float = springbone.stiffness_scale * (1.0 if pfa.is_empty() else pfa[i] if i < len(pfa) else pfa[-1])
pfa = springbone.drag_force
var drag_force: float = springbone.drag_force_scale * (1.0 if pfa.is_empty() else pfa[i] if i < len(pfa) else pfa[-1])
pfa = springbone.hit_radius
var hit_radius = springbone.hit_radius_scale * (1.0 if pfa.is_empty() else pfa[i] if i < len(pfa) else pfa[-1])
if not is_zero_approx(hit_radius):
joint["hitRadius"] = hit_radius
if not is_equal_approx(stiffness, 1.0):
joint["stiffness"] = stiffness
if not is_zero_approx(gravity_power):
joint["gravityPower"] = gravity_power
if not gravity_dir.is_equal_approx(Vector3(0, -1, 0)) and not is_zero_approx(gravity_power):
joint["gravityDir"] = [gravity_dir[0], gravity_dir[1], gravity_dir[2]]
if not is_equal_approx(drag_force, 0.5):
joint["dragForce"] = drag_force
joints.push_back(joint)
if len(joints) < 2:
push_warning("Unable to resolve vrm springbone joints " + ','.join(springbone.joint_nodes))
continue
spring["joints"] = joints
json_springs.push_back(spring)
sbone_extension["springs"] = json_springs
sbone_extension["specVersion"] = "1.0"
@@ -0,0 +1 @@
uid://km8m8wy853hv
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
uid://cd7qg71xy786j
@@ -0,0 +1,3 @@
extends GLTFDocumentExtension
pass
@@ -0,0 +1 @@
uid://dkd2cmpksxlvw
@@ -0,0 +1,26 @@
Note: Specific licenses apply to .vrm sample models. See vrm_samples/LICENSE_SAMPLES.txt for details
MIT License
Copyright (c) 2020-2021 V-Sekai Contributors (see credits in README.md)
Copyright (c) 2020 VRM Consortium
Copyright (c) 2018 Masataka SUMI for MToon
Copyright (c) 2007-2021 Juan Linietsky, Ariel Manzur.
Copyright (c) 2014-2021 Godot Engine contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,86 @@
- [English](README.md)
# VRM addon for Godot Engine
このパッケージには VRM Addon として [VRM v1.0](https://github.com/vrm-c/vrm-specification/tree/master/specification) に準拠した VRM モデルのインポーターやエクスポートと、VRM を動かすためのスクリプトが含まれています。Godot Engine 4.0 stable 以降に対応しています。
[V-Sekai team](https://v-sekai.org/about) が自信を持ってお届けします。
また、VRM Addon とは別に Godot 用の MToon シェーダーも同梱されています。(MToon 単体での利用が可能です)
![Example of VRM Addon used to import two example characters](vrm_samples/screenshot/vrm_sample_screenshot.png)
VRM が持つデータは全てインポートされ、インスペクタに表示されます。ただし、ボーンアニメーション等を行う場合に[リターゲットの必要性が出てくる](https://qiita.com/TokageItLab/items/e5880123a9f508b2769d)ので、それらに関しては他のスクリプトやアドオンの導入を各自で検討して下さい。
## VRM とは?
参照:[https://vrm.dev/](https://vrm.dev/)
「VRM」は VR アプリケーション向けの人型 3D アバター(3D モデル)データを扱うためのファイルフォーマットです。[glTF 2.0](https://www.khronos.org/gltf/) をベースとしており、誰でも自由に利用することができます。
## 現在 Godot で動作する VRM の機能
VRM 1.0をインポートとエクスポートをサポートをサポートします。機能の内訳は次のとおりです。
* VRM 0.0をインポート:✅実装済み; VRM 1.0への変換します。
* VRM 1.0をインポート:✅実装済み
* VRMをエクスポート(`.vrm`):✅実装済み, エクスポートには全部のモデルをVRM 1.0になります。
* VRM1.0の拡張子のglTFをエクスポート(`.gltf`):✅`VRMC_node_constraint`, ✅`VRMC_materials_mtoon`
* ⚠️ VRMC_springBoneは、`.vrm`の代わりに`.gltf`を使用することはサポートされていません。
* ⚠️ 注意: When exporting .gltf, a clone of the scene root node is not made by Godot.
Because some export operations are destructive, the export process will corrupt some of your materials.
Please save the scene first and revert after export!
* `VRMC_materials_mtoon`:✅実装済み
* `VRMC_node_constraint`:⚠バグ: リターゲティングと️問題がある。
* `VRMC_springBone`:✅実装済み(ボーン操作最適化パッチの適用を推奨)
* `VRMC_materials_hdr_emissive`:✅実装済み
* `VRMC_vrm`:✅実装済み
* `firstPerson`:⚠️Head hiding implemented (camera layers or runtime script needed)
* `eyeOffset`:✅実装済み(`Head``BoneAttachment3D``LookOffset`」)
* `lookAt`:⚠AnimationTrack として追加 (application must create `BlendSpace2D`)
* `expressions`(気分、口形素):
* モーフ、ブレンドシェイプ、バインド: ✅実装済み(`BlendTree` `Add3` AnimationTrack として追加)
* マテリアルカラー、UVオフセット: ✅実装済み(`BlendTree` `Add3` AnimationTrack として追加)
* `humanoid`:✅実装済み (uses `%GeneralSkeleton` `SkeletonProfileHumanoid` compatible retargeting.)
* Metadata:✅実装済み, including License information and screenshot
## Future work
* `VRMC_vrm_animation`のサポート
* サポートされていません。Intended use: humanoid AnimationLibrary import/export.
## Godot 3.x
Godot 3.x(3.2.2 以降)は、このリポジトリの `godot3` ブランチを利用して下さい。
https://github.com/V-Sekai/godot-vrm
## 使い方
VRM Addon を addons/vrm にインストールします。**生成された VRM meta のスクリプトからパスを参照するので、決してリネームしないで下さい。**
Godot-MToon-Shader を addons/Godot-MToon-Shader にインストールします。**マテリアルからパスを参照するので、決してリネームしないで下さい。**
「プロジェクト設定」→「プラグイン」で、「VRM」と「Godot-MToon-Shader」を探し、VRM と MToon プラグインを有効にします。
## 謝辞
Godot-VRM のテストと開発にご協力頂きました [V-Sekai team](https://v-sekai.org/about) とコントリビューターの方々に感謝致します。
- [The Mirror](https://www.themirror.space/)の https://github.com/aaronfranke
- https://github.com/fire
- https://github.com/TokageItLab
- https://github.com/lyuma
- https://github.com/SaracenOne
For their extensive help testing and contributing code to Godot-VRM.
また、UniVRM、MToon、その他 VRM ツールの開発者の方々に感謝致します。
- The VRM Consortium ( https://github.com/vrm-c )
- https://github.com/Santarh
- https://github.com/ousttrue
- https://github.com/saturday06
- https://github.com/FMS-Cat
@@ -0,0 +1,110 @@
- [日本語](README.ja.md)
# VRM addon for Godot Engine
This Godot addon fully implements an importer and exporter for models with the [VRM specification](https://github.com/vrm-c/vrm-specification/tree/master/specification).
Compatible with Godot Engine 4.0 stable or newer.
Proudly brought to you by the [V-Sekai team](https://v-sekai.org/about).
This package also includes a standalone full implementation of the MToon Shader for Godot Engine.
![Example of VRM Addon used to import two example characters](vrm_samples/screenshot/vrm_sample_screenshot.png)
## What is VRM?
See [https://vrm.dev/en/](https://vrm.dev/en/) (English) or [https://vrm.dev/](https://vrm.dev/) (日本語)
"VRM" is a file format for handling 3D humanoid avatar (3D model) data for VR applications.
It is based on [glTF 2.0](https://www.khronos.org/gltf/). Anyone is free to use it.
## VRM Features are currently supported in Godot Engine!
Import and export of VRM through version 1.0 is supported. Here is a feature breakdown:
* VRM 0.0 Import: ✅Implemented; will convert to VRM 1.0 compatible naming!
* VRM 1.0 Import: ✅Implemented
* VRM Export (`.vrm`): ✅Implemented, will export all models as VRM 1.0
* glTF Export with VRM 1.0 extensions (`.gltf`): ✅`VRMC_node_constraint`, ✅`VRMC_materials_mtoon`
* ⚠️ `VRMC_springBone` not supported in non-`.vrm` standalone `.gltf` export.
* ⚠️ Warning: When exporting `.gltf`, a clone of the scene root node is not made by Godot.
Because some export operations are destructive, the export process will corrupt some of your materials.
Please save the scene first and revert after export!
* `VRMC_materials_mtoon`: ✅Implemented
* `VRMC_node_constraint`: ⚠️Buggy: known issues when combined with retargeting.
* `VRMC_springBone`: ✅Implemented, but needs optimization.
* `VRMC_materials_hdr_emissive`: ✅Implemented
* `VRMC_vrm`: ✅Implemented
* `firstPerson`: ⚠️Head hiding implemented and supported as an import option (camera layers or runtime script needed)
* `eyeOffset`: ✅Implemented (`BoneAttachment3D` `"LookOffset"` on `Head`)
* `lookAt`: ⚠Only creates animation tracks (application must create `BlendSpace2D`)
* `expressions` (mood, viseme):
* blend shapes / binds: ✅Implemented (Animation tracks intended for `BlendTree` `Add2`)
* material color / UV offsets: ✅Implemented (Animation tracks intended for `BlendTree` `Add2`)
* `humanoid`: ✅Implemented (uses `%GeneralSkeleton` `SkeletonProfileHumanoid` compatible retargeting.)
* Metadata: ✅Implemented, including License information and screenshot
## Future work
* Support VRMC_vrm_animation:
* Not yet implemented. Intended use: humanoid AnimationLibrary import/export.
## A note about SkeletonModifier3D on Godot 4.3 and later.
godot-vrm currently creates an internal node child of the Skeleton3D to facilitate processing the skeleton modifiers for
VRM spring bones and node constraints.
Due to the behavior of skeleton modifier, there may be some differences.
For example, on Godot 4.3+, `update_secondary_fixed` is no longer supported: instead, the Skeleton node determines whether to use physics or idle processing.
## Head hiding settings
At import time, there are new scene import settings for .vrm files.
For runtime usage, head hiding mode is determined by various additional data properties on the GLTFState object:
`vrm/head_hiding_method` is an enum `vrm_constants.HeadHidingSetting` that determines the mode.
For BothLayers and BothLayersWithShadow modes, the MeshInstance3D layers are determined by the
`vrm/first_person_layers` and `vrm/third_person_layers` integers respectively.
For FirstPersonOnlyWithShadow, FirstPersonOnly and ThirdPersonOnly, certain meshes are deleted or modified to make the character suitable for first person or third person usage.
Shadow modes will create an additional mesh for hidden heads set to ShadowsOnly to allow the hidden head to still cast a shadow.
Recommended if your game has a first person mode and uses lights with shadows enabled.
Finally, there is an IgnoreHeadHiding mode which disables handling of the firstPerson flags and acts like an ordinary glTF import.
## Note for users of Godot 3.x
For VRM compatible with Godot Engine 3.2.2 or later, use the `godot3` branch of this repository.
https://github.com/V-Sekai/godot-vrm
## How to use
Install the vrm addon folder into addons/vrm. MUST NOT BE RENAMED: This path will be referenced by generated VRM meta scripts.
Install Godot-MToon-Shader into addons/Godot-MToon-Shader. MUST NOT BE RENAMED: This path is referenced by generated materials.
Enable the VRM and MToon plugins in Project Settings -> Plugins -> VRM and Godot-MToon-Shader.
## Credits
Thanks to the [V-Sekai team](https://v-sekai.org/about) and contributors:
- https://github.com/aaronfranke and [The Mirror team](https://www.themirror.space/)
- https://github.com/fire
- https://github.com/TokageItLab
- https://github.com/lyuma
- https://github.com/SaracenOne
For their extensive help testing and contributing code to Godot-VRM.
Special thanks to the authors of UniVRM, MToon and other VRM tooling
- The VRM Consortium ( https://github.com/vrm-c )
- https://github.com/Santarh
- https://github.com/ousttrue
- https://github.com/saturday06
- https://github.com/FMS-Cat
@@ -0,0 +1,50 @@
@tool
extends EditorSceneFormatImporter
const gltf_document_extension_class = preload("./vrm_extension.gd")
const vrm_constants = preload("./vrm_constants.gd")
const SAVE_DEBUG_GLTFSTATE_RES: bool = false
func _get_importer_name() -> String:
return "Godot-VRM"
func _get_recognized_extensions() -> Array:
return ["vrm"]
func _get_extensions() -> PackedStringArray:
var exts: PackedStringArray
exts.push_back("vrm")
return exts
func _get_import_flags() -> int:
return IMPORT_SCENE
func _import_scene(path: String, flags: int, options: Dictionary) -> Object:
print("Import VRM: " + path + " ----------------------")
var gltf: GLTFDocument = GLTFDocument.new()
flags |= EditorSceneFormatImporter.IMPORT_USE_NAMED_SKIN_BINDS
var vrm_extension: GLTFDocumentExtension = gltf_document_extension_class.new()
gltf.register_gltf_document_extension(vrm_extension, true)
var state: GLTFState = GLTFState.new()
state.set_additional_data(&"vrm/head_hiding_method", options.get(&"vrm/head_hiding_method", 0) as vrm_constants.HeadHidingSetting)
state.set_additional_data(&"vrm/first_person_layers", options.get(&"vrm/only_if_head_hiding_uses_layers/first_person_layers", 2) as int)
state.set_additional_data(&"vrm/third_person_layers", options.get(&"vrm/only_if_head_hiding_uses_layers/third_person_layers", 4) as int)
# HANDLE_BINARY_EMBED_AS_BASISU crashes on some files in 4.0 and 4.1
state.handle_binary_image = GLTFState.HANDLE_BINARY_EMBED_AS_UNCOMPRESSED # GLTFState.HANDLE_BINARY_EXTRACT_TEXTURES
var err = gltf.append_from_file(path, state, flags)
if err != OK:
gltf.unregister_gltf_document_extension(vrm_extension)
return null
var generated_scene = gltf.generate_scene(state)
if SAVE_DEBUG_GLTFSTATE_RES and path != "":
if !ResourceLoader.exists(path + ".res"):
state.take_over_path(path + ".res")
ResourceSaver.save(state, path + ".res")
gltf.unregister_gltf_document_extension(vrm_extension)
return generated_scene
@@ -0,0 +1 @@
uid://dejowscl2y1n0
@@ -0,0 +1,31 @@
@tool
extends ImporterMeshInstance3D
@export var orig_layers: int:
get:
if typeof(get(&"layer_mask")) != TYPE_NIL:
return get(&"layer_mask")
return 1 # Default layer on older engine versions.
@export var orig_shadow: int:
get:
if typeof(get(&"cast_shadow")) != TYPE_NIL:
return get(&"cast_shadow")
return GeometryInstance3D.SHADOW_CASTING_SETTING_ON
@export var shadow: int = GeometryInstance3D.SHADOW_CASTING_SETTING_ON
@export var layers: int
@export var first_person_flag: String
func _on_replacing_by(p_node: Node):
if not (p_node is MeshInstance3D):
push_error("ImporterMeshInstance3D was not replaced with MeshInstance3D")
var mi: MeshInstance3D = p_node as MeshInstance3D
mi.layers = layers
mi.cast_shadow = shadow
mi.set_meta("vrm_first_person_flag", first_person_flag)
func _init():
self.replacing_by.connect(_on_replacing_by)
@@ -0,0 +1 @@
uid://dn77qs3l0rnn8
@@ -0,0 +1,356 @@
## Constrains a target bone or node, by reading a source a bone or node.
@tool
@icon("icons/bone_node_constraint.svg")
class_name BoneNodeConstraint
extends Resource
enum ConstraintType {
NONE = 0,
AIM = 1,
ROLL = 2,
ROTATION = 3,
}
enum AimRollAxis {
NONE = 0,
POSITIVE_X = 1,
POSITIVE_Y = 2,
POSITIVE_Z = 3,
NEGATIVE_X = 4,
NEGATIVE_Y = 5,
NEGATIVE_Z = 6,
}
@export_group("Parameters")
@export var constraint_type: ConstraintType
@export var aim_or_roll_axis: AimRollAxis
@export var weight: float = 1.0
@export_group("Source")
@export var source_node_path: NodePath
@export var source_bone_name: StringName = &"":
set(value):
source_bone_name = value
var source_skel := source_node as Skeleton3D
if source_skel != null:
source_bone = source_skel.find_bone(source_bone_name)
@export var source_rest_transform: Transform3D
@export_group("Target")
@export var target_node_path: NodePath
@export var target_bone_name: StringName = &"":
set(value):
target_bone_name = value
var target_skel := target_node as Skeleton3D
if target_skel != null:
target_bone = target_skel.find_bone(target_bone_name)
@export var target_rest_rotation: Quaternion
@export var target_rest_origin: Vector3
# Compatibility options (for loading old scenes)
var source_bone_index: int = -1
var target_bone_index: int = -1
var target_rest_transform: Transform3D:
set(value):
target_rest_rotation = value.basis.get_rotation_quaternion()
target_rest_origin = value.origin
# Used during import/export and runtime, but can't be saved, instead a NodePath is saved.
var source_node: Node3D
var target_node: Node3D
# Used during the import/export process, but not exposed or saved.
var source_node_index: int = -1
var source_bone: int = -1
var target_bone: int = -1
var same_skeleton: bool
func set_node_references_from_paths(applier: Node) -> void:
source_node = applier.get_node(source_node_path) as Node3D
target_node = applier.get_node(target_node_path) as Node3D
var source_skel := source_node as Skeleton3D
source_bone = -1
if source_skel != null:
if source_bone_index != -1 and source_bone_name == &"":
source_bone_name = source_skel.get_bone_name(source_bone_index)
if source_bone_name != &"":
source_bone = source_skel.find_bone(source_bone_name)
var target_skel := target_node as Skeleton3D
target_bone = -1
if target_skel != null:
if target_bone_index != -1 and target_bone_name == &"":
target_bone_name = target_skel.get_bone_name(target_bone_index)
if target_bone_name != &"":
target_bone = target_skel.find_bone(target_bone_name)
same_skeleton = target_bone != -1 and source_bone != -1 and target_node == source_node
func set_node_paths_from_references(applier: Node) -> void:
source_node_path = applier.get_path_to(source_node)
target_node_path = applier.get_path_to(target_node)
func evaluate() -> void:
if constraint_type == ConstraintType.AIM:
evaluate_aim()
elif constraint_type == ConstraintType.ROLL:
evaluate_roll()
elif constraint_type == ConstraintType.ROTATION:
evaluate_rotation()
func evaluate_aim() -> void:
if source_node == null or target_node == null:
return
var source_global_transform: Transform3D = _get_source_global_transform() # * source_node.get_bone_pose(source_bone).affine_inverse() * Transform3D(source_node.get_bone_rest(source_bone).basis, Vector3())
var target_global_transform: Transform3D = _get_target_global_transform() * target_node.get_bone_pose(target_bone).affine_inverse() # * Transform3D(target_node.get_bone_rest(target_bone).basis, Vector3())
var target_rest_transform: Transform3D = target_node.get_bone_rest(target_bone) # .basis.get_rotation_quaternion()
# var relative_source_transform: Transform3D = target_rest_transform.affine_inverse() * target_global_transform.affine_inverse() * source_global_transform * source_rest_transform
var relative_source_transform: Transform3D = target_global_transform.affine_inverse() * source_global_transform * source_rest_transform
var rest_dir: Vector3 = _aim_get_rest_direction(target_rest_transform.basis) # Basis(target_rest_rotation))
#if source_bone_name == 'LeftHand':
# print(relative_source_transform.origin.normalized()) # print(source_node.get_bone_pose(source_bone).origin)
# - target_rest_rotation * target_rest_transform.origin
var aim_dir: Vector3 = (relative_source_transform.origin - target_rest_origin).normalized() # target_global_transform.origin.direction_to(source_global_transform.origin)
#if rest_dir.is_zero_approx() or aim_dir.is_zero_approx():
# return
var arc := Quaternion(rest_dir, aim_dir).normalized()
#if source_bone_name == 'LeftHand':
# print(str(rest_dir)+","+str(aim_dir))#print(arc.get_euler())
#_set_weighted_global_target_rotation(arc)
target_node.set_bone_pose_rotation(target_bone, target_rest_transform.basis.get_rotation_quaternion() * arc) # * arc) # * arc)
func evaluate_roll() -> void:
if source_node == null or target_node == null:
return
if aim_or_roll_axis < AimRollAxis.POSITIVE_X or aim_or_roll_axis > AimRollAxis.POSITIVE_Z:
printerr("BoneNodeConstraint: Roll axis not set! Must be positive X, Y, or Z.")
return
# Gather axis-angle information from the source rotation.
var source_transform: Transform3D = _get_posed_source_transform()
var source_quat: Quaternion = source_transform.basis.get_rotation_quaternion()
var source_axis: Vector3 = source_quat.get_axis()
var source_angle: float = source_quat.get_angle()
# Calculate what we need to apply to the target.
var axis_index: int = aim_or_roll_axis - 1 # Vector3.Axis
var axis_value: float = source_axis[axis_index]
var rotation_quat := Quaternion.IDENTITY
if not is_zero_approx(axis_value):
var target_axis := Vector3.ZERO
target_axis[axis_index] = 1.0
rotation_quat = Quaternion(target_axis, source_angle * axis_value)
_set_weighted_posed_target_rotation(rotation_quat)
func evaluate_rotation() -> void:
if source_node == null or target_node == null:
return
var source_transform: Transform3D = _get_posed_source_transform()
var source_quat: Quaternion = source_transform.basis.get_rotation_quaternion()
_set_weighted_posed_target_rotation(source_quat)
static func from_dictionary(dict: Dictionary): # -> BoneNodeConstraint:
var ret := new()
if not dict.has("constraint"):
return ret
var constraint_dict: Dictionary = dict["constraint"]
if constraint_dict.is_empty():
return ret
# Set up the constraint type.
var constraint_type_string: String = constraint_dict.keys()[0]
if constraint_type_string == "aim":
ret.constraint_type = ConstraintType.AIM
elif constraint_type_string == "roll":
ret.constraint_type = ConstraintType.ROLL
elif constraint_type_string == "rotation":
ret.constraint_type = ConstraintType.ROTATION
else:
printerr("BoneNodeConstraint: Unknown constraint type: " + constraint_type_string)
# Set up weight and source node index.
var constraint_parameters: Dictionary = constraint_dict[constraint_type_string]
ret.weight = constraint_dict.get("weight", 1.0)
ret.source_node_index = constraint_parameters["source"]
assert(ret.source_node_index >= 0)
# Set up the aim or roll axis.
if ret.constraint_type == ConstraintType.AIM:
var aim_axis: String = constraint_parameters.get("aimAxis", "")
ret.aim_or_roll_axis = _from_dictionary_get_aim_axis_from_string(aim_axis)
elif ret.constraint_type == ConstraintType.ROLL:
var roll_axis: String = constraint_parameters.get("rollAxis", "")
ret.aim_or_roll_axis = _from_dictionary_get_roll_axis_from_string(roll_axis)
return ret
func to_dictionary() -> Dictionary:
var type_key: String = ""
if constraint_type == ConstraintType.AIM:
type_key = "aim"
elif constraint_type == ConstraintType.ROLL:
type_key = "roll"
elif constraint_type == ConstraintType.ROTATION:
type_key = "rotation"
var parameters: Dictionary = {"source": source_node_index}
if constraint_type == ConstraintType.AIM:
parameters["aimAxis"] = _to_dictionary_get_string_from_aim_axis()
elif constraint_type == ConstraintType.ROLL:
parameters["rollAxis"] = _to_dictionary_get_string_from_roll_axis()
if weight != 1.0:
parameters["weight"] = weight
var constraint: Dictionary = {}
constraint[type_key] = parameters
return {"specVersion": "1.0", "constraint": constraint}
func _get_posed_source_transform() -> Transform3D:
if source_bone == -1:
return source_rest_transform.affine_inverse() * source_node.transform
var skeleton: Skeleton3D = source_node as Skeleton3D
var rest_inverse: Transform3D = skeleton.get_bone_rest(source_bone).affine_inverse()
return rest_inverse * skeleton.get_bone_pose(source_bone)
func _get_source_global_transform() -> Transform3D:
if source_bone == -1:
return source_node.global_transform
var skeleton: Skeleton3D = source_node as Skeleton3D
var ret := skeleton.get_bone_global_pose(source_bone)
if not same_skeleton:
return skeleton.global_transform * ret
return ret
func _get_target_global_transform() -> Transform3D:
if target_bone == -1:
return target_node.global_transform
var skeleton: Skeleton3D = target_node as Skeleton3D
var ret := skeleton.get_bone_global_pose(target_bone)
if not same_skeleton:
return skeleton.global_transform * ret
return ret
func _get_target_global_rest() -> Transform3D:
if target_bone == -1:
var parent_global: Transform3D = target_node.get_parent().global_transform
return parent_global * Transform3D(Basis(target_rest_rotation), target_rest_origin)
var skeleton: Skeleton3D = target_node as Skeleton3D
var ret := skeleton.get_bone_global_rest(target_bone)
if not same_skeleton:
return skeleton.global_transform * ret
return ret
func _set_weighted_posed_target_rotation(rotation_quat: Quaternion) -> void:
#print("A!")
if weight != 1.0:
rotation_quat = Quaternion.IDENTITY.slerp(rotation_quat, weight)
if target_bone == -1:
var rest_quat: Quaternion = target_rest_rotation
target_node.quaternion = rest_quat * rotation_quat
return
var skeleton: Skeleton3D = target_node as Skeleton3D
var rest_quat: Quaternion = target_rest_rotation
#if source_bone_name == &"LeftArm":
# print(rest_quat * rotation_quat)
skeleton.set_bone_pose_rotation(target_bone, rest_quat * rotation_quat)
func _set_weighted_global_target_rotation(rotation_quat: Quaternion) -> void:
if weight != 1.0:
rotation_quat = Quaternion.IDENTITY.slerp(rotation_quat, weight)
if target_bone == -1:
var target_global_transform: Transform3D = target_node.global_transform
var scale_basis: Basis = Basis.from_scale(target_global_transform.basis.get_scale())
target_global_transform.basis = Basis(rotation_quat) * scale_basis
target_node.global_transform = target_global_transform
return
var skeleton: Skeleton3D = target_node as Skeleton3D
#rotation_quat = Quaternion(_get_target_global_rest().basis).inverse() * rotation_quat
var parent_global_quat = skeleton.get_bone_global_pose(skeleton.get_bone_parent(target_bone)).basis.get_rotation_quaternion()
rotation_quat = target_rest_rotation * parent_global_quat.inverse() * rotation_quat
skeleton.set_bone_pose_rotation(target_bone, rotation_quat)
func _aim_get_rest_direction(rest_basis: Basis) -> Vector3:
match aim_or_roll_axis:
AimRollAxis.POSITIVE_X:
return rest_basis.x
AimRollAxis.POSITIVE_Y:
return rest_basis.y
AimRollAxis.POSITIVE_Z:
return rest_basis.z
AimRollAxis.NEGATIVE_X:
return -rest_basis.x
AimRollAxis.NEGATIVE_Y:
return -rest_basis.y
AimRollAxis.NEGATIVE_Z:
return -rest_basis.z
printerr("BoneNodeConstraint: Aim axis not set! Must be a valid value.")
return Vector3.ZERO
static func _from_dictionary_get_aim_axis_from_string(aim_axis: String) -> AimRollAxis:
match aim_axis:
"PositiveX":
return AimRollAxis.POSITIVE_X
"PositiveY":
return AimRollAxis.POSITIVE_Y
"PositiveZ":
return AimRollAxis.POSITIVE_Z
"NegativeX":
return AimRollAxis.NEGATIVE_X
"NegativeY":
return AimRollAxis.NEGATIVE_Y
"NegativeZ":
return AimRollAxis.NEGATIVE_Z
printerr("BoneNodeConstraint: Unknown aim axis: " + aim_axis)
return AimRollAxis.NONE
static func _from_dictionary_get_roll_axis_from_string(roll_axis: String) -> AimRollAxis:
match roll_axis:
"X":
return AimRollAxis.POSITIVE_X
"Y":
return AimRollAxis.POSITIVE_Y
"Z":
return AimRollAxis.POSITIVE_Z
printerr("BoneNodeConstraint: Unknown roll axis: " + roll_axis)
return AimRollAxis.NONE
func _to_dictionary_get_string_from_aim_axis() -> String:
match aim_or_roll_axis:
AimRollAxis.POSITIVE_X:
return "PositiveX"
AimRollAxis.POSITIVE_Y:
return "PositiveY"
AimRollAxis.POSITIVE_Z:
return "PositiveZ"
AimRollAxis.NEGATIVE_X:
return "NegativeX"
AimRollAxis.NEGATIVE_Y:
return "NegativeY"
AimRollAxis.NEGATIVE_Z:
return "NegativeZ"
printerr("BoneNodeConstraint: Invalid aim axis: " + str(aim_or_roll_axis))
return ""
func _to_dictionary_get_string_from_roll_axis() -> String:
match aim_or_roll_axis:
AimRollAxis.POSITIVE_X:
return "X"
AimRollAxis.POSITIVE_Y:
return "Y"
AimRollAxis.POSITIVE_Z:
return "Z"
printerr("BoneNodeConstraint: Invalid roll axis: " + str(aim_or_roll_axis))
return ""
@@ -0,0 +1 @@
uid://oyeyy4kgkpxh
@@ -0,0 +1,56 @@
## Attach this node in the scene and it will process the array of constraint
## resources on either bones or nodes, whatever the constraints reference.
@tool
@icon("icons/bone_node_constraint_applier.svg")
class_name BoneNodeConstraintApplier
extends Node
@export_node_path("Skeleton3D") var skeleton: NodePath:
set(value):
skeleton = value
if is_inside_tree():
_ready()
const bone_node_constraint = preload("./bone_node_constraint.gd")
@export var constraints: Array[bone_node_constraint] = []
#@export_node_path("Skeleton3D") var skeleton_node_path: NodePath = ^"%GeneralSkeleton"
#var skeleton: Skeleton3D
var skel: Skeleton3D
var internal_modifier_node: Node3D
func _ready() -> void:
if skeleton != NodePath():
skel = get_node(skeleton)
for constraint in constraints:
constraint.set_node_references_from_paths(self)
if skel == null:
skel = constraint.target_node as Skeleton3D
if skel == null:
skel = constraint.source_node as Skeleton3D
if skel == null:
return # Not supported.
if skeleton == NodePath():
skeleton = get_path_to(skel)
if ClassDB.class_exists(&"SkeletonModifier3D"):
if internal_modifier_node != null:
if internal_modifier_node.get_parent() != null:
internal_modifier_node.get_parent().remove_child(internal_modifier_node)
internal_modifier_node.queue_free()
internal_modifier_node = ClassDB.instantiate("SkeletonModifier3D")
internal_modifier_node.name = "VRM_internal_skeleton_modifier"
skel.add_child(internal_modifier_node, false, Node.INTERNAL_MODE_BACK)
internal_modifier_node.connect(&"modification_processed", self.do_process)
func _process(_delta: float):
#if not ClassDB.class_exists(&"SkeletonModifier3D"):
do_process()
func do_process() -> void:
for constraint in constraints:
constraint.evaluate()
@@ -0,0 +1 @@
<svg height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg"><path d="M10.478 1a2.466 2.466 0 0 0-2.094 3.824l-3.56 3.56a2.466 2.466 0 1 0-1.705 4.496 2.466 2.466 0 1 0 4.496-1.705l3.56-3.56a2.466 2.466 0 1 0 1.705-4.496A2.466 2.466 0 0 0 10.478 1z" fill="#d2b7e9"/></svg>

After

Width:  |  Height:  |  Size: 295 B

@@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://c44wulwtgxh3k"
path="res://.godot/imported/bone_node_constraint.svg-02a074f2e4ecc183996b19e0e710b229.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/vrm/node_constraint/icons/bone_node_constraint.svg"
dest_files=["res://.godot/imported/bone_node_constraint.svg-02a074f2e4ecc183996b19e0e710b229.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=2.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false
@@ -0,0 +1 @@
<svg height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg"><path d="M10.478 1a2.466 2.466 0 0 0-2.094 3.824l-3.56 3.56a2.466 2.466 0 1 0-1.705 4.496 2.466 2.466 0 1 0 4.496-1.705l3.56-3.56a2.466 2.466 0 1 0 1.705-4.496A2.466 2.466 0 0 0 10.478 1z" fill="#c38ef1"/></svg>

After

Width:  |  Height:  |  Size: 295 B

@@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dyk6y7maq82aj"
path="res://.godot/imported/bone_node_constraint_applier.svg-00cf8625100cc187a6acad8190383127.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/vrm/node_constraint/icons/bone_node_constraint_applier.svg"
dest_files=["res://.godot/imported/bone_node_constraint_applier.svg-00cf8625100cc187a6acad8190383127.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=2.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false
@@ -0,0 +1,7 @@
[plugin]
name="VRM"
description="VRM Importer"
author="V-Sekai"
version="2.0.1"
script="plugin.gd"
@@ -0,0 +1,231 @@
@tool
extends EditorPlugin
var import_plugin: EditorSceneFormatImporter
const VRMC_node_constraint = preload("./1.0/VRMC_node_constraint.gd")
var VRMC_node_constraint_inst := VRMC_node_constraint.new()
const VRMC_springBone = preload("./1.0/VRMC_springBone.gd")
var VRMC_springBone_inst := VRMC_springBone.new()
const VRMC_materials_mtoon = preload("./1.0/VRMC_materials_mtoon.gd")
var VRMC_materials_mtoon_inst := VRMC_materials_mtoon.new()
const VRMC_materials_hdr_emissiveMultiplier = preload("./1.0/VRMC_materials_hdr_emissiveMultiplier.gd")
var VRMC_materials_hdr_emissiveMultiplier_inst := VRMC_materials_hdr_emissiveMultiplier.new()
const VRMC_vrm = preload("./1.0/VRMC_vrm.gd")
var VRMC_vrm_inst := VRMC_vrm.new()
const VRMC_vrm_animation = preload("./1.0/VRMC_vrm_animation.gd")
var VRMC_vrm_animation_inst := VRMC_vrm_animation.new()
const vrm_options_post_import_plugin = preload("./vrm_options_post_import_plugin.gd")
var vrm_options_post_import_plugin_inst := vrm_options_post_import_plugin.new()
const vrm_meta_class = preload("./vrm_meta.gd")
const vrm_top_level = preload("./vrm_toplevel.gd")
const vrm_secondary = preload("./vrm_secondary.gd")
#const vrm_export_extension = preload("./1.0/vrm_export_extension.gd")
#var vrm_export_extension_inst = vrm_export_extension.new()
const export_as_item: String = "VRM 1.0 Avatar..."
const export_as_id: int = 0x56524d31 # 'VRM1'
var file_export_lib: EditorFileDialog
var accept_dialog: AcceptDialog
var selected_nodes: Array[Node]
var current_export_node: Node
func _export_vrm_pressed():
var root = get_tree().get_edited_scene_root()
if not root:
accept_dialog.dialog_text = "VRM Export can't be done without a scene."
accept_dialog.ok_button_text = "OK"
get_editor_interface().popup_dialog_centered(accept_dialog)
return
selected_nodes = get_editor_interface().get_selection().get_selected_nodes()
for selnode in selected_nodes:
if selnode.script != vrm_top_level:
var bleh: Array[Node]
selected_nodes = bleh
break
var next_filename: String = ""
if selected_nodes.is_empty():
var bleh: Array[Node]
selected_nodes = bleh
selected_nodes.append(get_tree().get_edited_scene_root())
var filename: String = root.get_scene_file_path().get_file().get_basename()
if filename.is_empty():
filename = root.get_name()
next_filename = filename + ".vrm"
for node in selected_nodes:
if node.script != vrm_top_level:
accept_dialog.dialog_text = "VRM Export requires the selected or top-level node to contain a vrm_top_level script with meta."
accept_dialog.ok_button_text = "OK"
get_editor_interface().popup_dialog_centered(accept_dialog)
selected_nodes.clear()
break
var meta: vrm_meta_class = node.vrm_meta
var failed_validate: PackedStringArray = VRMC_vrm._validate_meta(meta)
if node.vrm_meta == null:
node.vrm_meta = vrm_meta_class.new()
if not failed_validate.is_empty():
var res_path = meta.resource_path.split("::")[0]
if not res_path.is_empty() and (not res_path.begins_with("res://") or FileAccess.file_exists(res_path + ".import")):
node.vrm_meta = node.vrm_meta.duplicate(true)
get_editor_interface().inspect_object(node.vrm_meta)
accept_dialog.dialog_text = "VRM Export requires filling out license dropdowns and basic data:\n" + ",".join(failed_validate) + "\n\nExpand CLICK TO SEE METADATA in the Inspector"
accept_dialog.ok_button_text = "OK"
get_editor_interface().popup_dialog_centered(accept_dialog)
selected_nodes.clear()
break
_popup_next_node_export(next_filename)
func _popup_next_node_export(next_filename: String = ""):
if not selected_nodes.is_empty():
current_export_node = selected_nodes[-1]
selected_nodes.pop_back()
var filename: String = next_filename
if filename.is_empty():
filename = current_export_node.get_name()
file_export_lib.set_current_file(filename + ".vrm")
file_export_lib.popup_centered_ratio()
func reassign_owner(new_owner: Node, orig_node: Node, node: Node):
if node == new_owner or orig_node == null:
pass
elif orig_node.owner == null:
node.get_parent().remove_child(node)
node.queue_free()
return
else:
node.owner = new_owner
#print(node.name + " assigned to owner " + node.owner.name)
#print("Node " + str(node.name) + " orig child " + str(orig_node.get_child_count()) + " new child " + str(node.get_child_count()))
for i in range(node.get_child_count() - 1, -1, -1):
reassign_owner(new_owner, orig_node.get_child(i), node.get_child(i))
func remove_internals(node: Node, to_reparent: Dictionary):
for chld in node.get_children():
if chld.owner == null:
to_reparent[chld] = node
node.remove_child(chld)
else:
remove_internals(chld, to_reparent)
func _export_vrm_dialog_action(path: String):
var root: Node = current_export_node
current_export_node = null
if root.script != vrm_top_level:
accept_dialog.dialog_text = "VRM Export requires the selected or top-level node to contain a vrm_top_level script with meta."
accept_dialog.ok_button_text = "OK"
get_editor_interface().popup_dialog_centered(accept_dialog)
return
print("Before duplicate")
var to_reparent: Dictionary = {}
# Before:
# ERROR: Index p_index = 4 is out of bounds ((int)data.children_cache.size() - data.internal_children_front_count_cache - data.internal_children_back_count_cache = 3).
# at: Node::get_child (scene\main\node.cpp:1511)
remove_internals(root, to_reparent)
var new_root: Node = root.duplicate(Node.DUPLICATE_SIGNALS | Node.DUPLICATE_GROUPS | Node.DUPLICATE_SCRIPTS)
for chld in to_reparent:
to_reparent[chld].add_child(chld)
reassign_owner(new_root, root, new_root)
root = new_root
print("After duplicate")
var secondary := root.get_node_or_null("secondary") as Node3D
if secondary == null:
secondary = Node3D.new()
secondary.owner = root
root.add_child(secondary)
if secondary.script == null:
secondary.script = vrm_secondary
var gltf_doc := GLTFDocument.new()
gltf_doc.set(&"root_node_mode", 2) # GLTFDocument.ROOT_NODE_MODE_MULTI_ROOT
var gltf_state := GLTFState.new()
gltf_state.set_meta("vrm", "1.0")
var flags := EditorSceneFormatImporter.IMPORT_USE_NAMED_SKIN_BINDS
print("Do append_from_scene")
if gltf_doc.append_from_scene(root, gltf_state, flags) != OK:
push_error("VRM scene save error!")
print("Do write_to_filesystem")
var tmp_filename: String = path + ".tmp.glb"
if gltf_doc.write_to_filesystem(gltf_state, tmp_filename) != OK:
push_error("VRM scene save error!")
var da: DirAccess = DirAccess.open("res://")
da.rename(tmp_filename, path)
print("All done!")
root.queue_free()
_popup_next_node_export()
print(path)
if ProjectSettings.localize_path(path) != "":
get_editor_interface().get_resource_filesystem().update_file(path)
get_editor_interface().get_resource_filesystem().reimport_files(PackedStringArray([path]))
func _enter_tree() -> void:
accept_dialog = AcceptDialog.new()
if accept_dialog.has_method(&"set_unparent_when_invisible"):
accept_dialog.set_unparent_when_invisible(true)
file_export_lib = EditorFileDialog.new()
# get_gui_base().
get_editor_interface().get_base_control().add_child(file_export_lib)
file_export_lib.file_selected.connect(_export_vrm_dialog_action)
file_export_lib.set_title("Export VRM File")
file_export_lib.set_file_mode(EditorFileDialog.FILE_MODE_SAVE_FILE)
file_export_lib.set_access(EditorFileDialog.ACCESS_FILESYSTEM)
file_export_lib.clear_filters()
file_export_lib.add_filter("*.vrm")
file_export_lib.set_title("Export Scene to VRM 1.0 File")
var export_as_menu: PopupMenu = get_export_as_menu()
if export_as_menu != null:
for i in range(export_as_menu.item_count - 1, -1, -1):
if export_as_menu.get_item_text(i) == export_as_item:
export_as_menu.remove_item(i)
export_as_menu.add_item(export_as_item, export_as_id, KEY_V)
export_as_menu.set_item_metadata(export_as_menu.item_count - 1, _export_vrm_pressed)
add_scene_post_import_plugin(vrm_options_post_import_plugin_inst)
# NOTE: Be sure to also register at runtime if you want runtime import.
# This editor plugin script won't run outside of the editor.
GLTFDocument.register_gltf_document_extension(VRMC_vrm_inst)
GLTFDocument.register_gltf_document_extension(VRMC_node_constraint_inst)
GLTFDocument.register_gltf_document_extension(VRMC_springBone_inst)
GLTFDocument.register_gltf_document_extension(VRMC_materials_hdr_emissiveMultiplier_inst)
GLTFDocument.register_gltf_document_extension(VRMC_materials_mtoon_inst)
#GLTFDocument.register_gltf_document_extension(VRMC_vrm_animation_inst)
import_plugin = preload("./import_vrm.gd").new()
add_scene_format_importer_plugin(import_plugin)
func _exit_tree() -> void:
accept_dialog.queue_free()
file_export_lib.queue_free()
var export_as_menu: PopupMenu = get_export_as_menu()
if export_as_menu != null:
for i in range(export_as_menu.item_count - 1, -1, -1):
if export_as_menu.get_item_text(i) == export_as_item:
export_as_menu.remove_item(i)
GLTFDocument.unregister_gltf_document_extension(VRMC_vrm_inst)
GLTFDocument.unregister_gltf_document_extension(VRMC_node_constraint_inst)
GLTFDocument.unregister_gltf_document_extension(VRMC_springBone_inst)
GLTFDocument.unregister_gltf_document_extension(VRMC_materials_mtoon_inst)
GLTFDocument.unregister_gltf_document_extension(VRMC_materials_hdr_emissiveMultiplier_inst)
#GLTFDocument.unregister_gltf_document_extension(VRMC_vrm_animation_inst)
remove_scene_format_importer_plugin(import_plugin)
remove_scene_post_import_plugin(vrm_options_post_import_plugin_inst)
import_plugin = null
@@ -0,0 +1 @@
uid://ypkxojghtx27
@@ -0,0 +1,214 @@
@tool
class_name VRMCollider
extends Resource
# Bone name references are only valid within the given Skeleton.
# If the node was not a skeleton, bone is "" and contains a path to the node.
@export var node_path: NodePath:
set(value):
node_path = value
recreate_collider.emit()
# The bone within the skeleton with the collider, or "" if not a bone.
@export var bone: String:
set(value):
bone = value
emit_changed()
@export var offset: Vector3:
set(value):
offset = value
emit_changed()
@export var tail: Vector3: # if is_capsule
set(value):
tail = value
emit_changed()
@export var radius: float:
set(value):
radius = value
emit_changed()
@export var is_capsule: bool = false:
set(value):
if value != is_capsule:
is_capsule = value
recreate_collider.emit()
# (Array, Plane)
# Only use in editor
@export var gizmo_color: Color = Color.MAGENTA
signal recreate_collider
func create_runtime(secondary_node: Node3D, skeleton: Skeleton3D) -> VrmRuntimeCollider:
var node: Node3D = null
var bone_idx: int = -1
if node_path != NodePath():
node = secondary_node.get_node(node_path)
if node == null and bone != "":
bone_idx = skeleton.find_bone(bone)
if node == null and bone_idx == -1:
push_warning("spring collider: Unable to locate bone " + str(bone) + " or node " + str(node_path))
node = secondary_node
if is_capsule:
return CapsuleCollider.new(self, bone_idx, node)
else:
return SphereCollider.new(self, bone_idx, node)
#func _ready(ready_parent: Node3D, ready_skel: Object):
# self.parent = ready_parent
# if ready_parent.get_class() == "Skeleton3D":
# self.skel = ready_skel
# bone_idx = ready_parent.find_bone(bone)
# setup()
#func _process():
# for collider in colliders:
# collider.update(parent, skel)
class VrmRuntimeCollider:
var collider: VRMCollider
var bone_idx: int
var node: Node3D
var offset: Vector3
var radius: float
var position: Vector3
var gizmo_color: Color
func _init(p_collider: VRMCollider, p_bone_idx: int, p_node: Node3D):
bone_idx = bone_idx
node = p_node
collider = p_collider
collider.changed.connect(init)
init()
func init():
bone_idx = -1
offset = collider.offset
radius = collider.radius
func update(skel_global_xform_inv: Transform3D, center_transform: Transform3D, skel: Skeleton3D):
if node == null and bone_idx == -1:
bone_idx = skel.find_bone(collider.bone)
if bone_idx != -1:
position = center_transform * (skel.get_bone_global_pose(bone_idx) * offset)
else: # if node != null:
position = center_transform * skel_global_xform_inv * node.global_transform * offset
func collision(bone_position: Vector3, bone_radius: float, bone_length: float, out: Vector3, position_offset: Vector3 = Vector3.ZERO) -> Vector3:
var this_position = self.position + position_offset
var r = bone_radius + self.radius
if r <= 0:
return out
var diff: Vector3 = out - this_position
if diff.length_squared() <= r * r:
# Hit, move to orientation of normal
var normal: Vector3 = (out - this_position).normalized()
var pos_from_collider = this_position + normal * (bone_radius + self.radius)
# Limiting bone length
##print("Collision hit! " + str(pos_from_collider - bone_position) + " at " + str(bone_length) + ": " + str((pos_from_collider - bone_position).normalized()) + " -> " + str((pos_from_collider - bone_position).normalized() * bone_length))
out = bone_position + (pos_from_collider - bone_position).normalized() * bone_length
# out = out + 1.0 * (pos_from_collider - bone_position).normalized() * bone_length
return out
class SphereCollider:
extends VrmRuntimeCollider
func draw_debug(p_mesh: ImmediateMesh, p_center_transform_inv: Transform3D) -> void:
var step: int = 15
var sppi: float = 2 * PI / step
var center: Vector3 = p_center_transform_inv * self.position
var bas: Basis = p_center_transform_inv.basis
for i in range(1, step + 1):
p_mesh.surface_set_color(self.gizmo_color)
p_mesh.surface_add_vertex(center + ((bas * Vector3.UP * self.radius).rotated(bas * Vector3.RIGHT, sppi * ((i - 1) % step))))
p_mesh.surface_set_color(self.gizmo_color)
p_mesh.surface_add_vertex(center + ((bas * Vector3.UP * self.radius).rotated(bas * Vector3.RIGHT, sppi * (i % step))))
for i in range(1, step + 1):
p_mesh.surface_set_color(self.gizmo_color)
p_mesh.surface_add_vertex(center + ((bas * Vector3.RIGHT * self.radius).rotated(bas * Vector3.FORWARD, sppi * ((i - 1) % step))))
p_mesh.surface_set_color(self.gizmo_color)
p_mesh.surface_add_vertex(center + ((bas * Vector3.RIGHT * self.radius).rotated(bas * Vector3.FORWARD, sppi * (i % step))))
for i in range(1, step + 1):
p_mesh.surface_set_color(self.gizmo_color)
p_mesh.surface_add_vertex(center + ((bas * Vector3.FORWARD * self.radius).rotated(bas * Vector3.UP, sppi * ((i - 1) % step))))
p_mesh.surface_set_color(self.gizmo_color)
p_mesh.surface_add_vertex(center + ((bas * Vector3.FORWARD * self.radius).rotated(bas * Vector3.UP, sppi * (i % step))))
class CapsuleCollider:
extends VrmRuntimeCollider
var tail_offset: Vector3
var tail_position: Vector3
func init():
super.init()
tail_offset = collider.tail
func update(p_skel_global_xform_inv: Transform3D, p_center_transform: Transform3D, p_skel: Skeleton3D):
if node == null and bone_idx == -1:
bone_idx = p_skel.find_bone(collider.bone)
if bone_idx != -1:
position = p_center_transform * (p_skel.get_bone_global_pose(bone_idx) * offset)
tail_position = p_center_transform * (p_skel.get_bone_global_pose(bone_idx) * tail_offset)
else: # if node != null
position = p_center_transform * p_skel_global_xform_inv * node.global_transform * offset
tail_position = p_center_transform * p_skel_global_xform_inv * node.global_transform * tail_offset
func collision(p_bone_position: Vector3, p_bone_radius: float, p_bone_length: float, p_out: Vector3, p_position_offset: Vector3 = Vector3.ZERO) -> Vector3:
var P: Vector3 = tail_position - position
var Q: Vector3 = p_bone_position - position - p_position_offset
var dot = P.dot(Q)
if dot <= 0:
return super.collision(p_bone_position, p_bone_radius, p_bone_length, p_out, p_position_offset)
var t: float = dot / P.length()
if t >= 1.0:
return super.collision(p_bone_position, p_bone_radius, p_bone_length, p_out, p_position_offset + P)
return super.collision(p_bone_position, p_bone_radius, p_bone_length, p_out, p_position_offset + P * t)
func draw_debug(mesh: ImmediateMesh, center_transform_inv: Transform3D) -> void:
var step: int = 15
var sppi: float = 2 * PI / step
var center: Vector3 = center_transform_inv * self.position
var tail: Vector3 = center_transform_inv * self.tail_position
var bas: Basis = center_transform_inv.basis
var up_axis: Vector3 = (tail - position).normalized()
if up_axis.is_equal_approx(Vector3.ZERO):
up_axis = Vector3(0, 1, 0)
var right_axis: Vector3 #= up_axis.cross(Vector3.RIGHT).normalized()
if abs(up_axis.dot(Vector3.RIGHT)) < 0.8:
right_axis = up_axis.cross(Vector3.RIGHT).normalized()
elif abs(up_axis.dot(Vector3.FORWARD)) < 0.8:
right_axis = up_axis.cross(Vector3.FORWARD).normalized()
else:
right_axis = up_axis.cross(Vector3.UP).normalized()
var forward_axis: Vector3 = up_axis.cross(right_axis).normalized()
right_axis = forward_axis.cross(up_axis).normalized()
for i in range(1, step + 1):
mesh.surface_set_color(self.gizmo_color)
mesh.surface_add_vertex((center if i - 1 < step / 2 else tail) + ((bas * up_axis * self.radius).rotated(bas * right_axis, PI / 2 + sppi * ((i - 1) % step))))
mesh.surface_set_color(self.gizmo_color)
mesh.surface_add_vertex((center if i < step / 2 or i == step else tail) + ((bas * up_axis * self.radius).rotated(bas * right_axis, PI / 2 + sppi * (i % step))))
for i in range(1, step + 1):
mesh.surface_set_color(self.gizmo_color)
mesh.surface_add_vertex((center if i - 1 < step / 2 else tail) + ((bas * right_axis * self.radius).rotated(bas * forward_axis, PI / 2 + sppi * ((i - 1) % step))))
mesh.surface_set_color(self.gizmo_color)
mesh.surface_add_vertex((center if i < step / 2 or i == step else tail) + ((bas * right_axis * self.radius).rotated(bas * forward_axis, PI / 2 + sppi * (i % step))))
for i in range(1, step + 1):
mesh.surface_set_color(self.gizmo_color)
mesh.surface_add_vertex(center + ((bas * forward_axis * self.radius).rotated(bas * up_axis, sppi * ((i - 1) % step))))
mesh.surface_set_color(self.gizmo_color)
mesh.surface_add_vertex(center + ((bas * forward_axis * self.radius).rotated(bas * up_axis, sppi * (i % step))))
for i in range(1, step + 1):
mesh.surface_set_color(self.gizmo_color)
mesh.surface_add_vertex(tail + ((bas * forward_axis * self.radius).rotated(bas * up_axis, sppi * ((i - 1) % step))))
mesh.surface_set_color(self.gizmo_color)
mesh.surface_add_vertex(tail + ((bas * forward_axis * self.radius).rotated(bas * up_axis, sppi * (i % step))))
@@ -0,0 +1 @@
uid://drf7ihlxgty0d
@@ -0,0 +1,8 @@
@tool
class_name VRMColliderGroup
extends Resource
const vrm_collider = preload("./vrm_collider.gd")
# For organizational purposes only. At runtime, all colliders can be combined.
@export var colliders: Array[vrm_collider]
@@ -0,0 +1 @@
uid://2sgb2b08g57x
@@ -0,0 +1,79 @@
extends RefCounted
const vrm_to_human_bone: Dictionary = {
"hips": "Hips",
"spine": "Spine",
"chest": "Chest",
"upperChest": "UpperChest",
"neck": "Neck",
"head": "Head",
"leftEye": "LeftEye",
"rightEye": "RightEye",
"jaw": "Jaw",
"leftShoulder": "LeftShoulder",
"leftUpperArm": "LeftUpperArm",
"leftLowerArm": "LeftLowerArm",
"leftHand": "LeftHand",
"leftThumbMetacarpal": "LeftThumbMetacarpal",
"leftThumbProximal": "LeftThumbProximal",
"leftThumbDistal": "LeftThumbDistal",
"leftIndexProximal": "LeftIndexProximal",
"leftIndexIntermediate": "LeftIndexIntermediate",
"leftIndexDistal": "LeftIndexDistal",
"leftMiddleProximal": "LeftMiddleProximal",
"leftMiddleIntermediate": "LeftMiddleIntermediate",
"leftMiddleDistal": "LeftMiddleDistal",
"leftRingProximal": "LeftRingProximal",
"leftRingIntermediate": "LeftRingIntermediate",
"leftRingDistal": "LeftRingDistal",
"leftLittleProximal": "LeftLittleProximal",
"leftLittleIntermediate": "LeftLittleIntermediate",
"leftLittleDistal": "LeftLittleDistal",
"rightShoulder": "RightShoulder",
"rightUpperArm": "RightUpperArm",
"rightLowerArm": "RightLowerArm",
"rightHand": "RightHand",
"rightThumbMetacarpal": "RightThumbMetacarpal",
"rightThumbProximal": "RightThumbProximal",
"rightThumbDistal": "RightThumbDistal",
"rightIndexProximal": "RightIndexProximal",
"rightIndexIntermediate": "RightIndexIntermediate",
"rightIndexDistal": "RightIndexDistal",
"rightMiddleProximal": "RightMiddleProximal",
"rightMiddleIntermediate": "RightMiddleIntermediate",
"rightMiddleDistal": "RightMiddleDistal",
"rightRingProximal": "RightRingProximal",
"rightRingIntermediate": "RightRingIntermediate",
"rightRingDistal": "RightRingDistal",
"rightLittleProximal": "RightLittleProximal",
"rightLittleIntermediate": "RightLittleIntermediate",
"rightLittleDistal": "RightLittleDistal",
"leftUpperLeg": "LeftUpperLeg",
"leftLowerLeg": "LeftLowerLeg",
"leftFoot": "LeftFoot",
"leftToes": "LeftToes",
"rightUpperLeg": "RightUpperLeg",
"rightLowerLeg": "RightLowerLeg",
"rightFoot": "RightFoot",
"rightToes": "RightToes",
}
static func get_vrm_to_human_bone(is_vrm_0) -> Dictionary:
if is_vrm_0:
var vrm0_to_human_bone = vrm_to_human_bone.duplicate()
vrm0_to_human_bone["leftThumbIntermediate"] = "LeftThumbProximal"
vrm0_to_human_bone["leftThumbProximal"] = "LeftThumbMetacarpal"
vrm0_to_human_bone["rightThumbIntermediate"] = "RightThumbProximal"
vrm0_to_human_bone["rightThumbProximal"] = "RightThumbMetacarpal"
return vrm0_to_human_bone
return vrm_to_human_bone
enum HeadHidingSetting {
ThirdPersonOnly = 0,
FirstPersonOnly = 1,
FirstPersonOnlyWithShadow = 2,
BothLayers = 3,
BothLayersWithShadow = 4,
IgnoreHeadHiding = 5,
}
@@ -0,0 +1 @@
uid://difdluwlv5dk1
@@ -0,0 +1,978 @@
extends GLTFDocumentExtension
const vrm_constants_class = preload("./vrm_constants.gd")
const vrm_meta_class = preload("./vrm_meta.gd")
const vrm_secondary = preload("./vrm_secondary.gd")
const vrm_collider_group = preload("./vrm_collider_group.gd")
const vrm_collider = preload("./vrm_collider.gd")
const vrm_spring_bone = preload("./vrm_spring_bone.gd")
const vrm_top_level = preload("./vrm_toplevel.gd")
const importer_mesh_attributes = preload("./importer_mesh_attributes.gd")
const vrm_utils = preload("./vrm_utils.gd")
var vrm_meta: Resource = null
enum DebugMode {
None = 0,
Normal = 1,
LitShadeRate = 2,
}
enum OutlineColorMode {
FixedColor = 0,
MixedLight3Ding = 1,
}
enum OutlineWidthMode {
None = 0,
WorldCoordinates = 1,
ScreenCoordinates = 2,
}
enum RenderMode {
Opaque = 0,
Cutout = 1,
Transparent = 2,
TransparentWithZWrite = 3,
}
enum CullMode {
Off = 0,
Front = 1,
Back = 2,
}
enum FirstPersonFlag {
Auto, # Create headlessModel
Both, # Default layer
ThirdPersonOnly,
FirstPersonOnly,
}
const FirstPersonParser: Dictionary = {
"Auto": FirstPersonFlag.Auto,
"Both": FirstPersonFlag.Both,
"FirstPersonOnly": FirstPersonFlag.FirstPersonOnly,
"ThirdPersonOnly": FirstPersonFlag.ThirdPersonOnly,
}
func _process_khr_material(orig_mat: StandardMaterial3D, gltf_mat_props: Dictionary) -> Material:
# VRM spec requires support for the KHR_materials_unlit extension.
if gltf_mat_props.has("extensions"):
# TODO: Implement this extension upstream.
if gltf_mat_props["extensions"].has("KHR_materials_unlit"):
# TODO: validate that this is sufficient.
orig_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
return orig_mat
func _vrm_get_texture_info(gstate: GLTFState, vrm_mat_props: Dictionary, unity_tex_name: String) -> Dictionary:
var gltf_images: Array = gstate.get_images()
var gltf_textures: Array = gstate.get_textures()
var texture_info: Dictionary = {}
texture_info["tex"] = null
texture_info["offset"] = Vector3(0.0, 0.0, 0.0)
texture_info["scale"] = Vector3(1.0, 1.0, 1.0)
if vrm_mat_props["textureProperties"].has(unity_tex_name):
var mainTexId: int = vrm_mat_props["textureProperties"][unity_tex_name]
var mainTexImageId = gltf_textures[mainTexId].src_image
var mainTexImage: Texture2D = gltf_images[mainTexImageId]
texture_info["tex"] = mainTexImage
if vrm_mat_props["vectorProperties"].has(unity_tex_name):
var offsetScale: Array = vrm_mat_props["vectorProperties"][unity_tex_name]
texture_info["offset"] = Vector3(offsetScale[0], offsetScale[1], 0.0)
texture_info["scale"] = Vector3(offsetScale[2], offsetScale[3], 1.0)
return texture_info
func _vrm_get_float(vrm_mat_props: Dictionary, key: String, def: float) -> float:
return vrm_mat_props["floatProperties"].get(key, def)
func _process_vrm_material(orig_mat: Material, gstate: GLTFState, vrm_mat_props: Dictionary) -> Material:
var gltf_images: Array = gstate.get_images()
var gltf_textures: Array = gstate.get_textures()
var vrm_shader_name: String = vrm_mat_props["shader"]
if vrm_shader_name == "VRM_USE_GLTFSHADER":
return orig_mat # It's already correct!
if vrm_shader_name == "Standard" or vrm_shader_name == "UniGLTF/UniUnlit":
printerr("Unsupported legacy VRM shader " + vrm_shader_name + " on material " + str(orig_mat.resource_name))
return orig_mat
var maintex_info: Dictionary = _vrm_get_texture_info(gstate, vrm_mat_props, "_MainTex")
if vrm_shader_name == "VRM/UnlitTransparentZWrite" or vrm_shader_name == "VRM/UnlitTransparent" or vrm_shader_name == "VRM/UnlitTexture" or vrm_shader_name == "VRM/UnlitCutout":
if maintex_info["tex"] != null:
orig_mat.albedo_texture = maintex_info["tex"]
orig_mat.uv1_offset = maintex_info["offset"]
orig_mat.uv1_scale = maintex_info["scale"]
orig_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
if vrm_shader_name == "VRM/UnlitTransparentZWrite":
orig_mat.depth_draw_mode = StandardMaterial3D.DEPTH_DRAW_ALWAYS
orig_mat.no_depth_test = false
if vrm_shader_name == "VRM/UnlitTransparent" or vrm_shader_name == "VRM/UnlitTransparentZWrite":
orig_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
orig_mat.blend_mode = StandardMaterial3D.BLEND_MODE_MIX
if vrm_shader_name == "VRM/UnlitCutout":
orig_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR
orig_mat.alpha_scissor_threshold = _vrm_get_float(vrm_mat_props, "_Cutoff", 0.5)
return orig_mat
if vrm_shader_name != "VRM/MToon":
printerr("Unknown VRM shader " + vrm_shader_name + " on material " + str(orig_mat.resource_name))
return orig_mat
# Enum(Off,0,Front,1,Back,2) _CullMode
var outline_width_mode = int(vrm_mat_props["floatProperties"].get("_OutlineWidthMode", 0))
var blend_mode = int(vrm_mat_props["floatProperties"].get("_BlendMode", 0))
var cull_mode = int(vrm_mat_props["floatProperties"].get("_CullMode", 2))
var outl_cull_mode = int(vrm_mat_props["floatProperties"].get("_OutlineCullMode", 1))
if cull_mode == int(CullMode.Front) || (outl_cull_mode != int(CullMode.Front) && outline_width_mode != int(OutlineWidthMode.None)):
printerr("VRM Material " + str(orig_mat.resource_name) + " has unsupported front-face culling mode: " + str(cull_mode) + "/" + str(outl_cull_mode))
var mtoon_shader_base_path = "res://addons/Godot-MToon-Shader/mtoon"
var godot_outline_shader_name = null
if outline_width_mode != int(OutlineWidthMode.None):
godot_outline_shader_name = mtoon_shader_base_path + "_outline"
var godot_shader_name = mtoon_shader_base_path
if blend_mode == int(RenderMode.Opaque):
if cull_mode == int(CullMode.Off):
godot_shader_name = mtoon_shader_base_path + "_cull_off"
if blend_mode == int(RenderMode.Cutout):
godot_shader_name = mtoon_shader_base_path + "_cutout"
if cull_mode == int(CullMode.Off):
godot_shader_name = mtoon_shader_base_path + "_cutout_cull_off"
if godot_outline_shader_name:
godot_outline_shader_name += "_cutout"
elif blend_mode == int(RenderMode.Transparent):
godot_shader_name = mtoon_shader_base_path + "_trans"
if cull_mode == int(CullMode.Off):
godot_shader_name = mtoon_shader_base_path + "_trans_cull_off"
if godot_outline_shader_name:
godot_outline_shader_name += "_trans"
elif blend_mode == int(RenderMode.TransparentWithZWrite):
godot_shader_name = mtoon_shader_base_path + "_trans_zwrite"
if cull_mode == int(CullMode.Off):
godot_shader_name = mtoon_shader_base_path + "_trans_zwrite_cull_off"
if godot_outline_shader_name:
godot_outline_shader_name += "_trans_zwrite"
var godot_shader: Shader = ResourceLoader.load(godot_shader_name + ".gdshader")
var godot_shader_outline: Shader = null
if godot_outline_shader_name:
godot_shader_outline = ResourceLoader.load(godot_outline_shader_name + ".gdshader")
var new_mat: ShaderMaterial = ShaderMaterial.new()
new_mat.resource_name = orig_mat.resource_name
new_mat.shader = godot_shader
var outline_mat: ShaderMaterial = null
if godot_shader_outline == null:
new_mat.next_pass = null
else:
outline_mat = ShaderMaterial.new()
outline_mat.resource_name = orig_mat.resource_name + "_Outline"
outline_mat.shader = godot_shader_outline
new_mat.next_pass = outline_mat
var texture_repeat = Vector4(maintex_info["scale"].x, maintex_info["scale"].y, maintex_info["offset"].x, maintex_info["offset"].y)
new_mat.set_shader_parameter("_MainTex_ST", texture_repeat)
if outline_mat != null:
outline_mat.set_shader_parameter("_MainTex_ST", texture_repeat)
for param_name in ["_MainTex", "_ShadeTexture", "_BumpMap", "_RimTexture", "_SphereAdd", "_EmissionMap", "_OutlineWidthTexture", "_UvAnimMaskTexture"]:
var tex_info: Dictionary = _vrm_get_texture_info(gstate, vrm_mat_props, param_name)
if tex_info.get("tex", null) != null:
new_mat.set_shader_parameter(param_name, tex_info["tex"])
if outline_mat != null:
outline_mat.set_shader_parameter(param_name, tex_info["tex"])
if param_name == "_SphereAdd":
new_mat.set_shader_parameter("_MatcapColor", Color(1.0, 1.0, 1.0, 1.0))
if outline_mat != null:
outline_mat.set_shader_parameter("_MatcapColor", Color(1.0, 1.0, 1.0, 1.0))
for param_name in vrm_mat_props["floatProperties"]:
new_mat.set_shader_parameter(param_name, vrm_mat_props["floatProperties"][param_name])
if outline_mat != null:
outline_mat.set_shader_parameter(param_name, vrm_mat_props["floatProperties"][param_name])
for param_name in ["_Color", "_ShadeColor", "_RimColor", "_EmissionColor", "_OutlineColor"]:
if param_name in vrm_mat_props["vectorProperties"]:
var param_val = vrm_mat_props["vectorProperties"][param_name]
# TODO: Use Color for non-HDR color slots (_Color, _ShadeColor and _OutlineColor?)
# Or, use Color for all, and split _EmissionColor into emission color and emission strength.
var color_param: Color = Color(param_val[0], param_val[1], param_val[2], param_val[3])
if param_name == "_RimColor": # Marked [HDR] in MToon.shader
color_param = color_param.linear_to_srgb()
if param_name == "_EmissionColor":
var mult = maxf(color_param.r, maxf(color_param.g, color_param.b))
var emission_mult = 1.0
if mult > 1.0:
emission_mult = mult
color_param = color_param / mult
color_param = color_param.linear_to_srgb()
new_mat.set_shader_parameter("_EmissionMultiplier", emission_mult)
if outline_mat != null:
outline_mat.set_shader_parameter("_EmissionMultiplier", emission_mult)
new_mat.set_shader_parameter(param_name, color_param)
if outline_mat != null:
outline_mat.set_shader_parameter(param_name, color_param)
# FIXME: setting _Cutoff to disable cutoff is a bit unusual.
if blend_mode == int(RenderMode.Cutout):
new_mat.set_shader_parameter("_AlphaCutoutEnable", 1.0)
if outline_mat != null:
outline_mat.set_shader_parameter("_AlphaCutoutEnable", 1.0)
return new_mat
func _update_materials(vrm_extension: Dictionary, gstate: GLTFState) -> void:
var images = gstate.get_images()
#print(images)
var materials: Array = gstate.get_materials()
var spatial_to_shader_mat: Dictionary = {}
# Render priority setup
var render_queue_to_priority: Array = []
var negative_render_queue_to_priority: Array = []
var uniq_render_queues: Dictionary = {}
negative_render_queue_to_priority.push_back(0)
render_queue_to_priority.push_back(0)
uniq_render_queues[0] = true
for i in range(materials.size()):
var oldmat: Material = materials[i]
var vrm_mat: Dictionary = vrm_extension["materialProperties"][i]
var delta_render_queue = vrm_mat.get("renderQueue", 3000) - 3000
if not uniq_render_queues.has(delta_render_queue):
uniq_render_queues[delta_render_queue] = true
if delta_render_queue < 0:
negative_render_queue_to_priority.push_back(-delta_render_queue)
else:
render_queue_to_priority.push_back(delta_render_queue)
negative_render_queue_to_priority.sort()
render_queue_to_priority.sort()
# Material conversions
for i in range(materials.size()):
var oldmat: Material = materials[i]
if oldmat is ShaderMaterial:
# Indicates that the user asked to keep existing materials. Avoid changing them.
# print("Material " + str(i) + ": " + str(oldmat.resource_name) + " already is shader.")
continue
var newmat: Material = _process_khr_material(oldmat, gstate.json["materials"][i])
var vrm_mat_props: Dictionary = vrm_extension["materialProperties"][i]
newmat = _process_vrm_material(newmat, gstate, vrm_mat_props)
spatial_to_shader_mat[oldmat] = newmat
spatial_to_shader_mat[newmat] = newmat
# print("Replacing shader " + str(oldmat) + "/" + str(oldmat.resource_name) + " with " + str(newmat) + "/" + str(newmat.resource_name))
var target_render_priority = 0
var delta_render_queue = vrm_mat_props.get("renderQueue", 3000) - 3000
if delta_render_queue >= 0:
target_render_priority = render_queue_to_priority.find(delta_render_queue)
if target_render_priority > 100:
target_render_priority = 100
else:
target_render_priority = -negative_render_queue_to_priority.find(-delta_render_queue)
if target_render_priority < -100:
target_render_priority = -100
# render_priority only makes sense for transparent materials.
if newmat.get_class() == "StandardMaterial3D":
if int(newmat.transparency) > 0:
newmat.render_priority = target_render_priority
else:
var blend_mode = int(vrm_mat_props["floatProperties"].get("_BlendMode", 0))
if blend_mode == int(RenderMode.Transparent) or blend_mode == int(RenderMode.TransparentWithZWrite):
newmat.render_priority = target_render_priority
materials[i] = newmat
var oldpath = oldmat.resource_path
if oldpath.is_empty():
continue
newmat.take_over_path(oldpath)
ResourceSaver.save(newmat, oldpath)
gstate.set_materials(materials)
var meshes = gstate.get_meshes()
for i in range(meshes.size()):
var gltfmesh: GLTFMesh = meshes[i]
var mesh = gltfmesh.mesh
mesh.set_blend_shape_mode(Mesh.BLEND_SHAPE_MODE_NORMALIZED)
for surf_idx in range(mesh.get_surface_count()):
var surfmat = mesh.get_surface_material(surf_idx)
if spatial_to_shader_mat.has(surfmat):
mesh.set_surface_material(surf_idx, spatial_to_shader_mat[surfmat])
else:
printerr("Mesh " + str(i) + " material " + str(surf_idx) + " name " + str(surfmat.resource_name) + " has no replacement material.")
func _get_skel_godot_node(gstate: GLTFState, nodes: Array, skeletons: Array, skel_id: int) -> Node:
# There's no working direct way to convert from skeleton_id to node_id.
# Bugs:
# GLTFNode.parent is -1 if skeleton bone.
# skeleton_to_node is empty
# get_scene_node(skeleton bone) works though might maybe return an attachment.
# var skel_node_idx = nodes[gltfskel.roots[0]]
# return gstate.get_scene_node(skel_node_idx) # as Skeleton
for i in range(nodes.size()):
if nodes[i].skeleton == skel_id:
return gstate.get_scene_node(i)
return null
func _first_person_head_hiding(vrm_extension: Dictionary, gstate: GLTFState, human_bone_to_idx: Dictionary):
var firstperson = vrm_extension.get("firstPerson", null)
var nodes := gstate.get_nodes()
var skeletons := gstate.get_skeletons()
var node_to_head_hidden_node: Dictionary = {}
var head_relative_bones: Dictionary = {} # To determine which meshes to hide.
var head_bone_idx = firstperson.get("firstPersonBone", human_bone_to_idx.get("head", -1))
if head_bone_idx >= 0:
var headNode: GLTFNode = nodes[head_bone_idx]
var skel: Skeleton3D = _get_skel_godot_node(gstate, nodes, skeletons, headNode.skeleton)
vrm_utils._recurse_bones(head_relative_bones, skel, skel.find_bone(headNode.resource_name)) # FIXME: I forget if this is correct
var mesh_annotations_by_mesh = {}
for meshannotation in firstperson.get("meshAnnotations", []):
var s: String = meshannotation.get("firstPersonFlag", "Auto")
mesh_annotations_by_mesh[int(meshannotation["mesh"])] = s.substr(0, 1).to_lower() + s.substr(1)
var mesh_annotations_by_node = {}
for node_idx in range(len(nodes)):
if nodes[node_idx].mesh != -1 and mesh_annotations_by_mesh.has(nodes[node_idx].mesh):
mesh_annotations_by_node[node_idx] = mesh_annotations_by_mesh[nodes[node_idx].mesh]
vrm_utils.perform_head_hiding(gstate, mesh_annotations_by_node, head_relative_bones, node_to_head_hidden_node)
# https://github.com/vrm-c/vrm-specification/blob/master/specification/0.0/schema/vrm.humanoid.bone.schema.json
# vrm_extension["humanoid"]["bone"]:
#"enum": ["hips","leftUpperLeg","rightUpperLeg","leftLowerLeg","rightLowerLeg","leftFoot","rightFoot",
# "spine","chest","neck","head","leftShoulder","rightShoulder","leftUpperArm","rightUpperArm",
# "leftLowerArm","rightLowerArm","leftHand","rightHand","leftToes","rightToes","leftEye","rightEye","jaw",
# "leftThumbProximal","leftThumbIntermediate","leftThumbDistal",
# "leftIndexProximal","leftIndexIntermediate","leftIndexDistal",
# "leftMiddleProximal","leftMiddleIntermediate","leftMiddleDistal",
# "leftRingProximal","leftRingIntermediate","leftRingDistal",
# "leftLittleProximal","leftLittleIntermediate","leftLittleDistal",
# "rightThumbProximal","rightThumbIntermediate","rightThumbDistal",
# "rightIndexProximal","rightIndexIntermediate","rightIndexDistal",
# "rightMiddleProximal","rightMiddleIntermediate","rightMiddleDistal",
# "rightRingProximal","rightRingIntermediate","rightRingDistal",
# "rightLittleProximal","rightLittleIntermediate","rightLittleDistal", "upperChest"]
func _create_meta(root_node: Node, animplayer: AnimationPlayer, vrm_extension: Dictionary, gstate: GLTFState, skeleton: Skeleton3D, humanBones: BoneMap, human_bone_to_idx: Dictionary, pose_diffs: Array[Basis]) -> Resource:
var nodes = gstate.get_nodes()
var firstperson = vrm_extension.get("firstPerson", null)
var eyeOffset: Vector3
if firstperson:
# FIXME: Technically this is supposed to be offset relative to the "firstPersonBone"
# However, firstPersonBone defaults to Head...
# and the semantics of a VR player having their viewpoint out of something which does
# not rotate with their head is unclear.
# Additionally, the spec schema says this:
# "It is assumed that an offset from the head bone to the VR headset is added."
# Which implies that the Head bone is used, not the firstPersonBone.
var fpboneoffsetxyz = firstperson["firstPersonBoneOffset"] # example: 0,0.06,0
eyeOffset = Vector3(fpboneoffsetxyz["x"], fpboneoffsetxyz["y"], fpboneoffsetxyz["z"])
if human_bone_to_idx["head"] != -1:
eyeOffset = pose_diffs[human_bone_to_idx["head"]] * eyeOffset
var head_attach: BoneAttachment3D = null
for child in skeleton.find_children("*", "BoneAttachment3D"):
var child_attach: BoneAttachment3D = child as BoneAttachment3D
if child_attach.bone_name == "Head":
head_attach = child_attach
break
if head_attach == null:
head_attach = BoneAttachment3D.new()
head_attach.name = "Head"
skeleton.add_child(head_attach)
head_attach.owner = skeleton.owner
head_attach.bone_name = "Head"
var head_bone_offset: Node3D = Node3D.new()
head_bone_offset.name = "LookOffset"
head_attach.add_child(head_bone_offset)
head_bone_offset.unique_name_in_owner = true
head_bone_offset.owner = skeleton.owner
head_bone_offset.position = eyeOffset
vrm_meta = vrm_meta_class.new()
vrm_meta.resource_name = "CLICK TO SEE METADATA"
vrm_meta.exporter_version = vrm_extension.get("exporterVersion", "")
if vrm_extension.get("specVersion", "0.0") != "0.0":
push_warning("VRM file claims to be version " + str(vrm_extension["specVersion"]))
vrm_meta.spec_version = "0.0"
var vrm_extension_meta = vrm_extension.get("meta")
if vrm_extension_meta:
vrm_meta.title = vrm_extension["meta"].get("title", "")
vrm_meta.version = vrm_extension["meta"].get("version", "")
vrm_meta.authors = PackedStringArray([vrm_extension["meta"].get("author", "")])
vrm_meta.contact_information = vrm_extension["meta"].get("contactInformation", "")
vrm_meta.references = PackedStringArray([vrm_extension["meta"].get("reference", "")])
var tex: int = vrm_extension["meta"].get("texture", -1)
if tex >= 0:
var gltftex: GLTFTexture = gstate.get_textures()[tex]
vrm_meta.thumbnail_image = gstate.get_images()[gltftex.src_image]
vrm_meta.allowed_user_name = vrm_extension["meta"].get("allowedUserName", "")
vrm_meta.violent_usage = vrm_extension["meta"].get("violentUssageName", "") # Ussage (sic.) in VRM spec
vrm_meta.sexual_usage = vrm_extension["meta"].get("sexualUssageName", "") # Ussage (sic.) in VRM spec
var commercial_str = vrm_extension["meta"].get("commercialUssageName", "") # Ussage (sic.) in VRM spec
if commercial_str == "Allow":
commercial_str = "AllowCorporation"
else:
commercial_str = "PersonalNonProfit"
vrm_meta.commercial_usage_type = commercial_str
vrm_meta.other_permission_url = vrm_extension["meta"].get("otherPermissionUrl", "")
vrm_meta.license_name = vrm_extension["meta"].get("licenseName", "")
if vrm_meta.license_name.begins_with("CC"):
vrm_meta.allow_redistribution = "Allow"
vrm_meta.modification = "AllowModificationRedistribution"
if vrm_meta.license_name == "Redistribution_Prohibited":
vrm_meta.allow_redistribution = "Disallow"
vrm_meta.other_license_url = vrm_extension["meta"].get("otherLicenseUrl", "")
vrm_meta.humanoid_bone_mapping = humanBones
return vrm_meta
const vrm0_to_vrm1_presets: Dictionary = {
"joy": "happy",
"angry": "angry",
"sorrow": "sad",
"fun": "relaxed",
"a": "aa",
"i": "ih",
"u": "ou",
"e": "ee",
"o": "oh",
"blink": "blink",
"blink_l": "blinkLeft",
"blink_r": "blinkRight",
"lookup": "lookUp",
"lookdown": "lookDown",
"lookleft": "lookLeft",
"lookright": "lookRight",
"neutral": "neutral",
}
func _create_animation_player(animplayer: AnimationPlayer, vrm_extension: Dictionary, gstate: GLTFState, human_bone_to_idx: Dictionary, pose_diffs: Array[Basis]) -> AnimationPlayer:
# Remove all glTF animation players for safety.
# VRM does not support animation import in this way.
for i in range(gstate.get_animation_players_count(0)):
var node: AnimationPlayer = gstate.get_animation_player(i)
node.get_parent().remove_child(node)
var animation_library: AnimationLibrary = AnimationLibrary.new()
var meshes = gstate.get_meshes()
var nodes = gstate.get_nodes()
var blend_shape_groups = vrm_extension["blendShapeMaster"]["blendShapeGroups"]
# FIXME: Do we need to handle multiple references to the same mesh???
var mesh_idx_to_meshinstance: Dictionary = vrm_utils.generate_mesh_index_to_meshinstance_mapping(gstate)
var material_name_to_mesh_and_surface_idx: Dictionary = {}
for i in range(meshes.size()):
var gltfmesh: GLTFMesh = meshes[i]
for j in range(gltfmesh.mesh.get_surface_count()):
material_name_to_mesh_and_surface_idx[gltfmesh.mesh.get_surface_material(j).resource_name] = [i, j]
var firstperson = vrm_extension["firstPerson"]
var reset_anim = Animation.new()
reset_anim.resource_name = "RESET"
for shape in blend_shape_groups:
#print("Blend shape group: " + shape["name"])
var anim = Animation.new()
for matbind in shape["materialValues"]:
var mesh_and_surface_idx = material_name_to_mesh_and_surface_idx[matbind["materialName"]]
var node: ImporterMeshInstance3D = mesh_idx_to_meshinstance[mesh_and_surface_idx[0]]
var surface_idx = mesh_and_surface_idx[1]
var mat: Material = node.mesh.get_surface_material(surface_idx)
var paramprop = "shader_parameter/" + matbind["propertyName"]
var origvalue = null
var tv = matbind["targetValue"]
var newvalue = tv[0]
if mat is ShaderMaterial:
var smat: ShaderMaterial = mat
var param = smat.get_shader_parameter(matbind["propertyName"])
if param is Color:
origvalue = param
if len(tv) >= 4:
newvalue = Color(tv[0], tv[1], tv[2], tv[3])
else:
printerr("Expected 4 values but got " + str(len(tv)) + " for parameter " + matbind["propertyName"] + " surface " + node.name + "/" + str(surface_idx))
newvalue = origvalue # Filler value for consistency.
elif matbind["propertyName"] == "_MainTex" or matbind["propertyName"] == "_MainTex_ST":
origvalue = param
if len(tv) >= 4:
newvalue = (Vector4(tv[2], tv[3], tv[0], tv[1]) if matbind["propertyName"] == "_MainTex" else Vector4(tv[0], tv[1], tv[2], tv[3]))
else:
printerr("Expected 4 values but got " + str(len(tv)) + " for parameter " + matbind["propertyName"] + " surface " + node.name + "/" + str(surface_idx))
newvalue = origvalue # Filler value for consistency.
elif param is float:
origvalue = param
newvalue = tv[0]
else:
printerr("Unknown type for parameter " + matbind["propertyName"] + " surface " + node.name + "/" + str(surface_idx))
if origvalue != null:
var animtrack: int = anim.add_track(Animation.TYPE_VALUE)
anim.track_set_path(animtrack, str(animplayer.get_parent().get_path_to(node)) + ":mesh:surface_" + str(surface_idx) + "/material:" + paramprop)
anim.track_set_interpolation_type(animtrack, Animation.INTERPOLATION_NEAREST if bool(shape["isBinary"]) else Animation.INTERPOLATION_LINEAR)
anim.track_insert_key(animtrack, 0.0, newvalue)
animtrack = reset_anim.add_track(Animation.TYPE_VALUE)
reset_anim.track_set_path(animtrack, str(animplayer.get_parent().get_path_to(node)) + ":mesh:surface_" + str(surface_idx) + "/material:" + paramprop)
reset_anim.track_set_interpolation_type(animtrack, Animation.INTERPOLATION_NEAREST if bool(shape["isBinary"]) else Animation.INTERPOLATION_LINEAR)
reset_anim.track_insert_key(animtrack, 0.0, origvalue)
for bind in shape["binds"]:
# FIXME: Is this a mesh_idx or a node_idx???
var node: ImporterMeshInstance3D = mesh_idx_to_meshinstance[int(bind["mesh"])]
var nodeMesh: ImporterMesh = node.mesh
if nodeMesh == null || bind["index"] < 0 || bind["index"] >= nodeMesh.get_blend_shape_count():
printerr("Invalid blend shape index in bind " + str(shape) + " for mesh " + str(node.name))
continue
var animtrack: int = anim.add_track(Animation.TYPE_BLEND_SHAPE)
# nodeMesh.set_blend_shape_name(int(bind["index"]), shape["name"] + "_" + str(bind["index"]))
anim.track_set_path(animtrack, str(animplayer.get_parent().get_path_to(node)) + ":" + str(nodeMesh.get_blend_shape_name(int(bind["index"]))))
var interpolation: int = Animation.INTERPOLATION_LINEAR
if shape.has("isBinary") and bool(shape["isBinary"]):
interpolation = Animation.INTERPOLATION_NEAREST
anim.track_set_interpolation_type(animtrack, interpolation)
# FIXME: Godot has weird normal/tangent singularities at weight=1.0 or weight=0.5
# So we multiply by 0.99999 to produce roughly the same output, avoiding these singularities.
anim.track_insert_key(animtrack, 0.0, 0.99999 * float(bind["weight"]) / 100.0)
animtrack = reset_anim.add_track(Animation.TYPE_BLEND_SHAPE)
# nodeMesh.set_blend_shape_name(int(bind["index"]), shape["name"] + "_" + str(bind["index"]))
reset_anim.track_set_path(animtrack, str(animplayer.get_parent().get_path_to(node)) + ":" + str(nodeMesh.get_blend_shape_name(int(bind["index"]))))
reset_anim.track_insert_key(animtrack, 0.0, float(0.0))
#var mesh:ArrayMesh = meshes[bind["mesh"]].mesh
#print("Mesh name: " + mesh.resource_name)
#print("Bind index: " + str(bind["index"]))
#print("Bind weight: " + str(float(bind["weight"]) / 100.0))
# https://github.com/vrm-c/vrm-specification/tree/master/specification/0.0#blendshape-name-identifier
if vrm0_to_vrm1_presets.has(shape["presetName"]):
anim.resource_name = vrm0_to_vrm1_presets[shape["presetName"]]
if shape["presetName"].begins_with("look"):
animation_library.add_animation(vrm0_to_vrm1_presets[shape["presetName"]] + "Raw", anim)
if firstperson.get("lookAtTypeName", "") != "Bone" or not shape["presetName"].begins_with("look"):
animation_library.add_animation(vrm0_to_vrm1_presets[shape["presetName"]], anim)
else:
if shape["presetName"] == "unknown":
anim.resource_name = shape["name"]
animation_library.add_animation(shape["name"], anim)
else:
push_warning("Unrecognized preset name " + str(shape))
var skeletons: Array[GLTFSkeleton] = gstate.get_skeletons()
var eye_bone_horizontal: Quaternion = Quaternion.from_euler(Vector3(PI / 2, 0, 0))
if firstperson.get("lookAtTypeName", "") == "Bone":
var horizout = firstperson["lookAtHorizontalOuter"]
var horizin = firstperson["lookAtHorizontalInner"]
var vertup = firstperson["lookAtVerticalUp"]
var vertdown = firstperson["lookAtVerticalDown"]
var lefteye: int = human_bone_to_idx.get("leftEye", -1)
var righteye: int = human_bone_to_idx.get("rightEye", -1)
var leftEyePath: String = ""
var rightEyePath: String = ""
if lefteye > 0:
var leftEyeNode: GLTFNode = nodes[lefteye]
var skeleton: Skeleton3D = _get_skel_godot_node(gstate, nodes, skeletons, leftEyeNode.skeleton)
var skeletonPath: NodePath = animplayer.get_parent().get_path_to(skeleton)
leftEyePath = (str(skeletonPath) + ":" + nodes[human_bone_to_idx["leftEye"]].resource_name)
if righteye > 0:
var rightEyeNode: GLTFNode = nodes[righteye]
var skeleton: Skeleton3D = _get_skel_godot_node(gstate, nodes, skeletons, rightEyeNode.skeleton)
var skeletonPath: NodePath = animplayer.get_parent().get_path_to(skeleton)
rightEyePath = (str(skeletonPath) + ":" + nodes[human_bone_to_idx["rightEye"]].resource_name)
if lefteye > 0 and righteye > 0:
var animtrack: int = reset_anim.add_track(Animation.TYPE_ROTATION_3D)
reset_anim.track_set_path(animtrack, leftEyePath)
reset_anim.rotation_track_insert_key(animtrack, 0.0, eye_bone_horizontal)
animtrack = reset_anim.add_track(Animation.TYPE_ROTATION_3D)
reset_anim.track_set_path(animtrack, rightEyePath)
reset_anim.rotation_track_insert_key(animtrack, 0.0, eye_bone_horizontal)
var anim: Animation = null
if not animplayer.has_animation("lookLeft"):
anim = Animation.new()
animation_library.add_animation("lookLeft", anim)
else:
anim = animplayer.get_animation("lookLeft")
if anim and lefteye > 0 and righteye > 0:
var animtrack: int = anim.add_track(Animation.TYPE_ROTATION_3D)
anim.track_set_path(animtrack, leftEyePath)
anim.track_set_interpolation_type(animtrack, Animation.INTERPOLATION_LINEAR)
anim.rotation_track_insert_key(animtrack, horizout["xRange"] / 90.0, eye_bone_horizontal * (Basis(Vector3(0, 0, 1), -horizout["yRange"] * PI / 180.0)).get_rotation_quaternion())
animtrack = anim.add_track(Animation.TYPE_ROTATION_3D)
anim.track_set_path(animtrack, rightEyePath)
anim.track_set_interpolation_type(animtrack, Animation.INTERPOLATION_LINEAR)
anim.rotation_track_insert_key(animtrack, horizin["xRange"] / 90.0, eye_bone_horizontal * (Basis(Vector3(0, 0, 1), -horizin["yRange"] * PI / 180.0)).get_rotation_quaternion())
if not animplayer.has_animation("lookRight"):
anim = Animation.new()
animation_library.add_animation("lookRight", anim)
else:
anim = animplayer.get_animation("lookRight")
if anim and lefteye > 0 and righteye > 0:
var animtrack: int = anim.add_track(Animation.TYPE_ROTATION_3D)
anim.track_set_path(animtrack, leftEyePath)
anim.track_set_interpolation_type(animtrack, Animation.INTERPOLATION_LINEAR)
anim.rotation_track_insert_key(animtrack, horizin["xRange"] / 90.0, eye_bone_horizontal * (Basis(Vector3(0, 0, 1), horizin["yRange"] * PI / 180.0)).get_rotation_quaternion())
animtrack = anim.add_track(Animation.TYPE_ROTATION_3D)
anim.track_set_path(animtrack, rightEyePath)
anim.track_set_interpolation_type(animtrack, Animation.INTERPOLATION_LINEAR)
anim.rotation_track_insert_key(animtrack, horizout["xRange"] / 90.0, eye_bone_horizontal * (Basis(Vector3(0, 0, 1), horizout["yRange"] * PI / 180.0)).get_rotation_quaternion())
if not animplayer.has_animation("lookUp"):
anim = Animation.new()
animation_library.add_animation("lookUp", anim)
else:
anim = animplayer.get_animation("lookUp")
if anim and lefteye > 0 and righteye > 0:
var animtrack: int = anim.add_track(Animation.TYPE_ROTATION_3D)
anim.track_set_path(animtrack, leftEyePath)
anim.track_set_interpolation_type(animtrack, Animation.INTERPOLATION_LINEAR)
anim.rotation_track_insert_key(animtrack, vertup["xRange"] / 90.0, eye_bone_horizontal * (Basis(Vector3(1, 0, 0), -vertup["yRange"] * PI / 180.0)).get_rotation_quaternion())
animtrack = anim.add_track(Animation.TYPE_ROTATION_3D)
anim.track_set_path(animtrack, rightEyePath)
anim.track_set_interpolation_type(animtrack, Animation.INTERPOLATION_LINEAR)
anim.rotation_track_insert_key(animtrack, vertup["xRange"] / 90.0, eye_bone_horizontal * (Basis(Vector3(1, 0, 0), -vertup["yRange"] * PI / 180.0)).get_rotation_quaternion())
if not animplayer.has_animation("lookDown"):
anim = Animation.new()
animation_library.add_animation("lookDown", anim)
else:
anim = animplayer.get_animation("lookDown")
if anim and lefteye > 0 and righteye > 0:
var animtrack: int = anim.add_track(Animation.TYPE_ROTATION_3D)
anim.track_set_path(animtrack, leftEyePath)
anim.track_set_interpolation_type(animtrack, Animation.INTERPOLATION_LINEAR)
anim.rotation_track_insert_key(animtrack, vertdown["xRange"] / 90.0, eye_bone_horizontal * (Basis(Vector3(1, 0, 0), vertdown["yRange"] * PI / 180.0)).get_rotation_quaternion())
animtrack = anim.add_track(Animation.TYPE_ROTATION_3D)
anim.track_set_path(animtrack, rightEyePath)
anim.track_set_interpolation_type(animtrack, Animation.INTERPOLATION_LINEAR)
anim.rotation_track_insert_key(animtrack, vertdown["xRange"] / 90.0, eye_bone_horizontal * (Basis(Vector3(1, 0, 0), vertdown["yRange"] * PI / 180.0)).get_rotation_quaternion())
animation_library.add_animation("RESET", reset_anim)
animplayer.add_animation_library("", animation_library)
return animplayer
func _create_joints_recursive(joint_chains: Array[PackedStringArray], skeleton: Skeleton3D, bone_idx: int, level: int, current_chain: int):
if current_chain == -1: # ALWAYS do this?! # and level > 0:
current_chain = len(joint_chains)
joint_chains.push_back(PackedStringArray())
if current_chain != -1:
joint_chains[current_chain].push_back(skeleton.get_bone_name(bone_idx))
var bone_children = skeleton.get_bone_children(bone_idx)
if bone_children.is_empty():
if current_chain != -1: # and len(joint_chains[current_chain]) > 0 is guaranteed true
joint_chains[current_chain].push_back("") # Use empty string to denote 7cm tail bone.
else:
for i in range(len(bone_children)):
var child_bone: int = bone_children[i]
if i == 0:
_create_joints_recursive(joint_chains, skeleton, child_bone, level + 1, current_chain)
else:
_create_joints_recursive(joint_chains, skeleton, child_bone, 0, -1)
func _parse_secondary_node(secondary_node: Node, vrm_extension: Dictionary, gstate: GLTFState, pose_diffs: Array[Basis], is_vrm_0: bool) -> void:
var nodes = gstate.get_nodes()
var skeletons = gstate.get_skeletons()
# Assume that all SpringBone are part of one skeleton for now.
var skeleton_path: NodePath = secondary_node.get_path_to(secondary_node.get_parent().get_node("%GeneralSkeleton"))
var offset_flip: Vector3 = Vector3(-1, 1, 1) if is_vrm_0 else Vector3(1, 1, 1)
var collider_groups: Array[vrm_collider_group]
for cgroup in vrm_extension["secondaryAnimation"]["colliderGroups"]:
var gltfnode: GLTFNode = nodes[int(cgroup["node"])]
var collider_group: vrm_collider_group = vrm_collider_group.new()
var node_path: NodePath
var bone: String = ""
var new_resource_name: String = ""
var pose_diff: Basis = Basis()
if gltfnode.skeleton == -1:
var found_node: Node = gstate.get_scene_node(int(cgroup["node"]))
node_path = secondary_node.get_path_to(found_node)
bone = ""
new_resource_name = found_node.name
else:
var skeleton: Skeleton3D = _get_skel_godot_node(gstate, nodes, skeletons, gltfnode.skeleton)
bone = nodes[int(cgroup["node"])].resource_name
new_resource_name = bone
pose_diff = pose_diffs[skeleton.find_bone(bone)]
for collider_info in cgroup["colliders"]:
var collider: vrm_collider = vrm_collider.new()
collider.node_path = node_path
collider.bone = bone
collider.resource_name = new_resource_name
var offset_obj = collider_info.get("offset", {"x": 0.0, "y": 0.0, "z": 0.0})
var offset_vec = offset_flip * Vector3(offset_obj["x"], offset_obj["y"], offset_obj["z"])
# beware that quat * vec * vec multiplication is not associative
var local_pos: Vector3 = pose_diff * offset_vec
var radius: float = collider_info.get("radius", 0.0)
collider.is_capsule = false
collider.offset = local_pos
collider.tail = local_pos
collider.radius = radius
collider_group.colliders.append(collider)
collider_groups.append(collider_group)
var spring_bones: Array[vrm_spring_bone]
for sbone in vrm_extension["secondaryAnimation"]["boneGroups"]:
if sbone.get("bones", []).size() == 0:
continue
var first_bone_node: int = sbone["bones"][0]
var gltfnode: GLTFNode = nodes[int(first_bone_node)]
var skeleton: Skeleton3D = _get_skel_godot_node(gstate, nodes, skeletons, gltfnode.skeleton)
if skeleton_path != secondary_node.get_path_to(skeleton):
push_error("boneGroups somehow references a different skeleton... " + str(skeleton_path) + " vs " + str(secondary_node.get_path_to(skeleton)))
var comment: String = sbone.get("comment", "")
var stiffness_force = float(sbone.get("stiffiness", 1.0))
var gravity_power = float(sbone.get("gravityPower", 0.0))
var gravity_dir_json = sbone.get("gravityDir", {"x": 0.0, "y": -1.0, "z": 0.0})
var gravity_dir = Vector3(gravity_dir_json["x"], gravity_dir_json["y"], gravity_dir_json["z"])
var drag_force = float(sbone.get("dragForce", 0.4))
var hit_radius = float(sbone.get("hitRadius", 0.02))
var spring_collider_groups: Array[vrm_collider_group]
for cgroup_idx in sbone.get("colliderGroups", []):
spring_collider_groups.append(collider_groups[int(cgroup_idx)])
# Append to indiviudal packed arrays
var joint_chains: Array[PackedStringArray]
for bone_node in sbone["bones"]:
_create_joints_recursive(joint_chains, skeleton, skeleton.find_bone(nodes[int(bone_node)].resource_name), 1, -1)
# Center commonly points outside of the glTF Skeleton, such as the root node.
var center_node: NodePath = NodePath()
var center_bone: String = ""
var center_node_idx = sbone.get("center", -1)
if center_node_idx != -1:
var center_gltfnode: GLTFNode = nodes[int(center_node_idx)]
var bone_name: String = center_gltfnode.resource_name
if center_gltfnode.skeleton == gltfnode.skeleton and skeleton.find_bone(bone_name) != -1:
center_bone = bone_name
center_node = NodePath()
else:
center_bone = ""
center_node = (secondary_node.get_path_to(gstate.get_scene_node(int(center_node_idx))))
if center_node == NodePath():
printerr("Failed to find center scene node " + str(center_node_idx))
center_node = secondary_node.get_path_to(secondary_node) # Fallback
for chain in joint_chains:
var spring_bone: vrm_spring_bone = vrm_spring_bone.new()
spring_bone.comment = comment
spring_bone.center_bone = center_bone
spring_bone.center_node = center_node
spring_bone.collider_groups = spring_collider_groups
for bone_name in chain:
spring_bone.joint_nodes.push_back(bone_name) # end bone will be named ""
spring_bone.stiffness_scale = stiffness_force
spring_bone.gravity_scale = gravity_power
spring_bone.gravity_dir_default = gravity_dir
spring_bone.drag_force_scale = drag_force
spring_bone.hit_radius_scale = hit_radius
if not comment.is_empty():
spring_bone.resource_name = comment.split("\n")[0]
else:
spring_bone.resource_name = chain[0]
spring_bones.append(spring_bone)
secondary_node.set_script(vrm_secondary)
secondary_node.set("skeleton", skeleton_path)
secondary_node.set("spring_bones", spring_bones)
func _add_joints_recursive(new_joints_set: Dictionary, gltf_nodes: Array, bone: int, include_child_meshes: bool = false) -> void:
if bone < 0:
return
var gltf_node: Dictionary = gltf_nodes[bone]
if not include_child_meshes and gltf_node.get("mesh", -1) != -1:
return
new_joints_set[bone] = true
for child_node in gltf_node.get("children", []):
if not new_joints_set.has(child_node):
_add_joints_recursive(new_joints_set, gltf_nodes, int(child_node))
func _add_joint_set_as_skin(obj: Dictionary, new_joints_set: Dictionary) -> void:
var new_joints = [].duplicate()
for node in new_joints_set:
new_joints.push_back(node)
new_joints.sort()
var new_skin: Dictionary = {"joints": new_joints}
if not obj.has("skins"):
obj["skins"] = [].duplicate()
obj["skins"].push_back(new_skin)
func _add_vrm_nodes_to_skin(obj: Dictionary) -> bool:
var vrm_extension: Dictionary = obj.get("extensions", {}).get("VRM", {})
if not vrm_extension.has("humanoid"):
return false
var new_joints_set = {}.duplicate()
var secondaryAnimation = vrm_extension.get("secondaryAnimation", {})
for bone_group in secondaryAnimation.get("boneGroups", []):
for bone in bone_group["bones"]:
_add_joints_recursive(new_joints_set, obj["nodes"], int(bone), true)
for collider_group in secondaryAnimation.get("colliderGroups", []):
if int(collider_group["node"]) >= 0:
new_joints_set[int(collider_group["node"])] = true
var firstPerson = vrm_extension.get("firstPerson", {})
if firstPerson.get("firstPersonBone", -1) >= 0:
new_joints_set[int(firstPerson["firstPersonBone"])] = true
for human_bone in vrm_extension["humanoid"]["humanBones"]:
_add_joints_recursive(new_joints_set, obj["nodes"], int(human_bone["node"]), false)
_add_joint_set_as_skin(obj, new_joints_set)
return true
func _import_preflight(gstate: GLTFState, extensions: PackedStringArray = PackedStringArray(), psa2: Variant = null) -> Error:
if extensions.has("VRMC_vrm"):
# VRM 1.0 file. Do not parse as a VRM 0.0.
return ERR_INVALID_DATA
if typeof(gstate.get_additional_data(&"vrm/already_processed")) != TYPE_NIL:
return ERR_SKIP
gstate.set_additional_data(&"vrm/already_processed", true)
var gltf_json_parsed: Dictionary = gstate.json
var gltf_nodes = gltf_json_parsed["nodes"]
if not _add_vrm_nodes_to_skin(gltf_json_parsed):
push_error("Failed to find required VRM keys in json")
return ERR_INVALID_DATA
for node in gltf_nodes:
if node.get("name", "") == "Root":
node["name"] = "Root_"
return OK
func _import_post_parse(state: GLTFState) -> Error:
var nodes := state.get_nodes()
for n in nodes:
if typeof(n.get_additional_data(&"GODOT_rest_transform")) == TYPE_NIL:
n.set_additional_data(&"GODOT_rest_transform", n.get_xform())
return OK
func _import_post(gstate: GLTFState, node: Node) -> Error:
var gltf: GLTFDocument = GLTFDocument.new()
var root_node: Node = node
var is_vrm_0: bool = true
var gltf_json: Dictionary = gstate.json
var vrm_extension: Dictionary = gltf_json["extensions"]["VRM"]
var human_bone_to_idx: Dictionary = {}
# Ignoring in ["humanoid"]: armStretch, legStretch, upperArmTwist
# lowerArmTwist, upperLegTwist, lowerLegTwist, feetSpacing,
# and hasTranslationDoF
for human_bone in vrm_extension["humanoid"]["humanBones"]:
human_bone_to_idx[human_bone["bone"]] = int(human_bone["node"])
# Unity Mecanim properties:
# Ignoring: useDefaultValues
# Ignoring: min
# Ignoring: max
# Ignoring: center
# Ingoring: axisLength
var skeletons = gstate.get_skeletons()
var hipsNode: GLTFNode = gstate.nodes[human_bone_to_idx["hips"]]
var skeleton: Skeleton3D = _get_skel_godot_node(gstate, gstate.nodes, skeletons, hipsNode.skeleton)
var gltfnodes: Array = gstate.nodes
var humanBones: BoneMap = BoneMap.new()
humanBones.profile = SkeletonProfileHumanoid.new()
var vrm_to_human_bone = vrm_constants_class.get_vrm_to_human_bone(is_vrm_0) # vrm 0.0
for humanBoneName in human_bone_to_idx:
humanBones.set_skeleton_bone_name(vrm_to_human_bone[humanBoneName], gltfnodes[human_bone_to_idx[humanBoneName]].resource_name)
if is_vrm_0:
# VRM 0.0 has models facing backwards due to a spec error (flipped z instead of x)
var blend_shape_names: Dictionary = vrm_utils._extract_blendshape_names(gltf_json)
vrm_utils.rotate_scene_180(root_node, blend_shape_names, gstate)
var do_retarget = true
var pose_diffs: Array[Basis]
if do_retarget:
pose_diffs = vrm_utils.perform_retarget(gstate, root_node, skeleton, humanBones)
else:
# resize is busted for TypedArray and crashes Godot
for i in range(skeleton.get_bone_count()):
pose_diffs.append(Basis.IDENTITY)
skeleton.set_meta("vrm_pose_diffs", pose_diffs)
_update_materials(vrm_extension, gstate)
_first_person_head_hiding(vrm_extension, gstate, human_bone_to_idx)
var animplayer: AnimationPlayer
if root_node.has_node("AnimationPlayer"):
animplayer = root_node.get_node("AnimationPlayer")
else:
animplayer = AnimationPlayer.new()
animplayer.name = "AnimationPlayer"
root_node.add_child(animplayer, true)
animplayer.owner = root_node
_create_animation_player(animplayer, vrm_extension, gstate, human_bone_to_idx, pose_diffs)
root_node.set_script(vrm_top_level)
var vrm_meta: Resource = _create_meta(root_node, animplayer, vrm_extension, gstate, skeleton, humanBones, human_bone_to_idx, pose_diffs)
root_node.set("vrm_meta", vrm_meta)
if vrm_extension.has("secondaryAnimation") and (vrm_extension["secondaryAnimation"].get("colliderGroups", []).size() > 0 or vrm_extension["secondaryAnimation"].get("boneGroups", []).size() > 0):
# NOTICE: LOCAL PATCH
# Some VRM 0.0 exporters include secondaryAnimation data without a scene node
# named "secondary". get_node() throws before the intended null fallback below
# can run, so runtime import must use get_node_or_null() here.
# Source/context: this fallback is already present in the V-Sekai VRM add-on
# block below; the throwing lookup prevents it from being reached.
# Removal condition: remove when the vendored add-on ships this lookup fix.
var secondary_node: Node = root_node.get_node_or_null("secondary")
if secondary_node == null:
secondary_node = Node3D.new()
root_node.add_child(secondary_node, true)
secondary_node.set_owner(root_node)
secondary_node.set_name("secondary")
_parse_secondary_node(secondary_node, vrm_extension, gstate, pose_diffs, is_vrm_0)
return OK
@@ -0,0 +1 @@
uid://c0qjj7ewa80bs
@@ -0,0 +1,86 @@
@tool
extends Resource
# VRM extension is for 3d humanoid avatars (and models) in VR applications.
# Meta schema:
# Title of VRM model
@export var title: String
# Version of VRM model
@export var version: String
# Thumbnail of VRM model
@export var thumbnail_image: Texture
@export_subgroup("Author and Reference")
# Author of VRM model
@export var authors: PackedStringArray
@export var author: String:
get:
return ",".join(authors)
set(value):
authors = PackedStringArray() if value.is_empty() else value.split(",")
# Contact Information of VRM model author
@export var contact_information: String
# Reference of VRM model
@export var references: PackedStringArray
@export var reference_information: String:
get:
return ",".join(references)
set(value):
references = PackedStringArray() if value.is_empty() else value.split(",")
@export_subgroup("Permission")
# A person who can perform with this avatar
@export_enum(" ", "OnlyAuthor", "ExplicitlyLicensedPerson", "Everyone") var allowed_user_name: String
# A flag that permits to use this model in excessively violent contents
@export_enum(" ", "Disallow", "Allow") var violent_usage: String
# A flag that permits to use this model in excessively sexual contents
@export_enum(" ", "Disallow", "Allow") var sexual_usage: String
# An option that permits to use this model in commercial products
@export_enum(" ", "PersonalNonProfit", "PersonalProfit", "AllowCorporation") var commercial_usage_type: String
# A flag that permits to use this model in political or religious contents
@export_enum(" ", "Disallow", "Allow") var political_religious_usage: String
# A flag that permits to use this model in contents contain anti-social activities or hate speeches
@export_enum(" ", "Disallow", "Allow") var antisocial_hate_usage: String
# An option that forces or abandons to display the credit of this model
@export_enum(" ", "Required", "Unnecessary") var credit_notation: String
# A flag that permits to redistribute this model
@export_enum(" ", "Disallow", "Allow") var allow_redistribution: String
# An option that controls the condition to modify this model
@export_enum(" ", "Prohibited", "AllowModification", "AllowModificationRedistribution") var modification: String
# If there are any conditions not mentioned above, put the URL link of the license document here.
@export var other_permission_url: String
# License type (VRM 0.0 only)
@export var license_name: String
# (String,"","Redistribution_Prohibited","CC0","CC_BY","CC_BY_NC","CC_BY_SA","CC_BY_NC_SA","CC_BY_ND","CC_BY_NC_ND","Other")
# License URL (VRM 1.0 only)
@export var license_url: String
# Third party licenses of the model, if required. You can use line breaks. VRM 1.0 only
@export var third_party_licenses: String
# If "Other" is selected, put the URL link of the license document here.
@export var other_license_url: String
@export_subgroup("Import Export data")
# Human bone name -> Reference node index
# NOTE: We are currently discarding all Unity-specific data.
# We may need to store it somewhere in case we wish to re-export.
@export var humanoid_bone_mapping: BoneMap # VRM boneName -> bone name (within skeleton)
# NOTE: Mouth offset is not stored in any model metadata.
# As an alternative, we could get the centroid of vertices moved by viseme blend shapes.
# But for now, users should assume same as eyeOffset with y=0 (relative to head)
# Toplevel schema, belongs in vrm_meta:
# Version of exporter that vrm created. UniVRM-0.46
@export var exporter_version: String
# Version of VRM specification. 0.0
@export var spec_version: String
@@ -0,0 +1 @@
uid://b1ee4q1my1ig4
@@ -0,0 +1,11 @@
@tool
extends EditorScenePostImportPlugin
signal foo
func _get_import_options(path: String):
if path.is_empty() or path.get_extension().to_lower() == "vrm":
add_import_option_advanced(TYPE_INT, "vrm/head_hiding_method", 0, PROPERTY_HINT_ENUM,
"ThirdPersonOnly,FirstPersonOnly,FirstWithShadow,Layers,LayersWithShadow,IgnoreHeadHiding")
add_import_option_advanced(TYPE_INT, "vrm/only_if_head_hiding_uses_layers/first_person_layers", 2, PROPERTY_HINT_LAYERS_3D_RENDER)
add_import_option_advanced(TYPE_INT, "vrm/only_if_head_hiding_uses_layers/third_person_layers", 4, PROPERTY_HINT_LAYERS_3D_RENDER)
@@ -0,0 +1 @@
uid://delu28murwhas
@@ -0,0 +1,441 @@
@tool
class_name VRMSecondary
extends Node3D
const spring_bone_class = preload("./vrm_spring_bone.gd")
const collider_class = preload("./vrm_collider.gd")
const collider_group_class = preload("./vrm_collider_group.gd")
@export_category("Springbone Settings")
@export var update_secondary_fixed: bool = false:
set(value):
update_secondary_fixed = value
if is_child_of_vrm:
get_parent().update_secondary_fixed = value
@export var disable_colliders: bool = false:
set(value):
disable_colliders = value
if is_child_of_vrm:
get_parent().disable_colliders = value
@export var override_springbone_center: bool = false:
set(value):
override_springbone_center = value
if is_child_of_vrm:
get_parent().override_springbone_center = value
@export var default_springbone_center: Node3D:
set(value):
default_springbone_center = value
if is_child_of_vrm:
get_parent().default_springbone_center = value
@export var springbone_gravity_multiplier: float = 1.0:
set(value):
springbone_gravity_multiplier = value
if is_child_of_vrm:
get_parent().springbone_gravity_multiplier = value
modify_gravity = true
@export var springbone_gravity_rotation: Quaternion = Quaternion.IDENTITY:
set(value):
springbone_gravity_rotation = value
if is_child_of_vrm:
get_parent().springbone_gravity_rotation = value
modify_gravity = true
@export var springbone_add_force: Vector3 = Vector3.ZERO:
set(value):
springbone_add_force = value
if is_child_of_vrm:
get_parent().springbone_add_force = value
modify_gravity = true
@export_category("Run in Editor")
@export var update_in_editor: bool = false:
set(value):
update_in_editor = value
if is_child_of_vrm:
get_parent().update_in_editor = value
if Engine.is_editor_hint() and is_inside_tree():
if value:
_ready()
else:
for spring_bone in spring_bones_internal:
spring_bone.skel.clear_bones_global_pose_override()
@export var gizmo_spring_bone: bool = false:
set(value):
gizmo_spring_bone = value
if is_child_of_vrm:
get_parent().gizmo_spring_bone = value
@export var gizmo_spring_bone_color: Color = Color.LIGHT_YELLOW:
set(value):
gizmo_spring_bone_color = value
if is_child_of_vrm:
get_parent().gizmo_spring_bone_color = value
@export_category("Spring bones")
@export_node_path("Skeleton3D") var skeleton: NodePath:
set(value):
if skel is Skeleton3D and skel != null:
skel.clear_bones_global_pose_override()
skeleton = value
if is_inside_tree():
_ready()
@export var spring_bones: Array[spring_bone_class]:
set(value):
spring_bones = value
if is_child_of_vrm:
get_parent().spring_bones = value
var skel: Skeleton3D
var internal_modifier_node: Node3D
# Props
var spring_bones_internal: Array[spring_bone_class.SpringBoneRuntimeState]
var springs_centers: PackedInt32Array
var colliders_internal: Array[collider_class.VrmRuntimeCollider]
var colliders_centers: PackedInt32Array
var center_bones: PackedInt32Array
var center_nodes: Array[Node3D]
# Updated every frame
var center_transforms: Array[Transform3D]
var center_transforms_inv: Array[Transform3D]
var secondary_gizmo: SecondaryGizmo
var is_child_of_vrm: bool = false
var colliders_changed: bool = false
var modify_gravity: bool = false
@export var collider_groups: Array[collider_group_class] # Unused, but this way we don't break script compatibility.
@export var collider_library: Array[collider_class] # Unused, intended to make inspecting easier
var spring_bones_cached: Array[spring_bone_class]
func _on_recreate_collider():
colliders_changed = true
# Collider state
# TODO: explore packed data to make processing optimization such as c++ easier.
#var collider_skel_positions: PackedVector3Array
#var collider_skel_tails: PackedVector3Array # is_capsule if not equal to collider_skel_positions
#var collider_radius: PackedFloat32Array
#const springbone_runtime = preload("./runtime/springbone_runtime.gd")
#var spring_logic: Array[springbone_runtime]
# Called when the node enters the scene tree for the first time.
func _ready() -> void:
skel = get_node(skeleton)
if skel == null:
return # Not supported.
if ClassDB.class_exists(&"SkeletonModifier3D"):
if internal_modifier_node != null:
if internal_modifier_node.get_parent() != null:
internal_modifier_node.get_parent().remove_child(internal_modifier_node)
internal_modifier_node.queue_free()
internal_modifier_node = ClassDB.instantiate("SkeletonModifier3D")
internal_modifier_node.name = "VRM_internal_skeleton_modifier"
skel.add_child(internal_modifier_node, false, Node.INTERNAL_MODE_BACK)
internal_modifier_node.connect(&"modification_processed", self._on_secondary_process_modification_processed)
spring_bones_cached = spring_bones
var gizmo_spring_bone: bool = false
if get_parent() != null and get_parent().script != null and get_parent().script.resource_path.get_file() == "vrm_toplevel.gd":
is_child_of_vrm = true
if is_child_of_vrm:
get_parent().spring_bones = spring_bones
get_parent().collider_groups = collider_groups
get_parent().collider_library = collider_library
update_secondary_fixed = get_parent().get("update_secondary_fixed")
gizmo_spring_bone = get_parent().get("gizmo_spring_bone")
disable_colliders = get_parent().get("disable_colliders")
if secondary_gizmo == null and (Engine.is_editor_hint() or gizmo_spring_bone):
secondary_gizmo = SecondaryGizmo.new(self)
skel.add_child(secondary_gizmo, true, Node.INTERNAL_MODE_FRONT)
colliders_internal.clear()
spring_bones_internal.clear()
colliders_centers.clear()
center_bones.clear()
center_nodes.clear()
center_transforms.clear()
center_transforms_inv.clear()
var center_to_collider_to_internal: Dictionary = {}
var center_to_index: Dictionary = {}
for spring_bone in spring_bones:
if not spring_bone:
spring_bone = spring_bone_class.new()
var center_key: Variant = spring_bone.center_bone
if spring_bone.center_bone == "":
center_key = spring_bone.center_node
if not center_to_index.has(center_key):
center_to_index[center_key] = len(center_bones)
if spring_bone.center_bone != "":
center_bones.push_back(skel.find_bone(spring_bone.center_bone))
else:
center_bones.push_back(-1)
if spring_bone.center_node == NodePath():
center_nodes.push_back(null)
else:
center_nodes.push_back(get_node(spring_bone.center_node))
center_transforms.push_back(Transform3D.IDENTITY)
center_transforms_inv.push_back(Transform3D.IDENTITY)
update_centers(skel.global_transform)
collider_groups.clear()
collider_library.clear()
var seen_collider_groups: Dictionary
var seen_colliders: Dictionary
for spring_bone in spring_bones:
if not spring_bone:
spring_bone = spring_bone_class.new()
var center_key: Variant = spring_bone.center_bone
if spring_bone.center_bone == "":
center_key = spring_bone.center_node
var center_idx: int = center_to_index[center_key]
var tmp_colliders: Array[collider_class.VrmRuntimeCollider] = []
for collider_group in spring_bone.collider_groups:
if not seen_collider_groups.has(collider_group):
seen_collider_groups[collider_group] = true
collider_groups.append(collider_group)
for collider in collider_group.colliders:
if not seen_colliders.has(collider):
seen_colliders[collider] = true
collider_library.append(collider)
if not collider.recreate_collider.is_connected(self._on_recreate_collider):
collider.recreate_collider.connect(self._on_recreate_collider) # Rebuild everything if anything changes.
var collider_runtime: collider_class.VrmRuntimeCollider
if center_key not in center_to_collider_to_internal:
center_to_collider_to_internal[center_key] = {}
if center_to_collider_to_internal[center_key].has(collider):
collider_runtime = center_to_collider_to_internal[center_key][collider]
else:
collider_runtime = collider.create_runtime(self, skel)
collider_runtime.gizmo_color = collider.gizmo_color
colliders_internal.append(collider_runtime)
colliders_centers.append(center_idx)
center_to_collider_to_internal[center_key][collider] = collider_runtime
tmp_colliders.append(collider_runtime)
var new_spring_bone := spring_bone.create_runtime(skel)
new_spring_bone.ready(skel, tmp_colliders, center_transforms_inv[center_idx])
new_spring_bone.disable_colliders = disable_colliders
spring_bones_internal.append(new_spring_bone)
springs_centers.append(center_idx)
func check_for_editor_update() -> bool:
if not Engine.is_editor_hint():
return false
if is_child_of_vrm:
var parent: Node = get_parent()
if parent.update_in_editor != update_in_editor:
update_in_editor = parent.update_in_editor
return update_in_editor
func update_centers(skel_transform: Transform3D):
skel.get_bone_global_pose_no_override(0)
var skel_transform_inv: Transform3D = skel_transform.affine_inverse()
var center_xform: Transform3D
var center_xform_inv: Transform3D
if default_springbone_center != null:
center_xform = default_springbone_center.global_transform
center_xform_inv = center_xform.affine_inverse()
for center_i in range(len(center_nodes)):
var center_node: Node3D = center_nodes[center_i]
if (center_bones[center_i] == -1 and center_node == null) or override_springbone_center:
center_transforms[center_i] = skel_transform
center_transforms_inv[center_i] = skel_transform_inv
if default_springbone_center != null:
center_transforms[center_i] = center_xform_inv * center_transforms[center_i]
center_transforms_inv[center_i] = center_transforms_inv[center_i] * center_xform
elif center_bones[center_i] == -1 and center_node != null:
center_transforms[center_i] = center_node.global_transform.affine_inverse() * skel_transform
center_transforms_inv[center_i] = skel_transform_inv * center_node.global_transform
else:
center_transforms[center_i] = skel.get_bone_global_pose(center_bones[center_i])
center_transforms_inv[center_i] = center_transforms[center_i].affine_inverse()
func tick_spring_bones(delta: float) -> void:
# force update skeleton
if skel == null:
return
var skel_transform: Transform3D = skel.global_transform
update_centers(skel_transform)
var needs_reintialize: bool = false
# our setter syncs it the other direction.
if is_child_of_vrm:
var parent: Node = get_parent()
if parent.springbone_gravity_rotation != springbone_gravity_rotation or parent.springbone_gravity_multiplier != springbone_gravity_multiplier or parent.springbone_add_force != springbone_add_force:
springbone_add_force = parent.springbone_add_force
springbone_gravity_rotation = parent.springbone_gravity_rotation
springbone_gravity_multiplier = parent.springbone_gravity_multiplier
modify_gravity = true
if parent.disable_colliders != disable_colliders:
disable_colliders = parent.disable_colliders
for sb in spring_bones_internal:
sb.disable_colliders = disable_colliders
override_springbone_center = parent.override_springbone_center
default_springbone_center = parent.default_springbone_center
if spring_bones != parent.spring_bones:
spring_bones = parent.spring_bones
needs_reintialize = true
if modify_gravity:
for sb in spring_bones_internal:
sb.add_force = springbone_add_force
sb.gravity_rotation = springbone_gravity_rotation
sb.gravity_multiplier = springbone_gravity_multiplier
for spring_i in range(len(spring_bones_internal)):
needs_reintialize = spring_bones_internal[spring_i].pre_update() or needs_reintialize
if needs_reintialize or colliders_changed or spring_bones_cached != spring_bones:
colliders_changed = false
skel.clear_bones_global_pose_override()
_ready()
for spring_i in range(len(spring_bones_internal)):
spring_bones_internal[spring_i].pre_update()
for collider_i in range(len(colliders_internal)):
colliders_internal[collider_i].update(skel_transform, center_transforms[colliders_centers[collider_i]], skel)
for spring_i in range(len(spring_bones_internal)):
spring_bones_internal[spring_i].update(delta, center_transforms[springs_centers[spring_i]], center_transforms_inv[springs_centers[spring_i]])
if secondary_gizmo != null:
if Engine.is_editor_hint():
secondary_gizmo.draw_in_editor(true)
else:
secondary_gizmo.draw_in_game()
func _process(delta: float):
if not ClassDB.class_exists(&"SkeletonModifier3D"):
if not update_secondary_fixed:
do_process(delta)
func _physics_process(delta: float) -> void:
if not ClassDB.class_exists(&"SkeletonModifier3D"):
if update_secondary_fixed:
do_process(delta)
func _on_secondary_process_modification_processed() -> void:
var delta: float
# MODIFIER_CALLBACK_MODE_PROCESS_PHYSICS = 0
if skel.modifier_callback_mode_process == 0:
delta = get_physics_process_delta_time()
else:
delta = get_process_delta_time()
do_process(delta)
# Called every frame. 'delta' is the elapsed time since the previous frame.
func do_process(delta: float) -> void:
if not Engine.is_editor_hint() or check_for_editor_update():
tick_spring_bones(delta)
elif Engine.is_editor_hint():
if secondary_gizmo != null:
if skel != null:
var skel_transform: Transform3D = skel.global_transform
update_centers(skel_transform)
for collider_i in range(len(colliders_internal)):
colliders_internal[collider_i].update(skel_transform, center_transforms[colliders_centers[collider_i]], skel)
secondary_gizmo.draw_in_editor()
class SecondaryGizmo:
extends MeshInstance3D
var secondary_node
var m: StandardMaterial3D = StandardMaterial3D.new()
func _init(parent) -> void:
mesh = ImmediateMesh.new()
secondary_node = parent
m.no_depth_test = true
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
m.vertex_color_use_as_albedo = true
m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
func draw_in_editor(_do_draw_spring_bones: bool = false) -> void:
mesh.clear_surfaces()
if secondary_node.is_child_of_vrm && secondary_node.get_parent().gizmo_spring_bone:
draw_spring_bones(secondary_node.get_parent().gizmo_spring_bone_color)
draw_collider_groups()
func draw_in_game() -> void:
mesh.clear_surfaces()
if secondary_node.is_child_of_vrm && secondary_node.get_parent().gizmo_spring_bone:
draw_spring_bones(secondary_node.get_parent().gizmo_spring_bone_color)
draw_collider_groups()
func draw_spring_bones(color: Color) -> void:
if secondary_node.spring_bones_internal.is_empty():
return
set_material_override(m)
var i: int = 0
var s_sk: Skeleton3D = secondary_node.skel
# Spring bones
mesh.surface_begin(Mesh.PRIMITIVE_LINES)
for spring_bone in secondary_node.spring_bones_internal:
var center_transform_inv: Transform3D = secondary_node.center_transforms_inv[secondary_node.springs_centers[i]]
for v in spring_bone.verlets:
var s_tr: Transform3D = Transform3D.IDENTITY
if v.bone_idx != -1:
s_tr = s_sk.get_bone_global_pose(v.bone_idx)
draw_line(s_tr.origin, center_transform_inv * v.current_tail, color)
for v in spring_bone.verlets:
var s_tr: Transform3D = Transform3D.IDENTITY
if v.bone_idx != -1:
s_tr = s_sk.get_bone_global_pose(v.bone_idx)
draw_sphere((center_transform_inv.basis * s_tr.basis).orthonormalized(), center_transform_inv * v.current_tail, v.radius, color)
i += 1
mesh.surface_end()
func draw_collider_groups() -> void:
if secondary_node.colliders_internal.is_empty():
return
set_material_override(m)
var i: int = 0
mesh.surface_begin(Mesh.PRIMITIVE_LINES)
for collider in secondary_node.colliders_internal:
var center_transform_inv: Transform3D = secondary_node.center_transforms_inv[secondary_node.colliders_centers[i]]
collider.draw_debug(mesh, center_transform_inv)
i += 1
mesh.surface_end()
func draw_sphere(bas: Basis, center: Vector3, radius: float, color: Color) -> void:
var step: int = 15
var sppi: float = 2 * PI / step
for i in range(1, step + 1):
mesh.surface_set_color(color)
mesh.surface_add_vertex(center + ((bas * Vector3.UP * radius).rotated(bas * Vector3.RIGHT, sppi * (i - 1 % step))))
mesh.surface_set_color(color)
mesh.surface_add_vertex(center + ((bas * Vector3.UP * radius).rotated(bas * Vector3.RIGHT, sppi * (i % step))))
for i in range(1, step + 1):
mesh.surface_set_color(color)
mesh.surface_add_vertex(center + ((bas * Vector3.RIGHT * radius).rotated(bas * Vector3.FORWARD, sppi * ((i - 1) % step))))
mesh.surface_set_color(color)
mesh.surface_add_vertex(center + ((bas * Vector3.RIGHT * radius).rotated(bas * Vector3.FORWARD, sppi * (i % step))))
for i in range(1, step + 1):
mesh.surface_set_color(color)
mesh.surface_add_vertex(center + ((bas * Vector3.FORWARD * radius).rotated(bas * Vector3.UP, sppi * ((i - 1) % step))))
mesh.surface_set_color(color)
mesh.surface_add_vertex(center + ((bas * Vector3.FORWARD * radius).rotated(bas * Vector3.UP, sppi * (i % step))))
func draw_line(begin_pos: Vector3, end_pos: Vector3, color: Color) -> void:
mesh.surface_set_color(color)
mesh.surface_add_vertex(begin_pos)
mesh.surface_set_color(color)
mesh.surface_add_vertex(end_pos)
@@ -0,0 +1 @@
uid://cfoeurjclosmf
@@ -0,0 +1,172 @@
@tool
class_name VRMSpringBone
extends Resource
const VRMSpringBoneLogic = preload("./vrm_spring_bone_logic.gd")
const vrm_collider_group = preload("./vrm_collider_group.gd")
const vrm_collider = preload("./vrm_collider.gd")
# Annotation comment
@export var comment: String
@export_group("Bone List (End bone may be left blank)")
# bone name of the root bone of the swaying object, within skeleton.
@export var joint_nodes: PackedStringArray
@export_group("Spring Settings")
@export_range(0, 10, 0.001, "or_greater") var stiffness_scale: float = 1.0
@export_range(0, 3, 0.001, "or_greater") var drag_force_scale: float = 1.0
@export_range(0, 1, 0.001, "or_greater") var hit_radius_scale: float = 1.0
@export_range(-10, 10, 0.001, "or_lesser", "or_greater") var gravity_scale: float = 1.0
@export var gravity_dir_default: Vector3 = Vector3(0, -1, 0)
# Reference to the vrm_collidergroup for collisions with swaying objects.
@export var collider_groups: Array[vrm_collider_group]
@export_group("Per-Joint Bone Settings (Optional)")
# The resilience of the swaying object (the power of returning to the initial pose).
@export var stiffness_force: PackedFloat64Array
# The strength of gravity.
@export var gravity_power: PackedFloat64Array
# The direction of gravity. Set (0, -1, 0) for simulating the gravity.
# Set (1, 0, 0) for simulating the wind.
@export var gravity_dir: PackedVector3Array
# The resistance (deceleration) of automatic animation.
@export var drag_force: PackedFloat64Array
# The radius of the sphere used for the collision detection with colliders.
@export var hit_radius: PackedFloat64Array
@export_group("Frame of Reference Node")
# The reference point of a swaying object can be set at any location except the origin.
# When implementing UI moving with warp, the parent node to move with warp can be
# specified if you don't want to make the object swaying with warp movement.",
# Exactly one of the following must be set.
@export var center_bone: String = ""
@export var center_node: NodePath = NodePath()
class SpringBoneRuntimeState:
extends RefCounted
# Props
var springbone: VRMSpringBone
var verlets: Array[VRMSpringBoneLogic]
var colliders: Array[vrm_collider.VrmRuntimeCollider]
var skel: Skeleton3D = null
var has_warned: bool = false
var disable_colliders: bool = false
var gravity_multiplier: float = 1.0
var gravity_rotation: Quaternion = Quaternion.IDENTITY
var add_force: Vector3 = Vector3.ZERO
var joint_nodes: PackedStringArray
var cached_center_bone: String
var cached_center_node: NodePath
var cached_collider_groups: Array[vrm_collider_group]
func _init(this_springbone: VRMSpringBone, skel: Skeleton3D):
springbone = this_springbone
joint_nodes = springbone.joint_nodes.duplicate()
cached_center_bone = springbone.center_bone
cached_center_node = springbone.center_node
cached_collider_groups = springbone.collider_groups
func setup(center_transform_inv: Transform3D, force: bool = false):
#if len(joint_nodes) < 2:
#if force and not has_warned:
#has_warned = true
#push_warning(str(resource_name) + ": Springbone chain has insufficient joints.")
#return
if not joint_nodes.is_empty() && skel != null:
if force || verlets.is_empty():
if not verlets.is_empty():
for verlet in verlets:
verlet.reset(skel)
verlets.clear()
for id in range(len(joint_nodes) - 1):
var verlet: VRMSpringBoneLogic = create_vertlet(id, center_transform_inv)
verlets.append(verlet)
func create_vertlet(id: int, center_tr_inv: Transform3D) -> VRMSpringBoneLogic:
var verlet: VRMSpringBoneLogic
if id < len(joint_nodes) - 1:
var bone_idx: int = skel.find_bone(joint_nodes[id])
var pos: Vector3
if joint_nodes[id + 1].is_empty():
var delta: Vector3 = skel.get_bone_rest(bone_idx).origin
pos = delta.normalized() * 0.07
else:
var first_child: int = skel.find_bone(joint_nodes[id + 1])
var local_position: Vector3 = skel.get_bone_rest(first_child).origin
var sca: Vector3 = skel.get_bone_rest(first_child).basis.get_scale()
pos = Vector3(local_position.x * sca.x, local_position.y * sca.y, local_position.z * sca.z)
verlet = VRMSpringBoneLogic.new(skel, bone_idx, center_tr_inv, pos, skel.get_bone_global_pose_no_override(id))
return verlet
func ready(ready_skel: Skeleton3D, colliders_ref: Array[vrm_collider.VrmRuntimeCollider], center_transform_inv: Transform3D) -> void:
if ready_skel != null:
skel = ready_skel
setup(center_transform_inv)
colliders = colliders_ref.duplicate(false)
func pre_update() -> bool: # Returns true if the springbone system must be fully reinitialized.
if Engine.is_editor_hint():
if len(springbone.joint_nodes) == len(joint_nodes) + 1 and len(springbone.joint_nodes) >= 2 and not springbone.joint_nodes[-2].is_empty() and springbone.joint_nodes[-1].is_empty():
if springbone.resource_name.is_empty() and not springbone.joint_nodes[0].is_empty():
springbone.resource_name = springbone.joint_nodes[0]
var par_bone := skel.find_bone(springbone.joint_nodes[-2])
if par_bone != -1:
var child_bones := skel.get_bone_children(par_bone)
if not child_bones.is_empty():
springbone.joint_nodes[-1] = skel.get_bone_name(child_bones[0])
if (springbone.center_bone != cached_center_bone or
springbone.center_node != cached_center_node or
springbone.joint_nodes != joint_nodes or
springbone.collider_groups != cached_collider_groups):
return true
if not ClassDB.class_exists(&"SkeletonModifier3D"):
for i in range(len(verlets)):
verlets[i].pre_update(skel)
return false
func update(delta: float, center_transform: Transform3D, center_transform_inv: Transform3D) -> void:
if verlets.is_empty() or len(verlets) != len(springbone.joint_nodes):
if joint_nodes.is_empty():
return
setup(center_transform_inv)
var tmp_colliders: Array[vrm_collider.VrmRuntimeCollider]
if not disable_colliders:
tmp_colliders = colliders
for i in range(len(verlets)):
var pfa: PackedFloat64Array = springbone.gravity_power
var external: Vector3 = (springbone.gravity_dir[i] if i < len(springbone.gravity_dir) else springbone.gravity_dir_default)
external = external * (1.0 if pfa.is_empty() else pfa[i] if i < len(pfa) else pfa[-1]) * delta * springbone.gravity_scale * gravity_multiplier
if !gravity_rotation.is_equal_approx(Quaternion.IDENTITY):
external = gravity_rotation * external
if !center_transform.basis.is_equal_approx(Basis.IDENTITY):
external = center_transform.basis.get_rotation_quaternion().inverse() * external
external += add_force * delta
pfa = springbone.stiffness_force
var stiffness: float = springbone.stiffness_scale * (1.0 if pfa.is_empty() else pfa[i] if i < len(pfa) else pfa[-1]) * delta
pfa = springbone.drag_force
var drag_force: float = springbone.drag_force_scale * (1.0 if pfa.is_empty() else pfa[i] if i < len(pfa) else pfa[-1])
pfa = springbone.hit_radius
verlets[i].radius = springbone.hit_radius_scale * (1.0 if pfa.is_empty() else pfa[i] if i < len(pfa) else pfa[-1])
verlets[i].update(skel, center_transform, center_transform_inv, stiffness, drag_force, external, tmp_colliders)
func create_runtime(skel: Skeleton3D) -> SpringBoneRuntimeState:
return SpringBoneRuntimeState.new(self, skel)
@@ -0,0 +1 @@
uid://doa525e85v1s5
@@ -0,0 +1,101 @@
extends RefCounted
const vrm_collider = preload("./vrm_collider.gd")
var force_update: bool = true
var bone_idx: int = -1
var parent_idx: int = -1
var radius: float = 0
var length: float = 0
var bone_axis: Vector3
var current_tail: Vector3
var prev_tail: Vector3
var initial_transform: Transform3D
var global_pose: Transform3D
static func from_to_rotation_safe(from: Vector3, to: Vector3) -> Quaternion:
var axis: Vector3 = from.cross(to)
if is_equal_approx(axis.x, 0.0) and is_equal_approx(axis.y, 0.0) and is_equal_approx(axis.z, 0.0):
return Quaternion.IDENTITY
var angle: float = from.angle_to(to)
if is_equal_approx(angle, 0.0):
angle = 0.0
return Quaternion(axis.normalized(), angle)
func get_global_pose(skel: Skeleton3D) -> Transform3D:
return skel.get_bone_global_pose(parent_idx) * skel.get_bone_pose(bone_idx)
func get_local_pose_rotation(skel: Skeleton3D) -> Quaternion:
return get_global_pose(skel).basis.get_rotation_quaternion()
func get_global_pose_cached() -> Transform3D:
return global_pose
func get_local_pose_rotation_cached() -> Quaternion:
return global_pose.basis.get_rotation_quaternion()
func reset(skel: Skeleton3D) -> void:
if not ClassDB.class_exists(&"SkeletonModifier3D"):
skel.set_bone_global_pose_override(bone_idx, initial_transform, 1.0, true)
func _init(skel: Skeleton3D, idx: int, center_transform_inv: Transform3D, local_child_position: Vector3, default_pose: Transform3D) -> void:
initial_transform = default_pose
global_pose = default_pose
bone_idx = idx
parent_idx = skel.get_bone_parent(idx)
var world_child_position: Vector3 = get_global_pose(skel) * local_child_position
current_tail = center_transform_inv * world_child_position
prev_tail = current_tail
bone_axis = local_child_position.normalized()
length = local_child_position.length()
func pre_update(skel: Skeleton3D) -> void:
global_pose = get_global_pose(skel)
func update(skel: Skeleton3D, center_transform: Transform3D, center_transform_inv: Transform3D, stiffness_force: float, drag_force: float, external: Vector3, colliders: Array[vrm_collider.VrmRuntimeCollider]) -> void:
var tmp_current_tail: Vector3 = current_tail
var tmp_prev_tail: Vector3 = prev_tail
if ClassDB.class_exists(&"SkeletonModifier3D"):
global_pose = get_global_pose(skel)
var global_pose_tr: Transform3D = get_global_pose_cached()
var local_pose_rotation: Quaternion = get_local_pose_rotation_cached()
# Integration of velocity verlet
var next_tail: Vector3 = tmp_current_tail + (tmp_current_tail - tmp_prev_tail) * (1.0 - drag_force) + center_transform.basis.get_rotation_quaternion() * (local_pose_rotation * bone_axis * stiffness_force + external)
# Limiting bone length
var origin: Vector3 = center_transform * global_pose_tr.origin
next_tail = origin + (next_tail - origin).normalized() * length
#next_tail = center_transform_inv * next_tail
# Collision movement
for collider in colliders:
next_tail = collider.collision(origin, radius, length, next_tail)
# Recording current tails for next process
prev_tail = current_tail # center_transform_inv * current_tail
current_tail = next_tail # center_transform_inv * next_tail
# Apply rotation
var ft = from_to_rotation_safe(local_pose_rotation * (bone_axis), center_transform_inv.basis * (next_tail - origin))
if typeof(ft) != TYPE_NIL:
# ft = skel.global_transform.basis.get_rotation_quaternion().inverse() * ft
var qt: Quaternion = ft * local_pose_rotation
global_pose_tr.basis = Basis(qt).scaled(global_pose_tr.basis.get_scale()) # Scaling here avoids the most egregious artifacts in a scaled character, but this math is not correct. Use scale 1,1,1
if ClassDB.class_exists(&"SkeletonModifier3D"):
skel.set_bone_global_pose(bone_idx, global_pose_tr)
else:
skel.set_bone_global_pose_override(bone_idx, global_pose_tr, 1.0, true)
@@ -0,0 +1 @@
uid://bsi1pnsroitvh
@@ -0,0 +1,33 @@
@tool
class_name VRMTopLevel
extends Node3D
const vrm_meta_class = preload("./vrm_meta.gd")
const spring_bone_class = preload("./vrm_spring_bone.gd")
const collider_class = preload("./vrm_collider.gd")
const collider_group_class = preload("./vrm_collider_group.gd")
@export var vrm_meta: Resource = (func():
var ret: vrm_meta_class = vrm_meta_class.new()
ret.resource_name = "CLICK TO SEE METADATA"
return ret
).call()
@export_category("Springbone Settings")
@export var update_secondary_fixed: bool = false
@export var disable_colliders: bool = false
@export var override_springbone_center: bool = false
@export var default_springbone_center: Node3D
@export var springbone_gravity_multiplier: float = 1.0
@export var springbone_gravity_rotation: Quaternion = Quaternion.IDENTITY
@export var springbone_add_force: Vector3 = Vector3.ZERO
@export_category("Run in Editor")
@export var update_in_editor: bool = false
@export var gizmo_spring_bone: bool = false
@export var gizmo_spring_bone_color: Color = Color.LIGHT_YELLOW
@export var spring_bones: Array[spring_bone_class]
@export var collider_groups: Array[collider_group_class]
@export var collider_library: Array[collider_class]
@@ -0,0 +1 @@
uid://c6gcjg17qcr77
@@ -0,0 +1,601 @@
extends RefCounted
const ROTATE_180_BASIS = Basis(Vector3(-1, 0, 0), Vector3(0, 1, 0), Vector3(0, 0, -1))
const ROTATE_180_TRANSFORM = Transform3D(ROTATE_180_BASIS, Vector3.ZERO)
const vrm_constants_class = preload("./vrm_constants.gd")
const importer_mesh_attributes = preload("./importer_mesh_attributes.gd")
static func adjust_mesh_zforward(mesh: ImporterMesh, blendshapes: Array):
# MESH and SKIN data divide, to compensate for object position multiplying.
var surf_count: int = mesh.get_surface_count()
var surf_data_by_mesh = [].duplicate()
for surf_idx in range(surf_count):
var prim: int = mesh.get_surface_primitive_type(surf_idx)
var fmt_compress_flags: int = mesh.get_surface_format(surf_idx)
var arr: Array = mesh.get_surface_arrays(surf_idx)
var name: String = mesh.get_surface_name(surf_idx)
var bscount = mesh.get_blend_shape_count()
var bsarr: Array[Array] = []
for bsidx in range(bscount):
bsarr.append(mesh.get_surface_blend_shape_arrays(surf_idx, bsidx))
var lods: Dictionary = {} # mesh.surface_get_lods(surf_idx) # get_lods(mesh, surf_idx)
var mat: Material = mesh.get_surface_material(surf_idx)
var vert_arr_len: int = len(arr[ArrayMesh.ARRAY_VERTEX])
var vertarr: PackedVector3Array = arr[ArrayMesh.ARRAY_VERTEX]
var invert_vector = Vector3(-1, 1, -1)
for i in range(vert_arr_len):
vertarr[i] = invert_vector * vertarr[i]
if typeof(arr[ArrayMesh.ARRAY_NORMAL]) == TYPE_PACKED_VECTOR3_ARRAY:
var normarr: PackedVector3Array = arr[ArrayMesh.ARRAY_NORMAL]
for i in range(vert_arr_len):
normarr[i] = invert_vector * normarr[i]
if typeof(arr[ArrayMesh.ARRAY_TANGENT]) == TYPE_PACKED_FLOAT32_ARRAY:
var tangarr: PackedFloat32Array = arr[ArrayMesh.ARRAY_TANGENT]
for i in range(vert_arr_len):
tangarr[i * 4] = -tangarr[i * 4]
tangarr[i * 4 + 2] = -tangarr[i * 4 + 2]
for bsidx in range(len(bsarr)):
vertarr = bsarr[bsidx][ArrayMesh.ARRAY_VERTEX]
for i in range(vert_arr_len):
vertarr[i] = invert_vector * vertarr[i]
if typeof(bsarr[bsidx][ArrayMesh.ARRAY_NORMAL]) == TYPE_PACKED_VECTOR3_ARRAY:
var normarr: PackedVector3Array = bsarr[bsidx][ArrayMesh.ARRAY_NORMAL]
for i in range(vert_arr_len):
normarr[i] = invert_vector * normarr[i]
if typeof(bsarr[bsidx][ArrayMesh.ARRAY_TANGENT]) == TYPE_PACKED_FLOAT32_ARRAY:
var tangarr: PackedFloat32Array = bsarr[bsidx][ArrayMesh.ARRAY_TANGENT]
for i in range(vert_arr_len):
tangarr[i * 4] = -tangarr[i * 4]
tangarr[i * 4 + 2] = -tangarr[i * 4 + 2]
bsarr[bsidx].resize(ArrayMesh.ARRAY_MAX)
surf_data_by_mesh.push_back({"prim": prim, "arr": arr, "bsarr": bsarr, "lods": lods, "fmt_compress_flags": fmt_compress_flags, "name": name, "mat": mat})
if blendshapes.is_empty():
for bsidx in mesh.get_blend_shape_count():
blendshapes.append(mesh.get_blend_shape_name(bsidx))
mesh.clear()
for blend_name in blendshapes:
mesh.add_blend_shape(blend_name)
for surf_idx in range(surf_count):
var prim: int = surf_data_by_mesh[surf_idx].get("prim")
var arr: Array = surf_data_by_mesh[surf_idx].get("arr")
var bsarr: Array[Array] = surf_data_by_mesh[surf_idx].get("bsarr")
var lods: Dictionary = surf_data_by_mesh[surf_idx].get("lods")
var fmt_compress_flags: int = surf_data_by_mesh[surf_idx].get("fmt_compress_flags")
var name: String = surf_data_by_mesh[surf_idx].get("name")
var mat: Material = surf_data_by_mesh[surf_idx].get("mat")
mesh.add_surface(prim, arr, bsarr, lods, mat, name, fmt_compress_flags)
static func rotate_scene_180_inner(p_node: Node3D, mesh_set: Dictionary, skin_set: Dictionary):
if p_node is Skeleton3D:
for bone_idx in range(p_node.get_bone_count()):
var rest: Transform3D = ROTATE_180_TRANSFORM * p_node.get_bone_rest(bone_idx) * ROTATE_180_TRANSFORM
p_node.set_bone_rest(bone_idx, rest)
p_node.set_bone_pose_rotation(bone_idx, Quaternion(ROTATE_180_BASIS) * p_node.get_bone_pose_rotation(bone_idx) * Quaternion(ROTATE_180_BASIS))
p_node.set_bone_pose_scale(bone_idx, Vector3.ONE)
p_node.set_bone_pose_position(bone_idx, rest.origin)
p_node.transform = ROTATE_180_TRANSFORM * p_node.transform * ROTATE_180_TRANSFORM
if p_node is ImporterMeshInstance3D:
mesh_set[p_node.mesh] = true
if p_node.skin != null:
skin_set[p_node.skin] = true
for child in p_node.get_children():
if child is Node3D:
rotate_scene_180_inner(child, mesh_set, skin_set)
static func generate_mesh_index_to_meshinstance_mapping(gstate : GLTFState) -> Dictionary:
var nodes = gstate.get_nodes()
var mesh_idx_to_meshinstance : Dictionary = {}
for i in range(nodes.size()):
var gltfnode: GLTFNode = nodes[i]
var mesh_idx: int = gltfnode.mesh
#print("node idx " + str(i) + " node name " + gltfnode.resource_name + " mesh idx " + str(mesh_idx))
if mesh_idx != -1:
var scenenode: ImporterMeshInstance3D = gstate.get_scene_node(i)
mesh_idx_to_meshinstance[mesh_idx] = scenenode
#print("insert " + str(mesh_idx) + " node name " + scenenode.name)
return mesh_idx_to_meshinstance
static func rotate_scene_180(p_scene: Node3D, blend_shape_names: Dictionary, gstate : GLTFState):
var mesh_set: Dictionary = {}
var skin_set: Dictionary = {}
rotate_scene_180_inner(p_scene, mesh_set, skin_set)
var mesh_idx_to_meshinstance : Dictionary = generate_mesh_index_to_meshinstance_mapping(gstate)
for mesh_index in mesh_idx_to_meshinstance.keys():
var mesh_node = mesh_idx_to_meshinstance[mesh_index]
var mesh = mesh_node.mesh
if mesh_index in blend_shape_names.keys():
adjust_mesh_zforward(mesh, blend_shape_names[mesh_index])
else:
adjust_mesh_zforward(mesh, [])
for skin in skin_set:
for b in range(skin.get_bind_count()):
skin.set_bind_pose(b, ROTATE_180_TRANSFORM * skin.get_bind_pose(b) * ROTATE_180_TRANSFORM)
static func apply_node_transforms(p_root_node: Node3D, p_skeleton: Skeleton3D) -> Vector3:
var global_transform: Transform3D = Transform3D.IDENTITY
var pr: Node3D = p_skeleton
while pr != null:
global_transform = pr.transform * global_transform
pr.transform = Transform3D.IDENTITY
pr = pr.get_parent() as Node3D
global_transform.origin = Vector3.ZERO
# get_scale_local() not exposed to GDScript?
var sign_det: float = sign(global_transform.basis.determinant())
var rowx: Vector3 = Vector3(global_transform.basis.x.x, global_transform.basis.y.x, global_transform.basis.z.x)
var rowy: Vector3 = Vector3(global_transform.basis.x.y, global_transform.basis.y.y, global_transform.basis.z.y)
var rowz: Vector3 = Vector3(global_transform.basis.x.z, global_transform.basis.y.z, global_transform.basis.z.z)
var global_transform_scale_local: Vector3 = sign_det * Vector3(rowx.length(), rowy.length(), rowz.length())
for bone_idx in p_skeleton.get_parentless_bones():
var new_rest: Transform3D = global_transform.orthonormalized() * p_skeleton.get_bone_rest(bone_idx)
p_skeleton.set_bone_rest(bone_idx, new_rest)
var q: PackedInt32Array = p_skeleton.get_parentless_bones()
var q_off: int = 0
while q_off < len(q):
var src_idx: int = q[q_off]
q_off += 1
var src_children: PackedInt32Array = p_skeleton.get_bone_children(src_idx)
q.append_array(src_children)
var bone_rest: Transform3D = p_skeleton.get_bone_rest(src_idx)
p_skeleton.set_bone_rest(src_idx, Transform3D(bone_rest.basis, bone_rest.origin * global_transform_scale_local))
p_skeleton.set_bone_pose_position(src_idx, bone_rest.origin * global_transform_scale_local)
p_skeleton.set_bone_pose_rotation(src_idx, bone_rest.basis.get_rotation_quaternion())
p_skeleton.set_bone_pose_scale(src_idx, bone_rest.basis.get_scale())
# TODO: Do animation tracks (vrm_animation)?
return global_transform_scale_local
static func skeleton_rename(gstate: GLTFState, p_base_scene: Node, p_skeleton: Skeleton3D, p_bone_map: BoneMap):
var original_bone_names_to_indices = {}
var original_indices_to_bone_names = {}
var original_indices_to_new_bone_names = {}
var skellen: int = p_skeleton.get_bone_count()
# Rename bones to their humanoid equivalents.
for i in range(skellen):
var bn: StringName = p_bone_map.find_profile_bone_name(p_skeleton.get_bone_name(i))
original_bone_names_to_indices[p_skeleton.get_bone_name(i)] = i
original_indices_to_bone_names[i] = p_skeleton.get_bone_name(i)
original_indices_to_new_bone_names[i] = bn
if bn != StringName():
p_skeleton.set_bone_name(i, bn)
var gnodes = gstate.nodes
var root_bone_name = "Root"
if p_skeleton.find_bone(root_bone_name) == -1:
p_skeleton.add_bone(root_bone_name)
var new_root_bone_id = p_skeleton.find_bone(root_bone_name)
for root_bone_id in p_skeleton.get_parentless_bones():
if root_bone_id != new_root_bone_id:
p_skeleton.set_bone_parent(root_bone_id, new_root_bone_id)
else:
push_warning("VRM0: Root bone already found despite rename")
for gnode in gnodes:
var bn: StringName = p_bone_map.find_profile_bone_name(gnode.resource_name)
if bn != StringName():
gnode.resource_name = bn
var nodes: Array[Node] = p_base_scene.find_children("*", "ImporterMeshInstance3D")
while not nodes.is_empty():
var mi: ImporterMeshInstance3D = nodes.pop_back() as ImporterMeshInstance3D
var skin: Skin = mi.skin
if skin:
var node = mi.get_node(mi.skeleton_path)
if node and node is Skeleton3D and node == p_skeleton:
skellen = skin.get_bind_count()
for i in range(skellen):
# Bone name from skin (un-remapped bone name)
var bind_bone_name: StringName = skin.get_bind_name(i)
if bind_bone_name.is_empty():
#bind_bone_name = node.get_bone_name(skin.get_bind_bone(i))
if skin.get_bind_bone(i) != -1:
break # Not using named binds: no need to rename skin.
var bone_name_from_skel: StringName = p_bone_map.find_profile_bone_name(bind_bone_name)
if not bone_name_from_skel.is_empty():
skin.set_bind_name(i, bone_name_from_skel)
# Rename bones in all Nodes by calling method.
nodes = p_base_scene.find_children("*")
p_skeleton.name = "GeneralSkeleton"
p_skeleton.set_unique_name_in_owner(true)
while not nodes.is_empty():
var nd = nodes.pop_back()
if nd.has_method(&"_notify_skeleton_bones_renamed"):
nd.call(&"_notify_skeleton_bones_renamed", p_base_scene, p_skeleton, p_bone_map)
static func skeleton_rotate(p_base_scene: Node, src_skeleton: Skeleton3D, p_bone_map: BoneMap, old_skeleton_global_rest: Array[Transform3D]) -> Array[Basis]:
# is_renamed: was skeleton_rename already invoked?
var is_renamed = true
var profile = p_bone_map.profile
var prof_skeleton = Skeleton3D.new()
for i in range(profile.bone_size):
# Add single bones.
prof_skeleton.add_bone(profile.get_bone_name(i))
prof_skeleton.set_bone_rest(i, profile.get_reference_pose(i))
for i in range(profile.bone_size):
# Set parents.
var parent = profile.find_bone(profile.get_bone_parent(i))
if parent >= 0:
prof_skeleton.set_bone_parent(i, parent)
# Overwrite axis.
var old_skeleton_rest: Array[Transform3D]
old_skeleton_global_rest.clear()
for i in range(src_skeleton.get_bone_count()):
old_skeleton_rest.push_back(src_skeleton.get_bone_rest(i))
old_skeleton_global_rest.push_back(src_skeleton.get_bone_global_rest(i))
var diffs: Array[Basis]
diffs.resize(src_skeleton.get_bone_count())
# Short circuit the rotations
if false:
prof_skeleton.queue_free()
return diffs
var bones_to_process: PackedInt32Array = src_skeleton.get_parentless_bones()
var bpidx = 0
while bpidx < len(bones_to_process):
var src_idx: int = bones_to_process[bpidx]
bpidx += 1
var src_children: PackedInt32Array = src_skeleton.get_bone_children(src_idx)
for bone_idx in src_children:
bones_to_process.push_back(bone_idx)
var tgt_rot: Basis
var src_bone_name: StringName = StringName(src_skeleton.get_bone_name(src_idx)) if is_renamed else p_bone_map.find_profile_bone_name(src_skeleton.get_bone_name(src_idx))
if src_bone_name != StringName():
var src_pg: Basis
var src_parent_idx: int = src_skeleton.get_bone_parent(src_idx)
if src_parent_idx >= 0:
src_pg = src_skeleton.get_bone_global_rest(src_parent_idx).basis
var prof_idx: int = profile.find_bone(src_bone_name)
if prof_idx >= 0:
tgt_rot = src_pg.inverse() * prof_skeleton.get_bone_global_rest(prof_idx).basis # Mapped bone uses reference pose.
if src_skeleton.get_bone_parent(src_idx) >= 0:
diffs[src_idx] = (tgt_rot.inverse() * diffs[src_skeleton.get_bone_parent(src_idx)] * src_skeleton.get_bone_rest(src_idx).basis)
else:
diffs[src_idx] = tgt_rot.inverse() * src_skeleton.get_bone_rest(src_idx).basis
var diff: Basis
if src_skeleton.get_bone_parent(src_idx) >= 0:
diff = diffs[src_skeleton.get_bone_parent(src_idx)]
src_skeleton.set_bone_rest(src_idx, Transform3D(tgt_rot, diff * src_skeleton.get_bone_rest(src_idx).origin))
prof_skeleton.queue_free()
return diffs
static func apply_mesh_rotation(p_base_scene: Node, src_skeleton: Skeleton3D, old_skeleton_global_rest: Array[Transform3D], global_transform_scale_local: Vector3):
# Fix skin.
var scale_xform: Transform3D = Transform3D(Basis.from_scale(global_transform_scale_local), Vector3.ZERO)
var nodes: Array[Node] = p_base_scene.find_children("*", "ImporterMeshInstance3D")
var mutated_skins: Dictionary
while not nodes.is_empty():
var this_node = nodes.pop_back()
if this_node is ImporterMeshInstance3D:
var mi = this_node
var skin: Skin = mi.skin
var node = mi.get_node_or_null(mi.skeleton_path)
if skin and node and node is Skeleton3D and node == src_skeleton:
if mutated_skins.has(skin):
continue
mutated_skins[skin] = true
var skellen = skin.get_bind_count()
for i in range(skellen):
var bn: StringName = skin.get_bind_name(i)
if bn == &"":
bn = node.get_bone_name(skin.get_bind_bone(i))
var bone_idx: int = src_skeleton.find_bone(bn)
if bone_idx >= 0:
var adjust_transform: Transform3D = src_skeleton.get_bone_global_rest(bone_idx).affine_inverse() * old_skeleton_global_rest[bone_idx]
adjust_transform = adjust_transform.scaled(global_transform_scale_local)
# silhouette_diff[i] is not used because VRM files must be in T-Pose before export.
skin.set_bind_pose(i, adjust_transform * skin.get_bind_pose(i))
nodes = src_skeleton.get_children()
while not nodes.is_empty():
var attachment: BoneAttachment3D = nodes.pop_back() as BoneAttachment3D
if attachment == null:
continue
var bone_idx: int = attachment.bone_idx
if bone_idx == -1:
bone_idx = src_skeleton.find_bone(attachment.bone_name)
var adjust_transform: Transform3D = src_skeleton.get_bone_global_rest(bone_idx).affine_inverse() * old_skeleton_global_rest[bone_idx]
adjust_transform = adjust_transform.scaled(global_transform_scale_local)
var child_nodes: Array[Node] = attachment.get_children()
while not child_nodes.is_empty():
var child: Node3D = child_nodes.pop_back() as Node3D
if child == null:
continue
child.transform = adjust_transform * child.transform
# Init skeleton pose to new rest.
for i in range(src_skeleton.get_bone_count()):
var fixed_rest: Transform3D = src_skeleton.get_bone_rest(i)
src_skeleton.set_bone_pose_position(i, fixed_rest.origin)
src_skeleton.set_bone_pose_rotation(i, fixed_rest.basis.get_rotation_quaternion())
src_skeleton.set_bone_pose_scale(i, fixed_rest.basis.get_scale())
static func perform_retarget(gstate: GLTFState, root_node: Node, skeleton: Skeleton3D, bone_map: BoneMap) -> Array[Basis]:
var skeletonPath: NodePath = root_node.get_path_to(skeleton)
var global_transform_scale_local: Vector3 = apply_node_transforms(root_node, skeleton)
skeleton_rename(gstate, root_node, skeleton, bone_map)
var old_skeleton_global_rest: Array[Transform3D]
var poses = skeleton_rotate(root_node, skeleton, bone_map, old_skeleton_global_rest)
apply_mesh_rotation(root_node, skeleton, old_skeleton_global_rest, global_transform_scale_local)
var hips_bone_idx = skeleton.find_bone("Hips")
if hips_bone_idx != -1:
skeleton.motion_scale = abs(skeleton.get_bone_global_rest(hips_bone_idx).origin.y)
if skeleton.motion_scale < 0.0001:
skeleton.motion_scale = 1.0
return poses
static func _recurse_bones(bones: Dictionary, skel: Skeleton3D, bone_idx: int):
bones[skel.get_bone_name(bone_idx)] = bone_idx
for child in skel.get_bone_children(bone_idx):
_recurse_bones(bones, skel, child)
static func _generate_hide_bone_mesh(mesh: ImporterMesh, skin: Skin, bone_names_to_hide: Dictionary, blendshapes: Array) -> ImporterMesh:
var bind_indices_to_hide: Dictionary = {}
for i in range(skin.get_bind_count()):
var bind_name: StringName = skin.get_bind_name(i)
if bind_name != &"":
if bone_names_to_hide.has(bind_name):
bind_indices_to_hide[i] = true
else: # non-named binds???
if bone_names_to_hide.values().count(skin.get_bind_bone(i)) != 0:
bind_indices_to_hide[i] = true
# MESH and SKIN data divide, to compensate for object position multiplying.
var surf_count: int = mesh.get_surface_count()
var surf_data_by_mesh = [].duplicate()
var did_hide_any_surface_verts: bool = false
for surf_idx in range(surf_count):
var prim: int = mesh.get_surface_primitive_type(surf_idx)
var fmt_compress_flags: int = mesh.get_surface_format(surf_idx)
var arr: Array = mesh.get_surface_arrays(surf_idx).duplicate(true)
var name: String = mesh.get_surface_name(surf_idx)
var bscount = mesh.get_blend_shape_count()
var bsarr: Array[Array] = []
for bsidx in range(bscount):
bsarr.append(mesh.get_surface_blend_shape_arrays(surf_idx, bsidx).duplicate(true))
var lods: Dictionary = {} # mesh.surface_get_lods(surf_idx) # get_lods(mesh, surf_idx)
var mat: Material = mesh.get_surface_material(surf_idx)
var vert_arr_len: int = len(arr[ArrayMesh.ARRAY_VERTEX])
var hide_verts: PackedInt32Array
hide_verts.resize(vert_arr_len)
var did_hide_verts: bool = false
if typeof(arr[ArrayMesh.ARRAY_BONES]) == TYPE_PACKED_INT32_ARRAY and typeof(arr[ArrayMesh.ARRAY_WEIGHTS]) == TYPE_PACKED_FLOAT32_ARRAY:
var bonearr: PackedInt32Array = arr[ArrayMesh.ARRAY_BONES]
var weightarr: PackedFloat32Array = arr[ArrayMesh.ARRAY_WEIGHTS]
var bones_per_vert = len(bonearr) / vert_arr_len
var outidx = 0
for i in range(vert_arr_len):
var keepvert = true
for j in range(bones_per_vert):
if not is_zero_approx(weightarr[i * bones_per_vert + j]) and bind_indices_to_hide.has(bonearr[i * bones_per_vert + j]):
hide_verts[i] = 1
did_hide_verts = true
did_hide_any_surface_verts = true
break
if did_hide_verts and prim == Mesh.PRIMITIVE_TRIANGLES:
var indexarr: PackedInt32Array = arr[ArrayMesh.ARRAY_INDEX]
var new_indexarr: PackedInt32Array = PackedInt32Array()
var cnt: int = 0
for i in range(0, len(indexarr) - 2, 3):
if hide_verts[indexarr[i]] == 0 && hide_verts[indexarr[i + 1]] == 0 && hide_verts[indexarr[i + 2]] == 0:
cnt += 3
if cnt == 0:
continue # We skip this primitive entirely.
new_indexarr.resize(cnt)
cnt = 0
for i in range(0, len(indexarr) - 2, 3):
if hide_verts[indexarr[i]] == 0 && hide_verts[indexarr[i + 1]] == 0 && hide_verts[indexarr[i + 2]] == 0:
new_indexarr[cnt] = indexarr[i]
new_indexarr[cnt + 1] = indexarr[i + 1]
new_indexarr[cnt + 2] = indexarr[i + 2]
cnt += 3
arr[ArrayMesh.ARRAY_INDEX] = new_indexarr
surf_data_by_mesh.push_back({"prim": prim, "arr": arr, "bsarr": bsarr, "lods": lods, "fmt_compress_flags": fmt_compress_flags, "name": name, "mat": mat})
if len(surf_data_by_mesh) == 0: # all primitives were gobbled up
return null
if not did_hide_any_surface_verts:
return mesh
var new_mesh: ImporterMesh = ImporterMesh.new()
new_mesh.set_blend_shape_mode(mesh.get_blend_shape_mode())
new_mesh.set_lightmap_size_hint(mesh.get_lightmap_size_hint())
new_mesh.resource_name = mesh.resource_name + "_HeadHidden"
if blendshapes.is_empty():
for bsidx in mesh.get_blend_shape_count():
blendshapes.append(mesh.get_blend_shape_name(bsidx))
for blend_name in blendshapes:
new_mesh.add_blend_shape(blend_name)
for surf_idx in range(len(surf_data_by_mesh)):
var prim: int = surf_data_by_mesh[surf_idx].get("prim")
var arr: Array = surf_data_by_mesh[surf_idx].get("arr")
var bsarr: Array[Array] = surf_data_by_mesh[surf_idx].get("bsarr")
var lods: Dictionary = surf_data_by_mesh[surf_idx].get("lods")
var fmt_compress_flags: int = surf_data_by_mesh[surf_idx].get("fmt_compress_flags")
var name: String = surf_data_by_mesh[surf_idx].get("name")
var mat: Material = surf_data_by_mesh[surf_idx].get("mat")
new_mesh.add_surface(prim, arr, bsarr, lods, mat, name, fmt_compress_flags)
return new_mesh
static func perform_head_hiding(gstate: GLTFState, mesh_annotations_by_node: Dictionary, head_relative_bones: Dictionary, node_to_head_hidden_node: Dictionary):
var meshes = gstate.get_meshes()
var nodes = gstate.get_nodes()
var head_hiding_method_prop = gstate.get_additional_data(&"vrm/head_hiding_method")
var head_hiding_method := vrm_constants_class.HeadHidingSetting.ThirdPersonOnly
if typeof(head_hiding_method_prop) == TYPE_INT:
head_hiding_method = head_hiding_method_prop
if head_hiding_method == vrm_constants_class.HeadHidingSetting.IgnoreHeadHiding:
return
var layer_mask_first_prop = gstate.get_additional_data(&"vrm/first_person_layers")
var layer_mask_first := 2
if typeof(layer_mask_first_prop) == TYPE_INT:
layer_mask_first = layer_mask_first_prop
var layer_mask_third_prop = gstate.get_additional_data(&"vrm/third_person_layers")
var layer_mask_third := 4
if typeof(layer_mask_third_prop) == TYPE_INT:
layer_mask_third = layer_mask_third_prop
for node_idx in range(len(nodes)):
var gltf_node: GLTFNode = nodes[node_idx]
var node_node: Node = gstate.get_scene_node(node_idx)
if node_node is ImporterMeshInstance3D:
var node := node_node as ImporterMeshInstance3D
var flag: String = mesh_annotations_by_node.get(node_idx, "auto")
# Non-skinned meshes: use flag.
var mesh: ImporterMesh = node.mesh
var head_hidden_mesh: ImporterMesh = mesh
if flag == "auto" and head_hiding_method != vrm_constants_class.HeadHidingSetting.ThirdPersonOnly:
if node.skin == null:
var parent_node = node.get_parent()
if parent_node is BoneAttachment3D:
if head_relative_bones.has(parent_node.bone_name):
flag = "thirdPersonOnly"
else:
var blend_shape_names: Dictionary = _extract_blendshape_names(gstate.json)
if node_idx in blend_shape_names.keys():
head_hidden_mesh = _generate_hide_bone_mesh(mesh, node.skin, head_relative_bones, blend_shape_names[node_idx])
else:
head_hidden_mesh = _generate_hide_bone_mesh(mesh, node.skin, head_relative_bones, [])
if head_hidden_mesh == null:
flag = "thirdPersonOnly"
if head_hidden_mesh == mesh:
flag = "both" # Nothing to do: No head verts.
var layer_mask: int = layer_mask_first | layer_mask_third # "both"
if flag == "thirdPersonOnly":
layer_mask = layer_mask_third
if head_hiding_method == vrm_constants_class.HeadHidingSetting.FirstPersonOnly:
node.mesh = null # FIXME: How to exclude this node?
continue
elif flag == "firstPersonOnly":
layer_mask = layer_mask_first
if head_hiding_method == vrm_constants_class.HeadHidingSetting.ThirdPersonOnly:
node.mesh = null # FIXME: How to exclude this node?
continue
node.script = importer_mesh_attributes
node.layers = node.orig_layers
node.shadow = node.orig_shadow
var head_hidden_node: ImporterMeshInstance3D = null
var duplicate_shadow_node: ImporterMeshInstance3D = null
if head_hiding_method == vrm_constants_class.HeadHidingSetting.FirstPersonOnlyWithShadow:
if flag == "firstPersonOnly":
layer_mask = layer_mask_first
node.shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
if flag == "thirdPersonOnly":
node.shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_SHADOWS_ONLY
if flag == "auto" and head_hidden_mesh != mesh: # If it is still "auto", we have something to hide.
if (head_hiding_method == vrm_constants_class.HeadHidingSetting.BothLayers or
head_hiding_method == vrm_constants_class.HeadHidingSetting.BothLayersWithShadow or
head_hiding_method == vrm_constants_class.HeadHidingSetting.FirstPersonOnlyWithShadow):
head_hidden_node = ImporterMeshInstance3D.new()
head_hidden_node.name = node.name + " (Headless)"
head_hidden_node.skin = node.skin
head_hidden_node.mesh = head_hidden_mesh
head_hidden_node.skeleton_path = node.skeleton_path
head_hidden_node.script = importer_mesh_attributes
head_hidden_node.layers = node.layers
head_hidden_node.first_person_flag = "head_removed"
node.add_sibling(head_hidden_node)
head_hidden_node.owner = node.owner
var gltf_mesh: GLTFMesh = GLTFMesh.new()
gltf_mesh.mesh = head_hidden_mesh
# FIXME: do we need to assign gltf_mesh.instance_materials?
meshes.append(gltf_mesh)
node_to_head_hidden_node[node] = head_hidden_node
layer_mask = layer_mask_third
elif head_hiding_method == vrm_constants_class.HeadHidingSetting.FirstPersonOnly:
for m in meshes:
if m.mesh == mesh:
m.mesh = head_hidden_mesh
node.mesh = head_hidden_mesh
if head_hidden_node != null:
if head_hiding_method == vrm_constants_class.HeadHidingSetting.FirstPersonOnlyWithShadow:
node.shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_SHADOWS_ONLY
if head_hiding_method == vrm_constants_class.HeadHidingSetting.BothLayersWithShadow:
if flag == "thirdPersonOnly" or head_hidden_node != null:
duplicate_shadow_node = ImporterMeshInstance3D.new()
duplicate_shadow_node.name = node.name + " (Shadow)"
duplicate_shadow_node.skin = node.skin
duplicate_shadow_node.mesh = mesh
duplicate_shadow_node.skeleton_path = node.skeleton_path
duplicate_shadow_node.script = importer_mesh_attributes
duplicate_shadow_node.layers = node.layers
duplicate_shadow_node.shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_SHADOWS_ONLY
duplicate_shadow_node.first_person_flag = "head_removed"
node.add_sibling(duplicate_shadow_node)
duplicate_shadow_node.owner = node.owner
if head_hidden_node != null:
node_to_head_hidden_node[duplicate_shadow_node] = head_hidden_node
node_to_head_hidden_node[node] = duplicate_shadow_node
if (layer_mask_first != 0 and layer_mask != 0 and
(head_hiding_method == vrm_constants_class.HeadHidingSetting.BothLayers or
head_hiding_method == vrm_constants_class.HeadHidingSetting.BothLayersWithShadow)):
if node.layers & layer_mask_first == 0 or node.layers & layer_mask_third == 0:
if head_hidden_node != null:
head_hidden_node.layers = layer_mask_first
if duplicate_shadow_node != null:
duplicate_shadow_node.layers = layer_mask_first
node.layers = layer_mask
else:
if head_hidden_node != null:
head_hidden_node.layers = node.layers & layer_mask_first
if duplicate_shadow_node != null:
duplicate_shadow_node.layers = node.layers & layer_mask_first
node.layers = node.layers & layer_mask
node.first_person_flag = flag
gstate.meshes = meshes
static func _extract_blendshape_names(gltf_json: Dictionary) -> Dictionary:
# Extracts the blendshape targetNames from the GLTF json
# Returns Dictionary with blendshape names of meshes with targetNames sorted by the mesh id
var blend_shape_names: Dictionary = {}
for node_json in gltf_json["nodes"]:
if node_json.has("mesh"):
if gltf_json["meshes"][node_json["mesh"]]["primitives"][0].has("extras"):
if gltf_json["meshes"][node_json["mesh"]]["primitives"][0]["extras"].has("targetNames"):
blend_shape_names[int(node_json["mesh"])] = gltf_json["meshes"][node_json["mesh"]]["primitives"][0]["extras"]["targetNames"]
return blend_shape_names
@@ -0,0 +1 @@
uid://dhn20pflnapkg
@@ -0,0 +1 @@
@@ -0,0 +1,89 @@
# Live Debugging From The Godot Editor
Use this workflow when Electron is the real host and the model is selected from
the Tamagotchi settings window, but the imported runtime scene needs to be
inspected in the Godot editor.
## Required Process Order
1. Start the Godot editor against this project:
```powershell
& $env:GODOT4 -e --path .\engines\stage-tamagotchi-godot
```
2. In the Godot editor, enable:
```text
Debug -> Keep Debug Server Open
```
3. Start the Electron development app from a shell that already has remote
debugging enabled:
```powershell
$env:GODOT_STAGE_REMOTE_DEBUG = "1"
$env:GODOT_STAGE_REMOTE_DEBUG_URI = "tcp://127.0.0.1:6007"
nr dev:tamagotchi
```
4. In the Tamagotchi settings window, start the experimental Godot stage and
select a VRM model.
5. In the Godot editor, inspect the running scene:
```text
Scene dock -> Remote -> /root/Node3D/AvatarRoot/Avatar_<modelId>
```
Do not use the editor's Run button for this integration path. The editor-run
process does not receive Electron's `--airi-ws-url`, so it cannot show the model
materialized by Tamagotchi and sent over the sidecar WebSocket.
## How It Works
When `GODOT_STAGE_REMOTE_DEBUG=1` is set, Electron launches the Godot sidecar
with Godot debugger arguments before the engine `--` separator:
```text
--remote-debug tcp://127.0.0.1:6007
```
The sidecar process also receives `--airi-ws-url=<runtime-url>` after the
separator so `StageRoot` can connect back to Electron main.
## Troubleshooting
If the Godot editor only shows the local scene tree:
- Confirm the Godot editor was started before the sidecar.
- Confirm `Debug -> Keep Debug Server Open` is enabled.
- Confirm `nr dev:tamagotchi` was started after setting
`GODOT_STAGE_REMOTE_DEBUG=1`.
- Close the Godot stage sidecar window and start it again from the Tamagotchi
settings window.
- On Windows, confirm the editor is listening:
```powershell
Get-NetTCPConnection -LocalPort 6007
```
- Confirm the sidecar process includes `--remote-debug` and `--airi-ws-url`:
```powershell
Get-CimInstance Win32_Process |
Where-Object { $_.Name -like '*Godot*' } |
Select-Object ProcessId,CommandLine
```
If the imported model is distorted, first verify that the runtime importer uses
the same named-skin-bind flag as the V-Sekai editor importer. The AIRI runtime
import path defines this as `IMPORT_USE_NAMED_SKIN_BINDS := 16` in
`scripts/vrm/VrmRuntimeImporter.gd`.
If a `.vrm` file imports correctly in the Godot editor but does not appear
through the sidecar runtime path, check the file's glTF extension keys. The
current AIRI runtime bridge covers the VRM 0.x `extensions.VRM` path. VRM 1.0
files use `extensions.VRMC_vrm` and require the vendored `addons/vrm/1.0/VRMC_*`
extensions to be registered in the runtime importer before support can be
claimed.
@@ -0,0 +1,71 @@
# Vendored Add-on Local Patches
This project vendors Godot add-ons under `addons/` because Godot plugins are
installed as project-local source and asset folders. Keep this file in sync
whenever vendored add-on files differ from their upstream source.
## Upstream Baselines
- `addons/vrm`
- Repository: `https://github.com/V-Sekai/godot-vrm`
- Branch: `only-addon`
- Commit: `651205484c35f5cd7ba56475ff636e10db8ad674`
- `addons/Godot-MToon-Shader`
- Repository: `https://github.com/V-Sekai/Godot-MToon-Shader`
- Branch: `main`
- Commit: `268c0d3b19c0885698b7bd39e21a16c9c2af448f`
## Source Patches
### `addons/vrm/vrm_extension.gd`
- Local change: use `root_node.get_node_or_null("secondary")` instead of
`root_node.get_node("secondary")`.
- Reason: some VRM 0.0 exporters include `secondaryAnimation` data without a
scene node named `secondary`. The upstream `get_node()` call throws before
the existing null fallback can create the node.
- Validation: comparing vendored source files against upstream commit
`651205484c35f5cd7ba56475ff636e10db8ad674` shows this as the only changed
`.gd`/`.shader`/`.cfg`/`.cs` file under `addons/`. Runtime import then
completes without the `Node not found: "secondary"` importer error.
- Removal condition: remove this patch after the upstream add-on ships the same
lookup fix or otherwise handles missing `secondary` nodes before parsing
spring bones.
## Generated Metadata Differences
These files differ from the upstream commit after opening/importing the add-on
with Godot `4.6.2`. They are not AIRI behavior patches, but they are recorded so
future add-on upgrades can distinguish generated metadata churn from intentional
source changes.
### SVG Import Metadata
- `addons/vrm/node_constraint/icons/bone_node_constraint.svg.import`
- `addons/vrm/node_constraint/icons/bone_node_constraint_applier.svg.import`
Observed difference:
- Godot `4.6.2` adds current texture import fields such as
`compress/uastc_level`, `compress/rdo_quality_loss`, and
`process/channel_remap/*`.
### Godot UID Sidecars
Godot generated `.uid` sidecar files under:
- `addons/vrm/**/*.uid`
- `addons/Godot-MToon-Shader/**/*.uid`
These preserve Godot resource UIDs for imported scripts and shader resources.
They are local generated metadata, not source patches.
## Upgrade Checklist
When updating the vendored add-ons:
1. Compare the new upstream add-on against the current vendored tree.
2. Re-apply source patches listed above only if the upstream fix is still absent.
3. Let Godot regenerate import metadata and `.uid` sidecars if needed.
4. Update this file with the new upstream commit and the remaining local patch
list.
@@ -0,0 +1,103 @@
# VRM Runtime Import
The G1.1 Godot stage accepts `.vrm` scene input only. The renderer and Electron
main gate the model format to `vrm`; they do not currently distinguish VRM 0.x
from VRM 1.0.
## Host Boundary
The Electron settings renderer keeps using the existing selected-model store.
When Godot stage mode is active, Electron main materializes the selected VRM
bytes under `userData/godot-stage/models/<modelId>/<fileName>` and sends the
native file path to the Godot sidecar over the local WebSocket bridge.
Godot does not own the materialized file lifecycle. It owns only runtime nodes
and resources created from the imported file.
## Runtime Import Path
Runtime import is routed through:
```text
scripts/vrm/VrmAvatarLoader.cs
-> scripts/vrm/VrmRuntimeImporter.gd
-> scripts/vrm/AiriVrmRuntimeExtension.gd
-> addons/vrm/vrm_extension.gd
```
This is the current VRM 0.x runtime path. It exists because the vendored
`addons/vrm/import_vrm.gd` importer is an editor `EditorSceneFormatImporter`,
and the editor plugin is not active in the exported sidecar runtime.
`VrmRuntimeImporter.gd` mirrors the V-Sekai editor importer where runtime import
needs the same behavior:
- Registers the GLTF document extension through Godot's static
`GLTFDocument.register_gltf_document_extension(...)` API.
- Sets `GLTFState.HANDLE_BINARY_EMBED_AS_UNCOMPRESSED`.
- Uses `IMPORT_USE_NAMED_SKIN_BINDS := 16`, matching
`EditorSceneFormatImporter.IMPORT_USE_NAMED_SKIN_BINDS` from
`addons/vrm/import_vrm.gd`.
The named-skin-bind flag is required for the current VRM samples. Without it,
the model can import but the mesh and skeleton binding can be visibly distorted.
## Version Boundary
There are two independent boundaries:
- VRM version: VRM 0.x files use the glTF extension key `VRM`; VRM 1.0 files use
`VRMC_vrm` plus related `VRMC_*` extension keys.
- Runtime support: the V-Sekai add-on has editor import support, but it does not
expose a stable high-level runtime API such as `load_vrm(path) -> Node`.
The current AIRI runtime bridge covers the VRM 0.x path:
```text
extensions.VRM
-> scripts/vrm/AiriVrmRuntimeExtension.gd
-> addons/vrm/vrm_extension.gd
```
It does not yet register the vendored VRM 1.0 runtime extension set:
```text
extensions.VRMC_vrm
-> addons/vrm/1.0/VRMC_vrm.gd
-> addons/vrm/1.0/VRMC_springBone.gd
-> addons/vrm/1.0/VRMC_materials_mtoon.gd
-> addons/vrm/1.0/VRMC_node_constraint.gd
-> addons/vrm/1.0/VRMC_materials_hdr_emissiveMultiplier.gd
```
Do not treat `format: "vrm"` as a claim that all VRM versions are fully covered
by the sidecar runtime importer. VRM 1.0 runtime import needs a real fixture and
separate registration of the vendored `VRMC_*` extensions before it should be
claimed as supported.
## AIRI Runtime Extension
`AiriVrmRuntimeExtension.gd` extends the vendored V-Sekai VRM 0.x extension.
It exists because Godot 4.6 reports an internal missing-key error when
`GLTFState.get_additional_data(&"vrm/already_processed")` reads an unset key.
The AIRI runtime importer seeds that key before import, and the AIRI extension
treats only `true` as already processed. This preserves the vendor preflight
behavior while avoiding the debugger error during sidecar runtime import.
This key is also why VRM 1.0 cannot be enabled by only registering
`addons/vrm/1.0/VRMC_vrm.gd` in the current importer. `VRMC_vrm.gd` skips import
when `vrm/already_processed` is already set, while the VRM 0.x workaround seeds
that key before `append_from_file(...)`.
## Node Lifecycle
`StageSceneController` applies a new avatar in this order:
1. Import the new VRM into a detached node.
2. Add the imported node under `AvatarRoot`.
3. Replace the current avatar reference.
4. Remove the previous avatar from `AvatarRoot`.
5. Queue the previous avatar for freeing.
If import fails, the previous avatar remains visible.

Some files were not shown because too many files have changed in this diff Show More