diff --git a/apps/stage-tamagotchi/src/main/services/airi/godot-stage/index.test.ts b/apps/stage-tamagotchi/src/main/services/airi/godot-stage/index.test.ts deleted file mode 100644 index 93801af06..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/godot-stage/index.test.ts +++ /dev/null @@ -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 - id: string - request: { - url?: string - } - send: ReturnType -} - -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 - 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'), - }) - }) -}) diff --git a/apps/stage-tamagotchi/src/main/services/airi/godot-stage/index.ts b/apps/stage-tamagotchi/src/main/services/airi/godot-stage/index.ts index f71ea63cd..a427d87e6 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/godot-stage/index.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/godot-stage/index.ts @@ -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>[ type GodotStageMessage = Parameters>[1] type GodotStageProcess = ChildProcessByStdio +const DEFAULT_GODOT_REMOTE_DEBUG_URI = 'tcp://127.0.0.1:6007' + interface Deferred { promise: Promise 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 /** * 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(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).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, }) }) diff --git a/apps/stage-tamagotchi/src/renderer/pages/settings/models/godot-scene-input.ts b/apps/stage-tamagotchi/src/renderer/pages/settings/models/godot-scene-input.ts new file mode 100644 index 000000000..b26e69970 --- /dev/null +++ b/apps/stage-tamagotchi/src/renderer/pages/settings/models/godot-scene-input.ts @@ -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.') +} diff --git a/apps/stage-tamagotchi/src/renderer/pages/settings/models/index.vue b/apps/stage-tamagotchi/src/renderer/pages/settings/models/index.vue index a23f3394e..3625298b3 100644 --- a/apps/stage-tamagotchi/src/renderer/pages/settings/models/index.vue +++ b/apps/stage-tamagotchi/src/renderer/pages/settings/models/index.vue @@ -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 { + assertGodotSceneInputSupportedDisplayModel(model) + return { modelId: model.id, - format: model.format, + format: DisplayModelFormat.VRM, name: model.name, fileName: inferModelFileName(model), data: await readSceneInputData(model), diff --git a/apps/stage-tamagotchi/src/shared/eventa/index.ts b/apps/stage-tamagotchi/src/shared/eventa/index.ts index b99a5accd..360fbb20d 100644 --- a/apps/stage-tamagotchi/src/shared/eventa/index.ts +++ b/apps/stage-tamagotchi/src/shared/eventa/index.ts @@ -328,7 +328,7 @@ export interface ElectronGodotStageStatus { */ export interface ElectronGodotStageSceneInputPayload { modelId: string - format: string + format: 'vrm' name: string fileName: string data: Uint8Array diff --git a/docs/ai/context/plans/2026-05-09-character-cards-cloud-sync-design.md b/docs/ai/context/plans/2026-05-09-character-cards-cloud-sync-design.md index 4e3c1e90e..e619571c4 100644 --- a/docs/ai/context/plans/2026-05-09-character-cards-cloud-sync-design.md +++ b/docs/ai/context/plans/2026-05-09-character-cards-cloud-sync-design.md @@ -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 `useAiriCardStore` 现有 `cards: Map` + `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 // by clientId, 最后一笔操作覆盖前面 + pendingOps: Map // by clientId, 最后一笔操作覆盖前面 lastSyncedAt: number | null lastError: string | null } diff --git a/engines/stage-tamagotchi-godot/.gitignore b/engines/stage-tamagotchi-godot/.gitignore index 0af181cfb..cb4d9df6d 100644 --- a/engines/stage-tamagotchi-godot/.gitignore +++ b/engines/stage-tamagotchi-godot/.gitignore @@ -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 diff --git a/engines/stage-tamagotchi-godot/README.md b/engines/stage-tamagotchi-godot/README.md index f4e789dc1..e322f198f 100644 --- a/engines/stage-tamagotchi-godot/README.md +++ b/engines/stage-tamagotchi-godot/README.md @@ -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_ +``` + +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: diff --git a/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/.gitattributes b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/.gitattributes new file mode 100644 index 000000000..8ad74f78d --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/.gitattributes @@ -0,0 +1,2 @@ +# Normalize EOL for all files that Git considers text files. +* text=auto eol=lf diff --git a/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/.gitignore b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/.gitignore new file mode 100644 index 000000000..78f4360fd --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/.gitignore @@ -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 +*~ diff --git a/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/.gitrepo b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/.gitrepo new file mode 100644 index 000000000..0db54c1e6 --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/.gitrepo @@ -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 diff --git a/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/LICENSE b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/LICENSE new file mode 100644 index 000000000..d073a4dfa --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/LICENSE @@ -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. diff --git a/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/inspector_mtoon.gd b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/inspector_mtoon.gd new file mode 100644 index 000000000..f5a20a17f --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/inspector_mtoon.gd @@ -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 diff --git a/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/inspector_mtoon.gd.uid b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/inspector_mtoon.gd.uid new file mode 100644 index 000000000..9b6e2675d --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/inspector_mtoon.gd.uid @@ -0,0 +1 @@ +uid://cvupko56q73jf diff --git a/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon.gdshader b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon.gdshader new file mode 100644 index 000000000..5d3682f25 --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon.gdshader @@ -0,0 +1,3 @@ +shader_type spatial; + +#include "./mtoon_common.gdshaderinc" \ No newline at end of file diff --git a/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon.gdshader.uid b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon.gdshader.uid new file mode 100644 index 000000000..13108db50 --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon.gdshader.uid @@ -0,0 +1 @@ +uid://bxvypa3u1tln0 diff --git a/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_common.gdshaderinc b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_common.gdshaderinc new file mode 100644 index 000000000..b0c02b980 --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_common.gdshaderinc @@ -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; +} diff --git a/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_common.gdshaderinc.uid b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_common.gdshaderinc.uid new file mode 100644 index 000000000..3622bbacd --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_common.gdshaderinc.uid @@ -0,0 +1 @@ +uid://r646bqak6bbl diff --git a/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_cull_off.gdshader b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_cull_off.gdshader new file mode 100644 index 000000000..cc6f02900 --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_cull_off.gdshader @@ -0,0 +1,4 @@ +shader_type spatial; +render_mode cull_disabled; + +#include "./mtoon_common.gdshaderinc" \ No newline at end of file diff --git a/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_cull_off.gdshader.uid b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_cull_off.gdshader.uid new file mode 100644 index 000000000..f9428582b --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_cull_off.gdshader.uid @@ -0,0 +1 @@ +uid://nr34471gd4py diff --git a/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_cutout.gdshader b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_cutout.gdshader new file mode 100644 index 000000000..7b4fad8f3 --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_cutout.gdshader @@ -0,0 +1,5 @@ +shader_type spatial; + +#define ALPHA_CUTOUT + +#include "./mtoon_common.gdshaderinc" \ No newline at end of file diff --git a/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_cutout.gdshader.uid b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_cutout.gdshader.uid new file mode 100644 index 000000000..37f258f37 --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_cutout.gdshader.uid @@ -0,0 +1 @@ +uid://hqxi3at3xsjk diff --git a/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_cutout_cull_off.gdshader b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_cutout_cull_off.gdshader new file mode 100644 index 000000000..fb181ab76 --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_cutout_cull_off.gdshader @@ -0,0 +1,6 @@ +shader_type spatial; +render_mode cull_disabled; + +#define ALPHA_CUTOUT + +#include "./mtoon_common.gdshaderinc" \ No newline at end of file diff --git a/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_cutout_cull_off.gdshader.uid b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_cutout_cull_off.gdshader.uid new file mode 100644 index 000000000..99202189d --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_cutout_cull_off.gdshader.uid @@ -0,0 +1 @@ +uid://ea33g7wfh6mg diff --git a/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_outline.gdshader b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_outline.gdshader new file mode 100644 index 000000000..d1394726b --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_outline.gdshader @@ -0,0 +1,6 @@ +shader_type spatial; +render_mode cull_front; + +#define IS_OUTLINE + +#include "./mtoon_common.gdshaderinc" \ No newline at end of file diff --git a/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_outline.gdshader.uid b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_outline.gdshader.uid new file mode 100644 index 000000000..52eead676 --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_outline.gdshader.uid @@ -0,0 +1 @@ +uid://bkcfsxvugy7wy diff --git a/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_outline_cutout.gdshader b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_outline_cutout.gdshader new file mode 100644 index 000000000..517dbf940 --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_outline_cutout.gdshader @@ -0,0 +1,7 @@ +shader_type spatial; +render_mode cull_front; + +#define IS_OUTLINE +#define ALPHA_CUTOUT + +#include "./mtoon_common.gdshaderinc" \ No newline at end of file diff --git a/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_outline_cutout.gdshader.uid b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_outline_cutout.gdshader.uid new file mode 100644 index 000000000..0789238fe --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_outline_cutout.gdshader.uid @@ -0,0 +1 @@ +uid://cebyolqcmekmu diff --git a/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_outline_trans.gdshader b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_outline_trans.gdshader new file mode 100644 index 000000000..b6a2bbd30 --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_outline_trans.gdshader @@ -0,0 +1,7 @@ +shader_type spatial; +render_mode cull_front; + +#define IS_OUTLINE +#define ALPHA_BLEND + +#include "./mtoon_common.gdshaderinc" \ No newline at end of file diff --git a/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_outline_trans.gdshader.uid b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_outline_trans.gdshader.uid new file mode 100644 index 000000000..dddda03bb --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_outline_trans.gdshader.uid @@ -0,0 +1 @@ +uid://cvvurrhpom6hl diff --git a/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_outline_trans_zwrite.gdshader b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_outline_trans_zwrite.gdshader new file mode 100644 index 000000000..90fe3ec83 --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_outline_trans_zwrite.gdshader @@ -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" \ No newline at end of file diff --git a/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_outline_trans_zwrite.gdshader.uid b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_outline_trans_zwrite.gdshader.uid new file mode 100644 index 000000000..821ffd4fc --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_outline_trans_zwrite.gdshader.uid @@ -0,0 +1 @@ +uid://im4j6gwlgqlf diff --git a/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_trans.gdshader b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_trans.gdshader new file mode 100644 index 000000000..e71554444 --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_trans.gdshader @@ -0,0 +1,4 @@ +shader_type spatial; +#define ALPHA_BLEND + +#include "./mtoon_common.gdshaderinc" \ No newline at end of file diff --git a/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_trans.gdshader.uid b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_trans.gdshader.uid new file mode 100644 index 000000000..058ea694b --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_trans.gdshader.uid @@ -0,0 +1 @@ +uid://c6us0qt7nt2t1 diff --git a/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_trans_cull_off.gdshader b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_trans_cull_off.gdshader new file mode 100644 index 000000000..4b5e7c66a --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_trans_cull_off.gdshader @@ -0,0 +1,5 @@ +shader_type spatial; +render_mode cull_disabled; +#define ALPHA_BLEND + +#include "./mtoon_common.gdshaderinc" \ No newline at end of file diff --git a/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_trans_cull_off.gdshader.uid b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_trans_cull_off.gdshader.uid new file mode 100644 index 000000000..0c02863c4 --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_trans_cull_off.gdshader.uid @@ -0,0 +1 @@ +uid://wsv1fupf5jks diff --git a/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_trans_zwrite.gdshader b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_trans_zwrite.gdshader new file mode 100644 index 000000000..b83cecddb --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_trans_zwrite.gdshader @@ -0,0 +1,5 @@ +shader_type spatial; +render_mode depth_draw_always; +#define ALPHA_BLEND + +#include "./mtoon_common.gdshaderinc" \ No newline at end of file diff --git a/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_trans_zwrite.gdshader.uid b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_trans_zwrite.gdshader.uid new file mode 100644 index 000000000..afd4b923a --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_trans_zwrite.gdshader.uid @@ -0,0 +1 @@ +uid://cffy3w65iybgf diff --git a/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_trans_zwrite_cull_off.gdshader b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_trans_zwrite_cull_off.gdshader new file mode 100644 index 000000000..2f8903070 --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_trans_zwrite_cull_off.gdshader @@ -0,0 +1,5 @@ +shader_type spatial; +render_mode depth_draw_always, cull_disabled; +#define ALPHA_BLEND + +#include "./mtoon_common.gdshaderinc" \ No newline at end of file diff --git a/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_trans_zwrite_cull_off.gdshader.uid b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_trans_zwrite_cull_off.gdshader.uid new file mode 100644 index 000000000..e4a908001 --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/mtoon_trans_zwrite_cull_off.gdshader.uid @@ -0,0 +1 @@ +uid://cqjwkvnemee3s diff --git a/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/plugin.cfg b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/plugin.cfg new file mode 100644 index 000000000..a72dc2232 --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/plugin.cfg @@ -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" diff --git a/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/plugin.gd b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/plugin.gd new file mode 100644 index 000000000..cfa6c39b8 --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/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 diff --git a/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/plugin.gd.uid b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/plugin.gd.uid new file mode 100644 index 000000000..1729c2ee5 --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/plugin.gd.uid @@ -0,0 +1 @@ +uid://ciukb8qi1l782 diff --git a/engines/stage-tamagotchi-godot/addons/vrm/.gitrepo b/engines/stage-tamagotchi-godot/addons/vrm/.gitrepo new file mode 100644 index 000000000..d718b826a --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/.gitrepo @@ -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 diff --git a/engines/stage-tamagotchi-godot/addons/vrm/1.0/VRMC_materials_hdr_emissiveMultiplier.gd b/engines/stage-tamagotchi-godot/addons/vrm/1.0/VRMC_materials_hdr_emissiveMultiplier.gd new file mode 100644 index 000000000..362b7f5a6 --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/1.0/VRMC_materials_hdr_emissiveMultiplier.gd @@ -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, + } diff --git a/engines/stage-tamagotchi-godot/addons/vrm/1.0/VRMC_materials_hdr_emissiveMultiplier.gd.uid b/engines/stage-tamagotchi-godot/addons/vrm/1.0/VRMC_materials_hdr_emissiveMultiplier.gd.uid new file mode 100644 index 000000000..e13bcce67 --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/1.0/VRMC_materials_hdr_emissiveMultiplier.gd.uid @@ -0,0 +1 @@ +uid://d0j7qhde6lke1 diff --git a/engines/stage-tamagotchi-godot/addons/vrm/1.0/VRMC_materials_mtoon.gd b/engines/stage-tamagotchi-godot/addons/vrm/1.0/VRMC_materials_mtoon.gd new file mode 100644 index 000000000..ecaa8f229 --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/1.0/VRMC_materials_mtoon.gd @@ -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? diff --git a/engines/stage-tamagotchi-godot/addons/vrm/1.0/VRMC_materials_mtoon.gd.uid b/engines/stage-tamagotchi-godot/addons/vrm/1.0/VRMC_materials_mtoon.gd.uid new file mode 100644 index 000000000..d64be1abe --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/1.0/VRMC_materials_mtoon.gd.uid @@ -0,0 +1 @@ +uid://bfewyytai2nvn diff --git a/engines/stage-tamagotchi-godot/addons/vrm/1.0/VRMC_node_constraint.gd b/engines/stage-tamagotchi-godot/addons/vrm/1.0/VRMC_node_constraint.gd new file mode 100644 index 000000000..5b845662d --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/1.0/VRMC_node_constraint.gd @@ -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 diff --git a/engines/stage-tamagotchi-godot/addons/vrm/1.0/VRMC_node_constraint.gd.uid b/engines/stage-tamagotchi-godot/addons/vrm/1.0/VRMC_node_constraint.gd.uid new file mode 100644 index 000000000..24fe9b16c --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/1.0/VRMC_node_constraint.gd.uid @@ -0,0 +1 @@ +uid://btarour0um0f1 diff --git a/engines/stage-tamagotchi-godot/addons/vrm/1.0/VRMC_springBone.gd b/engines/stage-tamagotchi-godot/addons/vrm/1.0/VRMC_springBone.gd new file mode 100644 index 000000000..56690acd6 --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/1.0/VRMC_springBone.gd @@ -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" diff --git a/engines/stage-tamagotchi-godot/addons/vrm/1.0/VRMC_springBone.gd.uid b/engines/stage-tamagotchi-godot/addons/vrm/1.0/VRMC_springBone.gd.uid new file mode 100644 index 000000000..e185cf765 --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/1.0/VRMC_springBone.gd.uid @@ -0,0 +1 @@ +uid://km8m8wy853hv diff --git a/engines/stage-tamagotchi-godot/addons/vrm/1.0/VRMC_vrm.gd b/engines/stage-tamagotchi-godot/addons/vrm/1.0/VRMC_vrm.gd new file mode 100644 index 000000000..aff6877e0 --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/1.0/VRMC_vrm.gd @@ -0,0 +1,1041 @@ +extends GLTFDocumentExtension + +const vrm_constants_class = preload("../vrm_constants.gd") +const vrm_meta_class = preload("../vrm_meta.gd") +const vrm_top_level = preload("../vrm_toplevel.gd") +const vrm_utils = preload("../vrm_utils.gd") + +const importer_mesh_attributes = preload("../importer_mesh_attributes.gd") + +var vrm_meta: Resource = null + + +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 + + +# 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() + + vrm_meta = vrm_meta_class.new() + + vrm_meta.resource_name = "CLICK TO SEE METADATA" + vrm_meta.spec_version = vrm_extension.get("specVersion", "1.0") + var vrm_extension_meta = vrm_extension.get("meta") + if vrm_extension_meta: + vrm_meta.title = vrm_extension["meta"].get("name", "") + vrm_meta.version = vrm_extension["meta"].get("version", "") + vrm_meta.authors = PackedStringArray(vrm_extension["meta"].get("authors", [])) + vrm_meta.contact_information = vrm_extension["meta"].get("contactInformation", "") + vrm_meta.references = PackedStringArray(vrm_extension["meta"].get("references", [])) + var tex: int = vrm_extension["meta"].get("thumbnailImage", -1) + if tex >= 0: + vrm_meta.thumbnail_image = gstate.get_images()[tex] + var avatar_permission_map = {"": "", "onlyAuthor": "OnlyAuthor", "onlySeparatelyLicensedPerson": "ExplicitlyLicensedPerson", "everyone": "Everyone"} + vrm_meta.allowed_user_name = avatar_permission_map[vrm_extension["meta"].get("avatarPermission", "")] + vrm_meta.violent_usage = "Allow" if vrm_extension["meta"].get("allowExcessivelyViolentUsage", false) else "Disallow" + vrm_meta.sexual_usage = "Allow" if vrm_extension["meta"].get("allowExcessivelySexualUsage", false) else "Disallow" + var commercial_usage_map = {"": "", "personalNonProfit": "PersonalNonProfit", "personalProfit": "PersonalProfit", "corporation": "AllowCorporation"} + vrm_meta.commercial_usage_type = commercial_usage_map[vrm_extension["meta"].get("commercialUsage", "")] + vrm_meta.political_religious_usage = "Allow" if vrm_extension["meta"].get("allowPoliticalOrReligiousUsage", false) else "Disallow" + vrm_meta.antisocial_hate_usage = "Allow" if vrm_extension["meta"].get("allowAntisocialOrHateUsage", false) else "Disallow" + var credit_notation_map = {"": "", "required": "Required", "unnecessary": "Unnecessary"} + vrm_meta.credit_notation = credit_notation_map[vrm_extension["meta"].get("creditNotation", "")] + vrm_meta.allow_redistribution = "Allow" if vrm_extension["meta"].get("allowRedistribution", false) else "Disallow" + var modification_map = {"prohibited": "Prohibited", "allowModification": "AllowModification", "allowModificationRedistribution": "AllowModificationRedistribution"} + vrm_meta.modification = modification_map[vrm_extension["meta"].get("modification", "")] + vrm_meta.license_name = vrm_extension["meta"].get("licenseName", "") + vrm_meta.license_url = vrm_extension["meta"].get("licenseUrl", "") + vrm_meta.third_party_licenses = vrm_extension["meta"].get("thirdPartyLicenses", "") + vrm_meta.other_license_url = vrm_extension["meta"].get("otherLicenseUrl", "") + + vrm_meta.humanoid_bone_mapping = humanBones + return vrm_meta + + +static func _validate_meta(vrm_meta: vrm_meta_class) -> PackedStringArray: + if vrm_meta == null: + return PackedStringArray(["vrm_meta"]) + var missing: PackedStringArray = [] + for prop in ["allowed_user_name", "violent_usage", "sexual_usage", "commercial_usage_type", "political_religious_usage", "antisocial_hate_usage", "credit_notation", "allow_redistribution", "modification", "title", "author"]: + var val: Variant = vrm_meta.get(prop) + print(str(prop) + ":" + str(val)) + if typeof(val) != TYPE_STRING or val.strip_edges() == "": + missing.append(prop) + return missing + + +func _export_meta(vrm_meta: vrm_meta_class, vrm_extension: Dictionary, gstate: GLTFState): + var meta_obj: Dictionary = {} + meta_obj["specVersion"] = vrm_meta.spec_version + meta_obj["name"] = vrm_meta.title + meta_obj["version"] = vrm_meta.version + meta_obj["authors"] = Array(vrm_meta.authors) + meta_obj["contactInformation"] = vrm_meta.contact_information + meta_obj["references"] = Array(vrm_meta.references) + #FIXME: var tex: int = vrm_extension["meta"].get("thumbnailImage", -1) vrm_meta.thumbnailImage + var avatar_permission_map_rev = {"OnlyAuthor": "onlyAuthor", "ExplicitlyLicensedPerson": "onlySeparatelyLicensedPerson", "Everyone": "everyone"} + var commercial_usage_map_rev = {"PersonalNonProfit": "personalNonProfit", "PersonalProfit": "personalProfit", "AllowCorporation": "corporation"} + var credit_notation_map_rev = {"Required": "required", "Unnecessary": "unnecessary"} + var modification_map_rev = {"Prohibited": "prohibited", "AllowModification": "allowModification", "AllowModificationRedistribution": "allowModificationRedistribution"} + meta_obj["avatarPermission"] = avatar_permission_map_rev[vrm_meta.allowed_user_name] + meta_obj["allowExcessivelyViolentUsage"] = vrm_meta.violent_usage == "Allow" + meta_obj["allowExcessivelySexualUsage"] = vrm_meta.sexual_usage == "Allow" + meta_obj["commercialUsage"] = commercial_usage_map_rev[vrm_meta.commercial_usage_type] + meta_obj["allowPoliticalOrReligiousUsage"] = vrm_meta.political_religious_usage == "Allow" + meta_obj["allowAntisocialOrHateUsage"] = vrm_meta.antisocial_hate_usage == "Allow" + meta_obj["creditNotation"] = credit_notation_map_rev[vrm_meta.credit_notation] + meta_obj["allowRedistribution"] = vrm_meta.allow_redistribution == "Allow" + meta_obj["modification"] = modification_map_rev[vrm_meta.modification] + meta_obj["licenseName"] = vrm_meta.license_name + meta_obj["licenseUrl"] = vrm_meta.license_url + meta_obj["thirdPartyLicenses"] = vrm_meta.third_party_licenses + meta_obj["otherLicenseUrl"] = vrm_meta.other_license_url + vrm_extension["meta"] = meta_obj + + +const vrm_animation_to_look_at: Dictionary = { + "lookLeft": "rangeMapHorizontalOuter", + "lookRight": "rangeMapHorizontalOuter", + "lookDown": "rangeMapVerticalDown", + "lookUp": "rangeMapVerticalUp", +} +const vrm_animation_presets: Dictionary = { + "happy": true, + "angry": true, + "sad": true, + "relaxed": true, + "surprised": true, + "aa": true, + "ih": true, + "ou": true, + "ee": true, + "oh": true, + "blink": true, + "blinkLeft": true, + "blinkRight": true, + "lookUp": true, + "lookDown": true, + "lookLeft": true, + "lookRight": true, + "neutral": true, +} + + +func _create_animation(default_values: Dictionary, default_blend_shapes: Dictionary, anim_name: String, expression: Dictionary, animplayer: AnimationPlayer, gstate: GLTFState, material_idx_to_mesh_and_surface_idx: Dictionary, mesh_idx_to_meshinstance: Dictionary, node_to_head_hidden_node: Dictionary, look_at: Dictionary): + #print("Blend shape group: " + shape["name"]) + var anim = Animation.new() + anim.resource_name = anim_name + + var extra_weight: float = 1.0 + var input_key: float = 0.0 + if vrm_animation_to_look_at.has(anim_name): + extra_weight = look_at.get(vrm_animation_to_look_at[anim_name], {}).get("outputScale", 1.0) + input_key = look_at.get(vrm_animation_to_look_at[anim_name], {}).get("inputMaxValue", 90.0) / 180.0 + + var interpolation_type = Animation.INTERPOLATION_NEAREST if bool(expression["isBinary"]) else Animation.INTERPOLATION_LINEAR + anim.set_meta("vrm_is_binary", expression.get("isBinary", false)) + anim.set_meta("vrm_override_blink", expression.get("overrideBlink", false)) + anim.set_meta("vrm_override_look_at", expression.get("overrideLookAt", false)) + anim.set_meta("vrm_override_mouth", expression.get("overrideMouth", false)) + for textransformbind in expression.get("textureTransformBinds", []): + var mesh_and_surface_idx = material_idx_to_mesh_and_surface_idx[int(textransformbind["material"])] + 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 scale = textransformbind["scale"] + var offset = textransformbind["offset"] + var newvalue1: Variant + var origvalue1: Variant + var property_path1: String + + var newvalue2: Variant + var origvalue2: Variant + var property_path2: String + + if mat is ShaderMaterial: + var smat: ShaderMaterial = mat + var param = smat.get_shader_parameter("_MainTex_ST") + if param is Vector4: + newvalue1 = Vector4(scale[0], scale[1], offset[0], offset[1]) + newvalue2 = newvalue1 + origvalue1 = param + origvalue2 = param + property_path1 = "shader_parameter/_MainTex_ST" + if smat.next_pass != null: + property_path2 = "next_pass:" + property_path1 + else: + printerr("Unknown type for tex transform parameter" + " surface " + node.name + "/" + str(surface_idx)) + elif mat is BaseMaterial3D: + var smat: BaseMaterial3D = mat + property_path1 = "uv1_offset" + origvalue1 = smat.uv1_offset + newvalue1 = Vector3(offset[0], offset[1], 0) + property_path2 = "uv1_scale" + origvalue2 = smat.uv1_scale + newvalue2 = Vector3(scale[0], scale[1], 0) + + if not property_path1.is_empty(): + var animtrack: int = anim.add_track(Animation.TYPE_VALUE) + var anim_path = str(animplayer.get_parent().get_path_to(node)) + ":mesh:surface_" + str(surface_idx) + "/material:" + property_path1 + anim.track_set_path(animtrack, anim_path) + anim.track_set_interpolation_type(animtrack, interpolation_type) + anim.track_insert_key(animtrack, input_key, origvalue1.lerp(newvalue1, extra_weight)) + default_values[anim_path] = origvalue1 + if not property_path2.is_empty(): + var animtrack: int = anim.add_track(Animation.TYPE_VALUE) + var anim_path = str(animplayer.get_parent().get_path_to(node)) + ":mesh:surface_" + str(surface_idx) + "/material:" + property_path2 + anim.track_set_path(animtrack, anim_path) + anim.track_set_interpolation_type(animtrack, interpolation_type) + anim.track_insert_key(animtrack, input_key, origvalue2.lerp(newvalue2, extra_weight)) + default_values[anim_path] = origvalue2 + + for matbind in expression.get("materialColorBinds", []): + var mesh_and_surface_idx = material_idx_to_mesh_and_surface_idx[matbind["material"]] + var node: ImporterMeshInstance3D = mesh_idx_to_meshinstance[mesh_and_surface_idx[0]] + var surface_idx = mesh_and_surface_idx[1] + + var mat: Material = node.get_surface_material(surface_idx) + var tv: Array = matbind["targetValue"] + var property_path: String = "" + var newvalue: Color = Color(tv[0], tv[1], tv[2], tv[3]) + if matbind["type"] != "color" and matbind["type"] != "outlineColor": + newvalue.a = 1.0 + var origvalue: Color + + if mat is ShaderMaterial: + var smat: ShaderMaterial = mat + var property_mapping = { + "color": "_Color", + "emissionColor": "_EmissionColor", + "shadeColor": "_ShadeColor", + "matcapColor": "_SphereColor", + "rimColor": "_RimColor", + "outlineColor": "_OutlineColor", + } + var param = smat.get_shader_parameter(property_mapping.get(matbind["type"], matbind["type"])) + if param is Color: + origvalue = param + property_path = "shader_parameter/" + property_mapping.get(matbind["type"], matbind["type"]) + if matbind["type"] == "outlineColor": + property_path = "next_pass:" + property_path + else: + printerr("Unknown type for parameter " + matbind["type"] + " surface " + node.name + "/" + str(surface_idx)) + elif mat is BaseMaterial3D: + var smat: BaseMaterial3D = mat + if matbind["type"] == "color": + property_path = "albedo_color" + origvalue = mat.albedo_color + elif matbind["type"] == "emissionColor": + property_path = "emission" + origvalue = mat.emission + + if not property_path.is_empty(): + var animtrack: int = anim.add_track(Animation.TYPE_VALUE) + var anim_path = str(animplayer.get_parent().get_path_to(node)) + ":mesh:surface_" + str(surface_idx) + "/material:" + property_path + anim.track_set_path(animtrack, anim_path) + anim.track_set_interpolation_type(animtrack, interpolation_type) + anim.track_insert_key(animtrack, input_key, origvalue.lerp(newvalue, extra_weight)) + default_values[anim_path] = origvalue + for bind in expression.get("morphTargetBinds", []): + # FIXME: Is this a mesh_idx or a node_idx??? + var node_maybe: Node = gstate.get_scene_node(int(bind["node"])) + if node_maybe == null: + push_warning("Morph target bind is null") + continue + if not node_maybe is ImporterMeshInstance3D: + push_warning("Morph target bind is a " + str(node_maybe.get_class())) + continue + var node: ImporterMeshInstance3D = node_maybe as ImporterMeshInstance3D + 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(expression) + " 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"])) + var anim_path: String = str(animplayer.get_parent().get_path_to(node)) + ":" + str(nodeMesh.get_blend_shape_name(int(bind["index"]))) + anim.track_set_path(animtrack, anim_path) + anim.track_set_interpolation_type(animtrack, interpolation_type) + # FIXME: Godot has weird normal/tangent singularities at weight=1.0 or weight=0.5 + anim.blend_shape_track_insert_key(animtrack, input_key, 0.99999 * float(bind["weight"])) + default_blend_shapes[anim_path] = 0.0 # TODO: Find the default value from gltf?? + #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)) + var head_hidden_node: ImporterMeshInstance3D = node_to_head_hidden_node.get(node, null) + while head_hidden_node != null: + animtrack = anim.add_track(Animation.TYPE_BLEND_SHAPE) + # nodeMesh.set_blend_shape_name(int(bind["index"]), shape["name"] + "_" + str(bind["index"])) + anim_path = str(animplayer.get_parent().get_path_to(head_hidden_node)) + ":" + str(nodeMesh.get_blend_shape_name(int(bind["index"]))) + anim.track_set_path(animtrack, anim_path) + anim.track_set_interpolation_type(animtrack, interpolation_type) + # FIXME: Godot has weird normal/tangent singularities at weight=1.0 or weight=0.5 + anim.blend_shape_track_insert_key(animtrack, input_key, extra_weight * 0.99999 * float(bind["weight"]) / 100.0) + default_blend_shapes[anim_path] = 0.0 # TODO: Find the default value from gltf?? + head_hidden_node = node_to_head_hidden_node.get(head_hidden_node, null) + return anim + + +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 materials = gstate.get_materials() + var nodes = gstate.get_nodes() + + var firstperson = vrm_extension.get("firstPerson", {}) + var lookAt = vrm_extension.get("lookAt", {}) + + var skeletons: Array = gstate.get_skeletons() + + var head_relative_bones: Dictionary = {} # To determine which meshes to hide. + + var node_to_head_hidden_node: Dictionary = {} + + var lefteye: int = human_bone_to_idx.get("leftEye", -1) + var righteye: int = human_bone_to_idx.get("rightEye", -1) + + var head_bone_idx = 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) + + var head_attach: BoneAttachment3D = null + for child in skel.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" + skel.add_child(head_attach) + head_attach.owner = skel.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 = skel.owner + var look_offset = Vector3(0, 0, 0) + if lookAt.has("offsetFromHeadBone"): + var gltf_look_offset = lookAt["offsetFromHeadBone"] + look_offset = pose_diffs[skel.find_bone("Head")] * Vector3(gltf_look_offset[0], gltf_look_offset[1], gltf_look_offset[2]) + elif lefteye >= 0 and righteye >= 0: + look_offset = skel.get_bone_rest(lefteye).origin.lerp(skel.get_bone_rest(righteye).origin, 0.5) + head_bone_offset.position = look_offset + + vrm_utils._recurse_bones(head_relative_bones, skel, skel.find_bone("Head")) + + var mesh_annotations_by_node = {} + for meshannotation in firstperson.get("meshAnnotations", []): + mesh_annotations_by_node[int(meshannotation["node"])] = meshannotation.get("type", "auto") + + vrm_utils.perform_head_hiding(gstate, mesh_annotations_by_node, head_relative_bones, node_to_head_hidden_node) + + var meshes = gstate.get_meshes() + var expressions = vrm_extension.get("expressions", {}) + # FIXME: Do we need to handle multiple references to the same mesh??? + var mesh_idx_to_meshinstance: Dictionary = {} + var material_idx_to_mesh_and_surface_idx: Dictionary = {} + var material_to_idx: Dictionary = {} + for i in range(materials.size()): + material_to_idx[materials[i]] = i + for i in range(meshes.size()): + var gltfmesh: GLTFMesh = meshes[i] + for j in range(gltfmesh.mesh.get_surface_count()): + material_idx_to_mesh_and_surface_idx[material_to_idx[gltfmesh.mesh.get_surface_material(j)]] = [i, j] + + for i in range(nodes.size()): + var gltfnode: GLTFNode = nodes[i] + var mesh_idx: int = gltfnode.mesh + if mesh_idx != -1: + var scenenode: ImporterMeshInstance3D = gstate.get_scene_node(i) + mesh_idx_to_meshinstance[mesh_idx] = scenenode + + var default_values: Dictionary = {} + var default_blend_shapes: Dictionary = {} + for expression_name in expressions.get("preset", {}): + var expression = expressions["preset"][expression_name] + if lookAt.get("type", "") != "bone" or not vrm_animation_to_look_at.has(expression_name): + var anim: Animation = _create_animation(default_values, default_blend_shapes, expression_name, expression, animplayer, gstate, material_idx_to_mesh_and_surface_idx, mesh_idx_to_meshinstance, node_to_head_hidden_node, lookAt) + # https://github.com/vrm-c/vrm-specification/tree/master/specification/0.0#blendshape-name-identifier + animation_library.add_animation(expression_name, anim) + if vrm_animation_to_look_at.has(expression_name): + expression_name += "Raw" + var anim: Animation = _create_animation(default_values, default_blend_shapes, expression_name, expression, animplayer, gstate, material_idx_to_mesh_and_surface_idx, mesh_idx_to_meshinstance, node_to_head_hidden_node, {}) + # https://github.com/vrm-c/vrm-specification/tree/master/specification/0.0#blendshape-name-identifier + animation_library.add_animation(expression_name, anim) + for expression_name in expressions.get("custom", {}): + if expressions["preset"].has(expression_name): + continue + if vrm_animation_to_look_at.has(expression_name): + continue + var expression = expressions["custom"][expression_name] + var anim: Animation = _create_animation(default_values, default_blend_shapes, expression_name, expression, animplayer, gstate, material_idx_to_mesh_and_surface_idx, mesh_idx_to_meshinstance, node_to_head_hidden_node, lookAt) + # https://github.com/vrm-c/vrm-specification/tree/master/specification/0.0#blendshape-name-identifier + animation_library.add_animation(expression_name, anim) + + var eye_bone_horizontal: Quaternion = Quaternion.from_euler(Vector3(PI / 2, 0, 0)) + var leftEyePath: String = "" + var rightEyePath: String = "" + if lookAt.get("type", "") == "bone" and lefteye >= 0 and righteye >= 0: + var leftEyeNode: GLTFNode = nodes[lefteye] + var rightEyeNode: GLTFNode = nodes[righteye] + 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) + rightEyePath = (str(skeletonPath) + ":" + nodes[human_bone_to_idx["rightEye"]].resource_name) + + if lookAt.get("type", "") == "bone" and not leftEyePath.is_empty() and not rightEyePath.is_empty(): + var horizout = lookAt.get("rangeMapHorizontalOuter", {}) + var horizin = lookAt.get("rangeMapHorizontalOuter", {}) + var vertdown = lookAt.get("rangeMapVerticalDown", {}) + var vertup = lookAt.get("rangeMapVerticalUp", {}) + + var anim: Animation = null + var animtrack: int + var input_val: float + anim = Animation.new() + animation_library.add_animation("lookLeft", anim) + animtrack = anim.add_track(Animation.TYPE_ROTATION_3D) + anim.track_set_path(animtrack, leftEyePath) + anim.track_set_interpolation_type(animtrack, Animation.INTERPOLATION_LINEAR) + input_val = horizout.get("inputMaxValue", 90) / 180.0 + anim.rotation_track_insert_key(animtrack, input_val, eye_bone_horizontal * (Basis(Vector3(0, 0, 1), -horizout.get("outputScale", 1.0) * input_val * 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) + input_val = horizin.get("inputMaxValue", 90) / 180.0 + anim.rotation_track_insert_key(animtrack, input_val, eye_bone_horizontal * (Basis(Vector3(0, 0, 1), -horizin.get("outputScale", 1.0) * input_val * PI / 180.0)).get_rotation_quaternion()) + + anim = Animation.new() + animation_library.add_animation("lookRight", anim) + animtrack = anim.add_track(Animation.TYPE_ROTATION_3D) + anim.track_set_path(animtrack, leftEyePath) + anim.track_set_interpolation_type(animtrack, Animation.INTERPOLATION_LINEAR) + input_val = horizout.get("inputMaxValue", 90) / 180.0 + anim.rotation_track_insert_key(animtrack, input_val, eye_bone_horizontal * (Basis(Vector3(0, 0, 1), horizout.get("outputScale", 1.0) * input_val * 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) + input_val = horizin.get("inputMaxValue", 90) / 180.0 + anim.rotation_track_insert_key(animtrack, input_val, eye_bone_horizontal * (Basis(Vector3(0, 0, 1), horizin.get("outputScale", 1.0) * input_val * PI / 180.0)).get_rotation_quaternion()) + + anim = Animation.new() + animation_library.add_animation("lookUp", anim) + animtrack = anim.add_track(Animation.TYPE_ROTATION_3D) + anim.track_set_path(animtrack, leftEyePath) + anim.track_set_interpolation_type(animtrack, Animation.INTERPOLATION_LINEAR) + input_val = vertup.get("inputMaxValue", 90) / 180.0 + anim.rotation_track_insert_key(animtrack, input_val, eye_bone_horizontal * (Basis(Vector3(1, 0, 0), -vertup.get("outputScale", 1.0) * input_val * 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) + input_val = vertup.get("inputMaxValue", 90) / 180.0 + anim.rotation_track_insert_key(animtrack, input_val, eye_bone_horizontal * (Basis(Vector3(1, 0, 0), -vertup.get("outputScale", 1.0) * input_val * PI / 180.0)).get_rotation_quaternion()) + + anim = Animation.new() + animation_library.add_animation("lookDown", anim) + animtrack = anim.add_track(Animation.TYPE_ROTATION_3D) + anim.track_set_path(animtrack, leftEyePath) + anim.track_set_interpolation_type(animtrack, Animation.INTERPOLATION_LINEAR) + input_val = vertdown.get("inputMaxValue", 90) / 180.0 + anim.rotation_track_insert_key(animtrack, input_val, eye_bone_horizontal * (Basis(Vector3(1, 0, 0), vertdown.get("outputScale", 1.0) * input_val * 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) + input_val = vertdown.get("inputMaxValue", 90) / 180.0 + anim.rotation_track_insert_key(animtrack, input_val, eye_bone_horizontal * (Basis(Vector3(1, 0, 0), vertdown.get("outputScale", 1.0) * input_val * PI / 180.0)).get_rotation_quaternion()) + + var reset_anim: Animation = Animation.new() + reset_anim.resource_name = "RESET" + for anim_path in default_values: + var animtrack: int = reset_anim.add_track(Animation.TYPE_VALUE) + reset_anim.track_set_path(animtrack, anim_path) + reset_anim.track_insert_key(animtrack, 0.0, default_values[anim_path]) + for anim_path in default_blend_shapes: + var animtrack: int = reset_anim.add_track(Animation.TYPE_BLEND_SHAPE) + reset_anim.track_set_path(animtrack, anim_path) + reset_anim.blend_shape_track_insert_key(animtrack, 0.0, default_blend_shapes[anim_path]) + + if lookAt.get("type", "") == "bone" and not leftEyePath.is_empty() and not rightEyePath.is_empty(): + var animtrack = 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) + + animation_library.add_animation(&"RESET", reset_anim) + + animplayer.add_animation_library("", animation_library) + return animplayer + + +func _export_animations(root_node: Node, skel: Skeleton3D, animplayer: AnimationPlayer, vrm_extension: Dictionary, gstate: GLTFState): + if animplayer.has_animation("lookLeft") and animplayer.has_animation("lookUp") and animplayer.has_animation("lookDown"): + var look_left_anim: Animation = animplayer.get_animation("lookLeft") + var look_up_anim: Animation = animplayer.get_animation("lookUp") + var look_down_anim: Animation = animplayer.get_animation("lookDown") + var look_at = {"rangeMapHorizontalInner": {}, "rangeMapHorizontalOuter": {}, "rangeMapVerticalDown": {}, "rangeMapVerticalUp": {}} + if look_left_anim.track_get_type(0) == Animation.TYPE_ROTATION_3D: + look_at["type"] = "bone" + else: + look_at["type"] = "expression" + if look_at["type"] == "bone": + for i in range(look_left_anim.get_track_count()): + var key: String + if look_left_anim.track_get_path(i).get_subname(0) == "leftEye": + key = "rangeMapHorizontalOuter" + elif look_left_anim.track_get_path(i).get_subname(0) == "rightEye": + key = "rangeMapHorizontalInner" + else: + continue + var look_length = look_left_anim.track_get_key_time(i, 0) + var quat: Quaternion = look_left_anim.track_get_key_value(i, 0) + var angle_from_quat: float = quat.get_angle() * sign(quat.get_axis().y) + look_at[key] = {"inputMaxValue": look_length * 180.0, "outputScale": abs(angle_from_quat * 180.0 / PI)} + for i in range(look_up_anim.get_track_count()): + if look_up_anim.track_get_path(i).get_subname(0) != "leftEye": + continue + var look_length = look_up_anim.track_get_key_time(i, 0) + var quat: Quaternion = look_up_anim.track_get_key_value(i, 0) + var angle_from_quat: float = quat.get_angle() * sign(quat.get_axis().y) + look_at["rangeMapVerticalUp"] = {"inputMaxValue": look_length * 180.0, "outputScale": abs(angle_from_quat * 180.0 / PI)} + for i in range(look_down_anim.get_track_count()): + if look_down_anim.track_get_path(i).get_subname(0) != "leftEye": + continue + var look_length = look_down_anim.track_get_key_time(i, 0) + var quat: Quaternion = look_down_anim.track_get_key_value(i, 0) + var angle_from_quat: float = quat.get_angle() * sign(quat.get_axis().y) + look_at["rangeMapVerticalDown"] = {"inputMaxValue": look_length * 180.0, "outputScale": abs(angle_from_quat * 180.0 / PI)} + else: + var look_length = look_left_anim.track_get_key_time(0, 0) + look_at["rangeMapHorizontalOuter"] = {"inputMaxValue": look_length * 180.0, "outputScale": 1.0} + look_at["rangeMapHorizontalInner"] = {"inputMaxValue": look_length * 180.0, "outputScale": 1.0} + look_length = look_up_anim.track_get_key_time(0, 0) + look_at["rangeMapVerticalUp"] = {"inputMaxValue": look_length * 180.0, "outputScale": 1.0} + look_length = look_down_anim.track_get_key_time(0, 0) + look_at["rangeMapVerticalDown"] = {"inputMaxValue": look_length * 180.0, "outputScale": 1.0} + vrm_extension["lookAt"] = look_at + + # TODO: port VRM 0.0 names. + var presets: Dictionary = {} + var custom: Dictionary = {} + var mat_lookup: Dictionary = {} + var gltf_materials: Array[Material] = gstate.materials + var shader_to_standard_material = gstate.get_meta("shader_to_standard_material") + if typeof(shader_to_standard_material) == TYPE_DICTIONARY: + for i in range(len(gltf_materials)): + if shader_to_standard_material.has(gltf_materials[i]): + mat_lookup[shader_to_standard_material[gltf_materials[i]]] = i + mat_lookup[gltf_materials[i]] = i + var mesh_bs_lookup: Dictionary = {} + var gltf_meshes: Array[GLTFMesh] = gstate.meshes + for i in range(len(gltf_meshes)): + var mesh: ImporterMesh = gltf_meshes[i].mesh + var blend_shape_to_idx: Dictionary = {} + for bsi in range(mesh.get_blend_shape_count()): + blend_shape_to_idx[mesh.get_blend_shape_name(bsi)] = bsi + mesh_bs_lookup[gltf_meshes[i].mesh] = blend_shape_to_idx + var mesh_instances = animplayer.get_parent().find_children("*", "MeshInstance3D") + for meshinst in mesh_instances: + var mesh: Mesh = meshinst.mesh + var blend_shape_to_idx: Dictionary = {} + if mesh is ArrayMesh: + for bsi in range(mesh.get_blend_shape_count()): + blend_shape_to_idx[mesh.get_blend_shape_name(bsi)] = bsi + mesh_bs_lookup[mesh] = blend_shape_to_idx + mesh_instances = animplayer.get_parent().find_children("*", "ImporterMeshInstance3D") + for meshinst in mesh_instances: + var mesh: ImporterMesh = meshinst.mesh + var blend_shape_to_idx: Dictionary = {} + for bsi in range(mesh.get_blend_shape_count()): + blend_shape_to_idx[mesh.get_blend_shape_name(bsi)] = bsi + mesh_bs_lookup[mesh] = blend_shape_to_idx + + for exp in animplayer.get_animation_list(): + if exp == "RESET": + continue + if exp.ends_with("Raw") and vrm_animation_to_look_at.has(exp.substr(0, len(exp) - 3)): + exp = exp.substr(0, len(exp) - 3) + var expression: Dictionary = {} + var texture_transform_binds = {} + var morph_target_binds = [] + var material_color_binds = [] + var anim: Animation = animplayer.get_animation(exp) + if anim.get_track_count() == 0: + continue + for i in range(anim.get_track_count()): + var anim_path = anim.track_get_path(i) + var meshinst: Node = animplayer.get_parent().get_node(NodePath(str(anim_path.get_concatenated_names()))) + var val = anim.track_get_key_value(i, 0) + if anim.track_get_type(i) == Animation.TYPE_BLEND_SHAPE: + if val == 0.0: + continue + var gltf_blendshape_idx = mesh_bs_lookup[meshinst.mesh][anim_path.get_subname(0)] + morph_target_binds.push_back({"node": gstate.get_node_index(meshinst), "index": gltf_blendshape_idx, "weight": val}) + elif anim.track_get_type(i) == Animation.TYPE_VALUE: + if anim_path.get_subname_count() < 3 or anim_path.get_subname(0) != "mesh" or not anim_path.get_subname(1).begins_with("surface_") or not anim_path.get_subname(1).ends_with("/material"): + push_warning("Ignoring unsupported animation value track " + str(anim_path)) + continue + var material_idx = int(anim_path.get_subname(1).split("/")[0].split("_")[1]) + var gltf_material_idx: int + if meshinst is ImporterMeshInstance3D: + gltf_material_idx = mat_lookup[meshinst.mesh.get_surface_material(material_idx)] + if meshinst is MeshInstance3D: + # wtf lol + if meshinst.get_surface_override_material(material_idx) == null: + gltf_material_idx = mat_lookup[meshinst.mesh.surface_get_material(material_idx)] + else: + gltf_material_idx = mat_lookup[meshinst.get_surface_override_material(material_idx)] + if typeof(val) == TYPE_COLOR: + var property_mapping = { + "shader_parameter/_Color": "color", + "shader_parameter/_EmissionColor": "emissionColor", + "shader_parameter/_ShadeColor": "shadeColor", + "shader_parameter/_SphereColor": "matcapColor", + "shader_parameter/_RimColor": "rimColor", + "shader_parameter/_OutlineColor": "outlineColor", + "albedo_color": "color", + "emission": "emissionColor", + } + var shader_prop = anim_path.get_subname(2) + if not property_mapping.has(shader_prop): + push_warning("Unable to serialize color animation " + str(shader_prop) + " for material " + str(gltf_materials[gltf_material_idx].resource_name)) + continue + var material_bind = {"material": gltf_material_idx, "type": property_mapping[shader_prop], "targetValue": [val.r, val.g, val.b, val.a]} + material_color_binds.push_back(material_bind) + elif typeof(val) == TYPE_VECTOR4: + var shader_prop = anim_path.get_subname(2) + assert(shader_prop == "shader_parameter/_MainTex_ST") + texture_transform_binds[gltf_material_idx] = {"material": gltf_material_idx, "scale": [val.x, val.y], "offset": [val.z, val.w]} + elif typeof(val) == TYPE_VECTOR3: + var shader_prop = anim_path.get_subname(2) + if not texture_transform_binds.has(gltf_material_idx): + texture_transform_binds[gltf_material_idx] = {} + var tex_bind = texture_transform_binds[gltf_material_idx] + tex_bind["material"] = gltf_material_idx + if shader_prop == "uv1_offset": + tex_bind["offset"] = [val.z, val.w] + elif shader_prop == "uv1_scale": + tex_bind["scale"] = [val.x, val.y] + if morph_target_binds.is_empty() and material_color_binds.is_empty() and texture_transform_binds.is_empty(): + continue + if not morph_target_binds.is_empty(): + expression["morphTargetBinds"] = morph_target_binds + if not material_color_binds.is_empty(): + expression["materialColorBinds"] = material_color_binds + if not texture_transform_binds.is_empty(): + expression["textureTransformBinds"] = texture_transform_binds.values() + expression["isBinary"] = anim.get_meta("vrm_is_binary", anim.track_get_interpolation_type(0) == Animation.INTERPOLATION_NEAREST) + if anim.has_meta("vrm_override_blink"): + expression["overrideBlink"] = anim.get_meta("vrm_override_blink") + if anim.has_meta("vrm_override_blink"): + expression["overrideLookAt"] = anim.get_meta("vrm_override_look_at") + if anim.has_meta("vrm_override_blink"): + expression["overrideMouth"] = anim.get_meta("vrm_override_mouth") + if vrm_animation_presets.has(exp): + presets[exp] = expression + elif "/" not in exp: + custom[exp] = expression + + var expressions: Dictionary = {"preset": presets, "custom": custom} + vrm_extension["expressions"] = expressions + + +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_vrm", {}) + if not vrm_extension.has("humanoid"): + return false + var new_joints_set = {}.duplicate() + + var human_bones: Dictionary = vrm_extension["humanoid"]["humanBones"] + var gltf_nodes: Array = obj["nodes"] + for i in range(len(gltf_nodes)): + if gltf_nodes[i]["name"] == "Root": + pass + + for human_bone in human_bones: + _add_joints_recursive(new_joints_set, obj["nodes"], int(human_bones[human_bone]["node"]), false) + _add_joint_set_as_skin(obj, new_joints_set) + + return true + + +func remove_null_owner(node: Node): + if node.owner == null: + node.get_parent().remove_child(node) + node.queue_free() + return + for child in node.get_children(): + remove_null_owner(child) + + +const required_bones = ["hips", "spine", "head", "leftUpperLeg", "leftLowerLeg", "leftFoot", "rightUpperLeg", "rightLowerLeg", "rightFoot", "leftUpperArm", "leftLowerArm", "leftHand", "rightUpperArm", "rightLowerArm", "rightHand"] + + +func _export_preflight(gstate: GLTFState, root: Node) -> Error: + if gstate.get_meta("vrm", "") != "1.0": + return ERR_INVALID_DATA + if root.script != vrm_top_level: + return ERR_INVALID_DATA + gstate.set_meta("vrm_root", root) + + # Do not call remove_null_owner on root, since root will not have an owner. + for node in root.get_children(): + remove_null_owner(node) # should be done by builtin exporter. + var vrm_extension: Dictionary = {} + var humanoid_skeleton: Skeleton3D = _get_humanoid_skel(root) + var anim_player: AnimationPlayer + if root.has_node("AnimationPlayer"): + anim_player = root.get_node("AnimationPlayer") as AnimationPlayer + if anim_player == null: + for n in root.get_children(): + if n is AnimationPlayer: + anim_player = n + break + if anim_player != null: + gstate.set_meta("anim_player", anim_player) + anim_player.owner = null + root.remove_child(anim_player) + gstate.set_meta("vrm_extension", vrm_extension) + gstate.set_meta("look_offset", Vector3.ZERO) + + gstate.add_used_extension("VRMC_vrm", false) + var skels = root.find_children("*", "Skeleton3D") + for skelx in skels: + var skel: Skeleton3D = skelx + var root_bone: int = skel.find_bone("Root") + if root_bone == -1: + continue + var root_children = skel.get_bone_children(root_bone) + var root_parent = skel.get_bone_parent(root_bone) + for b in root_children: + skel.set_bone_parent(b, root_parent) + # remove_bone(idx): + var bone_storage = [] + for b in range(skel.get_bone_count()): + var parent: int = skel.get_bone_parent(b) + if parent > root_bone: + parent -= 1 + if b != root_bone: + bone_storage.append([skel.get_bone_name(b), parent, skel.get_bone_rest(b), skel.get_bone_pose_position(b), skel.get_bone_pose_rotation(b), skel.get_bone_pose_scale(b)]) + skel.clear_bones() + for bone_data in bone_storage: + skel.add_bone(bone_data[0]) + for b in range(skel.get_bone_count()): + var bone_data = bone_storage[b] + skel.set_bone_parent(b, bone_data[1]) + skel.set_bone_rest(b, bone_data[2]) + skel.set_bone_pose_position(b, bone_data[3]) + skel.set_bone_pose_rotation(b, bone_data[4]) + skel.set_bone_pose_scale(b, bone_data[5]) + var skins: Dictionary = {} + var meshes = root.find_children("*", "ImporterMeshInstance3D") + for meshx in meshes: + var mesh: ImporterMeshInstance3D = meshx + if mesh.skin != null: + skins[mesh.skin] = true + meshes = root.find_children("*", "MeshInstance3D") + for meshx in meshes: + var mesh: MeshInstance3D = meshx + if mesh.skin != null: + skins[mesh.skin] = true + if mesh.has_meta("vrm_first_person_flag") and mesh.get_meta("vrm_first_person_flag") == "head_removed": + mesh.get_parent().remove_child(mesh) + mesh.queue_free() + + for skinx in skins: + var skin: Skin = skinx + for b in range(skin.get_bind_count()): + if skin.get_bind_name(b) != "": + skin.set_bind_name(b, skin.get_bind_name(b)) + var bind_bone = skin.get_bind_bone(b) + if bind_bone != -1 and bind_bone > root_bone: + skin.set_bind_bone(b, bind_bone - 1) + for ch in skel.find_children("*", "BoneAttachment3D"): + var attach: BoneAttachment3D = ch as BoneAttachment3D + if attach.bone_name == "Head" or attach.bone_idx == skel.find_bone("Head"): + var look_offset: Node3D = attach.get_node("LookOffset") as Node3D + if look_offset != null: + gstate.set_meta("look_offset", look_offset.position) + attach.remove_child(look_offset) + look_offset.queue_free() + + #var ps:PackedScene = PackedScene.new() + #ps.pack(root) + #ResourceSaver.save(ps, "res://saved_outgoing_vrm.tscn", ResourceSaver.FLAG_REPLACE_SUBRESOURCE_PATHS) + 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 + + +static func _validate_humanoid(root_node: Node3D) -> Dictionary: + var human_to_vrm_bone: Dictionary + for vrm_bone in vrm_constants_class.vrm_to_human_bone: + human_to_vrm_bone[vrm_constants_class.vrm_to_human_bone[vrm_bone]] = vrm_bone + + var humanoid_skeleton: Skeleton3D = _get_humanoid_skel(root_node) + + var vrm_bone_mapping: Dictionary = {} + for i in range(humanoid_skeleton.get_bone_count()): + var bone_name = humanoid_skeleton.get_bone_name(i) + if human_to_vrm_bone.has(bone_name): + vrm_bone_mapping[human_to_vrm_bone[bone_name]] = bone_name + for bone_name in required_bones: + if not vrm_bone_mapping.has(bone_name): + push_warning("Skeleton " + str(humanoid_skeleton.name) + " missing required humanoid bone " + str(bone_name)) + return {} + + return vrm_bone_mapping + + +func _export_post(gstate: GLTFState) -> Error: + var json_gltf_nodes: Array = gstate.json["nodes"] + for i in range(len(json_gltf_nodes)): + if json_gltf_nodes[i].has("extensions") and json_gltf_nodes[i]["extensions"].is_empty(): + json_gltf_nodes[i].erase("extensions") + var root_node: Node3D = gstate.get_meta("vrm_root") + var json: Dictionary = gstate.json + + var gltf_nodes: Array[GLTFNode] = gstate.nodes + var godot_node_to_gltf_mesh_node_idx: Dictionary = {} + for i in range(len(gltf_nodes)): + if gltf_nodes[i].mesh != -1: + godot_node_to_gltf_mesh_node_idx[gstate.get_scene_node(i)] = i + + var vrm_extension: Dictionary = gstate.get_meta("vrm_extension") + vrm_extension["specVersion"] = "1.0" + if not json.has("extensions"): + json["extensions"] = {} + json["extensions"]["VRMC_vrm"] = vrm_extension + _export_meta(root_node.vrm_meta, vrm_extension, gstate) + # FIXME: despite more than one material in use, only one material is in json["materials"] + + var orig_bone_map: BoneMap = root_node.vrm_meta.humanoid_bone_mapping + var orig_bone_name_dict: Dictionary = {} + if orig_bone_map != null and orig_bone_map.profile != null: + for i in range(orig_bone_map.profile.bone_size): + var bn: StringName = orig_bone_map.get_skeleton_bone_name(orig_bone_map.profile.get_bone_name(i)) + if not bn.is_empty(): + orig_bone_name_dict[orig_bone_map.profile.get_bone_name(i)] = bn + + var humanoid_skeleton: Skeleton3D = _get_humanoid_skel(root_node) + + var anim_player = gstate.get_meta("anim_player") + if anim_player != null: + root_node.add_child(anim_player) + anim_player.owner = root_node + _export_animations(root_node, humanoid_skeleton, anim_player, vrm_extension, gstate) + + var humanoid_bone_mapping: Dictionary = _validate_humanoid(root_node) + var godot_bone_to_gltf_node_map: Dictionary + var human_bones: Dictionary = {} + var humanoid: Dictionary = {"humanBones": human_bones} + for skel in gstate.skeletons: + if skel.get_godot_skeleton() == humanoid_skeleton: + godot_bone_to_gltf_node_map = skel.get_godot_bone_node() + + for vrm_bone in humanoid_bone_mapping: + var bone_idx = humanoid_skeleton.find_bone(humanoid_bone_mapping[vrm_bone]) + var gltf_node_idx: int = godot_bone_to_gltf_node_map[bone_idx] + human_bones[vrm_bone] = {"node": gltf_node_idx} + if orig_bone_name_dict.has(json_gltf_nodes[gltf_node_idx]["name"]): + json_gltf_nodes[gltf_node_idx]["name"] = orig_bone_name_dict[json_gltf_nodes[gltf_node_idx]["name"]] + vrm_extension["humanoid"] = humanoid + + if not _add_vrm_nodes_to_skin(json): + push_error("Export post failed to find vrm humanBones in VRMC_vrm extension") + return ERR_INVALID_DATA + # firstPerson + var first_person = {} + var mesh_annotations = [] + + var nodes = gstate.nodes + for node_idx in range(len(nodes)): + var gltf_node: GLTFNode = nodes[node_idx] + var node: Node = gstate.get_scene_node(node_idx) + if node is ImporterMeshInstance3D or node is MeshInstance3D: + var first_person_flag: String = "auto" + if node.has_meta("vrm_first_person_flag"): + first_person_flag = node.get_meta("vrm_first_person_flag") + elif node.skin != null and node.skin.has_meta("vrm_first_person_flag"): # HACK (ImporterMesh api limit) + first_person_flag = node.skin.get_meta("vrm_first_person_flag") + var mesh_annotation = {"node": node_idx, "type": first_person_flag} + mesh_annotations.append(mesh_annotation) + + first_person["meshAnnotations"] = mesh_annotations + vrm_extension["firstPerson"] = first_person + + # lookAt + var look_at: Dictionary = {} + if vrm_extension.has("lookAt"): + look_at = vrm_extension["lookAt"] + var look_offset: Vector3 = gstate.get_meta("look_offset") + if look_offset.is_equal_approx(Vector3.ZERO): + push_warning("Model must have a Head bone attachment with a child LookOffset.") + look_at["offsetFromHeadBone"] = [look_offset.x, look_offset.y, look_offset.z] + vrm_extension["lookAt"] = look_at + + return OK + + +func _import_preflight(gstate: GLTFState, extensions: PackedStringArray = PackedStringArray()) -> Error: + if not extensions.has("VRMC_vrm"): + return ERR_SKIP + 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 vrm humanBones in VRMC_vrm extension during import preflight") + return ERR_INVALID_DATA + for node in gltf_nodes: + if node.has("extensions") and node["extensions"].is_empty(): + node.erase("extensions") + if node.get("name", "") == "Root": + node["name"] = "Root_" + if node.get("name", "") == "AnimationPlayer": + node["name"] = "AnimationPlayer_" + if node.get("name", "") == "BoneNodeConstraintApplier": + node["name"] = "BoneNodeConstraintApplier_" + 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 root_node: Node = node + + var gltf_json: Dictionary = gstate.json + var gltf_nodes = gltf_json["nodes"] + for nodex in gltf_nodes: + if nodex.has("extensions") and nodex["extensions"].is_empty(): + nodex.erase("extensions") + var vrm_extension: Dictionary = gltf_json["extensions"]["VRMC_vrm"] + + var human_bone_to_idx: Dictionary = {} + # Ignoring in ["humanoid"]: armStretch, legStretch, upperArmTwist + # lowerArmTwist, upperLegTwist, lowerLegTwist, feetSpacing, + # and hasTranslationDoF + var human_bones: Dictionary = vrm_extension["humanoid"]["humanBones"] + for human_bone in human_bones: + human_bone_to_idx[human_bone] = int(human_bones[human_bone]["node"]) + + 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() + + for humanBoneName in human_bone_to_idx: + humanBones.set_skeleton_bone_name(vrm_constants_class.vrm_to_human_bone[humanBoneName], gltfnodes[human_bone_to_idx[humanBoneName]].resource_name) + + 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) + + 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) + + return OK diff --git a/engines/stage-tamagotchi-godot/addons/vrm/1.0/VRMC_vrm.gd.uid b/engines/stage-tamagotchi-godot/addons/vrm/1.0/VRMC_vrm.gd.uid new file mode 100644 index 000000000..5cf5a4c7e --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/1.0/VRMC_vrm.gd.uid @@ -0,0 +1 @@ +uid://cd7qg71xy786j diff --git a/engines/stage-tamagotchi-godot/addons/vrm/1.0/VRMC_vrm_animation.gd b/engines/stage-tamagotchi-godot/addons/vrm/1.0/VRMC_vrm_animation.gd new file mode 100644 index 000000000..9dc3fcf00 --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/1.0/VRMC_vrm_animation.gd @@ -0,0 +1,3 @@ +extends GLTFDocumentExtension + +pass diff --git a/engines/stage-tamagotchi-godot/addons/vrm/1.0/VRMC_vrm_animation.gd.uid b/engines/stage-tamagotchi-godot/addons/vrm/1.0/VRMC_vrm_animation.gd.uid new file mode 100644 index 000000000..bab6edc42 --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/1.0/VRMC_vrm_animation.gd.uid @@ -0,0 +1 @@ +uid://dkd2cmpksxlvw diff --git a/engines/stage-tamagotchi-godot/addons/vrm/LICENSE b/engines/stage-tamagotchi-godot/addons/vrm/LICENSE new file mode 100644 index 000000000..cb069513d --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/LICENSE @@ -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. diff --git a/engines/stage-tamagotchi-godot/addons/vrm/README.ja.md b/engines/stage-tamagotchi-godot/addons/vrm/README.ja.md new file mode 100644 index 000000000..abea7682d --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/README.ja.md @@ -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 diff --git a/engines/stage-tamagotchi-godot/addons/vrm/README.md b/engines/stage-tamagotchi-godot/addons/vrm/README.md new file mode 100644 index 000000000..ba8efdb4b --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/README.md @@ -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`: ✅I️mplemented (`BoneAttachment3D` `"LookOffset"` on `Head`) + * `lookAt`: ⚠Only creates animation tracks (application must create `BlendSpace2D`) + * `expressions` (mood, viseme): + * blend shapes / binds: ✅I️mplemented (Animation tracks intended for `BlendTree` `Add2`) + * material color / UV offsets: ✅I️mplemented (Animation tracks intended for `BlendTree` `Add2`) + * `humanoid`: ✅I️mplemented (uses `%GeneralSkeleton` `SkeletonProfileHumanoid` compatible retargeting.) + * Metadata: ✅I️mplemented, 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 diff --git a/engines/stage-tamagotchi-godot/addons/vrm/import_vrm.gd b/engines/stage-tamagotchi-godot/addons/vrm/import_vrm.gd new file mode 100644 index 000000000..56ef9d505 --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/import_vrm.gd @@ -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 diff --git a/engines/stage-tamagotchi-godot/addons/vrm/import_vrm.gd.uid b/engines/stage-tamagotchi-godot/addons/vrm/import_vrm.gd.uid new file mode 100644 index 000000000..ef767202d --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/import_vrm.gd.uid @@ -0,0 +1 @@ +uid://dejowscl2y1n0 diff --git a/engines/stage-tamagotchi-godot/addons/vrm/importer_mesh_attributes.gd b/engines/stage-tamagotchi-godot/addons/vrm/importer_mesh_attributes.gd new file mode 100644 index 000000000..dc2d4fb69 --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/importer_mesh_attributes.gd @@ -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) diff --git a/engines/stage-tamagotchi-godot/addons/vrm/importer_mesh_attributes.gd.uid b/engines/stage-tamagotchi-godot/addons/vrm/importer_mesh_attributes.gd.uid new file mode 100644 index 000000000..ce639dfa9 --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/importer_mesh_attributes.gd.uid @@ -0,0 +1 @@ +uid://dn77qs3l0rnn8 diff --git a/engines/stage-tamagotchi-godot/addons/vrm/node_constraint/bone_node_constraint.gd b/engines/stage-tamagotchi-godot/addons/vrm/node_constraint/bone_node_constraint.gd new file mode 100644 index 000000000..15cbb68c8 --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/node_constraint/bone_node_constraint.gd @@ -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 "" diff --git a/engines/stage-tamagotchi-godot/addons/vrm/node_constraint/bone_node_constraint.gd.uid b/engines/stage-tamagotchi-godot/addons/vrm/node_constraint/bone_node_constraint.gd.uid new file mode 100644 index 000000000..24bace5f6 --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/node_constraint/bone_node_constraint.gd.uid @@ -0,0 +1 @@ +uid://oyeyy4kgkpxh diff --git a/engines/stage-tamagotchi-godot/addons/vrm/node_constraint/bone_node_constraint_applier.gd b/engines/stage-tamagotchi-godot/addons/vrm/node_constraint/bone_node_constraint_applier.gd new file mode 100644 index 000000000..aaf211e1b --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/node_constraint/bone_node_constraint_applier.gd @@ -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() diff --git a/engines/stage-tamagotchi-godot/addons/vrm/node_constraint/bone_node_constraint_applier.gd.uid b/engines/stage-tamagotchi-godot/addons/vrm/node_constraint/bone_node_constraint_applier.gd.uid new file mode 100644 index 000000000..5fecb63e9 --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/node_constraint/bone_node_constraint_applier.gd.uid @@ -0,0 +1 @@ +uid://82m5iq64h7yk diff --git a/engines/stage-tamagotchi-godot/addons/vrm/node_constraint/icons/bone_node_constraint.svg b/engines/stage-tamagotchi-godot/addons/vrm/node_constraint/icons/bone_node_constraint.svg new file mode 100644 index 000000000..986fafba9 --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/node_constraint/icons/bone_node_constraint.svg @@ -0,0 +1 @@ + diff --git a/engines/stage-tamagotchi-godot/addons/vrm/node_constraint/icons/bone_node_constraint.svg.import b/engines/stage-tamagotchi-godot/addons/vrm/node_constraint/icons/bone_node_constraint.svg.import new file mode 100644 index 000000000..aca44f822 --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/node_constraint/icons/bone_node_constraint.svg.import @@ -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 diff --git a/engines/stage-tamagotchi-godot/addons/vrm/node_constraint/icons/bone_node_constraint_applier.svg b/engines/stage-tamagotchi-godot/addons/vrm/node_constraint/icons/bone_node_constraint_applier.svg new file mode 100644 index 000000000..f1c9012c9 --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/node_constraint/icons/bone_node_constraint_applier.svg @@ -0,0 +1 @@ + diff --git a/engines/stage-tamagotchi-godot/addons/vrm/node_constraint/icons/bone_node_constraint_applier.svg.import b/engines/stage-tamagotchi-godot/addons/vrm/node_constraint/icons/bone_node_constraint_applier.svg.import new file mode 100644 index 000000000..67cd4be94 --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/node_constraint/icons/bone_node_constraint_applier.svg.import @@ -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 diff --git a/engines/stage-tamagotchi-godot/addons/vrm/plugin.cfg b/engines/stage-tamagotchi-godot/addons/vrm/plugin.cfg new file mode 100644 index 000000000..0ee040134 --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/plugin.cfg @@ -0,0 +1,7 @@ +[plugin] + +name="VRM" +description="VRM Importer" +author="V-Sekai" +version="2.0.1" +script="plugin.gd" diff --git a/engines/stage-tamagotchi-godot/addons/vrm/plugin.gd b/engines/stage-tamagotchi-godot/addons/vrm/plugin.gd new file mode 100644 index 000000000..6a09fa8d3 --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/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 diff --git a/engines/stage-tamagotchi-godot/addons/vrm/plugin.gd.uid b/engines/stage-tamagotchi-godot/addons/vrm/plugin.gd.uid new file mode 100644 index 000000000..b5393943b --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/plugin.gd.uid @@ -0,0 +1 @@ +uid://ypkxojghtx27 diff --git a/engines/stage-tamagotchi-godot/addons/vrm/vrm_collider.gd b/engines/stage-tamagotchi-godot/addons/vrm/vrm_collider.gd new file mode 100644 index 000000000..374b2be77 --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/vrm_collider.gd @@ -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)))) diff --git a/engines/stage-tamagotchi-godot/addons/vrm/vrm_collider.gd.uid b/engines/stage-tamagotchi-godot/addons/vrm/vrm_collider.gd.uid new file mode 100644 index 000000000..f9f3f5617 --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/vrm_collider.gd.uid @@ -0,0 +1 @@ +uid://drf7ihlxgty0d diff --git a/engines/stage-tamagotchi-godot/addons/vrm/vrm_collider_group.gd b/engines/stage-tamagotchi-godot/addons/vrm/vrm_collider_group.gd new file mode 100644 index 000000000..b6946d5d6 --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/vrm_collider_group.gd @@ -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] diff --git a/engines/stage-tamagotchi-godot/addons/vrm/vrm_collider_group.gd.uid b/engines/stage-tamagotchi-godot/addons/vrm/vrm_collider_group.gd.uid new file mode 100644 index 000000000..fa0725717 --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/vrm_collider_group.gd.uid @@ -0,0 +1 @@ +uid://2sgb2b08g57x diff --git a/engines/stage-tamagotchi-godot/addons/vrm/vrm_constants.gd b/engines/stage-tamagotchi-godot/addons/vrm/vrm_constants.gd new file mode 100644 index 000000000..fb64a0083 --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/vrm_constants.gd @@ -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, +} diff --git a/engines/stage-tamagotchi-godot/addons/vrm/vrm_constants.gd.uid b/engines/stage-tamagotchi-godot/addons/vrm/vrm_constants.gd.uid new file mode 100644 index 000000000..0fc9972ff --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/vrm_constants.gd.uid @@ -0,0 +1 @@ +uid://difdluwlv5dk1 diff --git a/engines/stage-tamagotchi-godot/addons/vrm/vrm_extension.gd b/engines/stage-tamagotchi-godot/addons/vrm/vrm_extension.gd new file mode 100644 index 000000000..83db7015b --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/vrm_extension.gd @@ -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 diff --git a/engines/stage-tamagotchi-godot/addons/vrm/vrm_extension.gd.uid b/engines/stage-tamagotchi-godot/addons/vrm/vrm_extension.gd.uid new file mode 100644 index 000000000..76a0472f4 --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/vrm_extension.gd.uid @@ -0,0 +1 @@ +uid://c0qjj7ewa80bs diff --git a/engines/stage-tamagotchi-godot/addons/vrm/vrm_meta.gd b/engines/stage-tamagotchi-godot/addons/vrm/vrm_meta.gd new file mode 100644 index 000000000..f42fd2525 --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/vrm_meta.gd @@ -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 diff --git a/engines/stage-tamagotchi-godot/addons/vrm/vrm_meta.gd.uid b/engines/stage-tamagotchi-godot/addons/vrm/vrm_meta.gd.uid new file mode 100644 index 000000000..d6fb8618b --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/vrm_meta.gd.uid @@ -0,0 +1 @@ +uid://b1ee4q1my1ig4 diff --git a/engines/stage-tamagotchi-godot/addons/vrm/vrm_options_post_import_plugin.gd b/engines/stage-tamagotchi-godot/addons/vrm/vrm_options_post_import_plugin.gd new file mode 100644 index 000000000..66881910e --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/vrm_options_post_import_plugin.gd @@ -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) diff --git a/engines/stage-tamagotchi-godot/addons/vrm/vrm_options_post_import_plugin.gd.uid b/engines/stage-tamagotchi-godot/addons/vrm/vrm_options_post_import_plugin.gd.uid new file mode 100644 index 000000000..b47d0ca4f --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/vrm_options_post_import_plugin.gd.uid @@ -0,0 +1 @@ +uid://delu28murwhas diff --git a/engines/stage-tamagotchi-godot/addons/vrm/vrm_secondary.gd b/engines/stage-tamagotchi-godot/addons/vrm/vrm_secondary.gd new file mode 100644 index 000000000..c787fa0e7 --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/vrm_secondary.gd @@ -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) diff --git a/engines/stage-tamagotchi-godot/addons/vrm/vrm_secondary.gd.uid b/engines/stage-tamagotchi-godot/addons/vrm/vrm_secondary.gd.uid new file mode 100644 index 000000000..453c4c2a1 --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/vrm_secondary.gd.uid @@ -0,0 +1 @@ +uid://cfoeurjclosmf diff --git a/engines/stage-tamagotchi-godot/addons/vrm/vrm_spring_bone.gd b/engines/stage-tamagotchi-godot/addons/vrm/vrm_spring_bone.gd new file mode 100644 index 000000000..2399ed80a --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/vrm_spring_bone.gd @@ -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) diff --git a/engines/stage-tamagotchi-godot/addons/vrm/vrm_spring_bone.gd.uid b/engines/stage-tamagotchi-godot/addons/vrm/vrm_spring_bone.gd.uid new file mode 100644 index 000000000..cb3a15f20 --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/vrm_spring_bone.gd.uid @@ -0,0 +1 @@ +uid://doa525e85v1s5 diff --git a/engines/stage-tamagotchi-godot/addons/vrm/vrm_spring_bone_logic.gd b/engines/stage-tamagotchi-godot/addons/vrm/vrm_spring_bone_logic.gd new file mode 100644 index 000000000..739353365 --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/vrm_spring_bone_logic.gd @@ -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) diff --git a/engines/stage-tamagotchi-godot/addons/vrm/vrm_spring_bone_logic.gd.uid b/engines/stage-tamagotchi-godot/addons/vrm/vrm_spring_bone_logic.gd.uid new file mode 100644 index 000000000..ac7635e35 --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/vrm_spring_bone_logic.gd.uid @@ -0,0 +1 @@ +uid://bsi1pnsroitvh diff --git a/engines/stage-tamagotchi-godot/addons/vrm/vrm_toplevel.gd b/engines/stage-tamagotchi-godot/addons/vrm/vrm_toplevel.gd new file mode 100644 index 000000000..3cb50f6da --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/vrm_toplevel.gd @@ -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] diff --git a/engines/stage-tamagotchi-godot/addons/vrm/vrm_toplevel.gd.uid b/engines/stage-tamagotchi-godot/addons/vrm/vrm_toplevel.gd.uid new file mode 100644 index 000000000..fd0973fd1 --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/vrm_toplevel.gd.uid @@ -0,0 +1 @@ +uid://c6gcjg17qcr77 diff --git a/engines/stage-tamagotchi-godot/addons/vrm/vrm_utils.gd b/engines/stage-tamagotchi-godot/addons/vrm/vrm_utils.gd new file mode 100644 index 000000000..169c6c6df --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/vrm_utils.gd @@ -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 diff --git a/engines/stage-tamagotchi-godot/addons/vrm/vrm_utils.gd.uid b/engines/stage-tamagotchi-godot/addons/vrm/vrm_utils.gd.uid new file mode 100644 index 000000000..f5cb2e20d --- /dev/null +++ b/engines/stage-tamagotchi-godot/addons/vrm/vrm_utils.gd.uid @@ -0,0 +1 @@ +uid://dhn20pflnapkg diff --git a/engines/stage-tamagotchi-godot/assets/fixtures/vrm/.gitkeep b/engines/stage-tamagotchi-godot/assets/fixtures/vrm/.gitkeep new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/engines/stage-tamagotchi-godot/assets/fixtures/vrm/.gitkeep @@ -0,0 +1 @@ + diff --git a/engines/stage-tamagotchi-godot/docs/live-debugging.md b/engines/stage-tamagotchi-godot/docs/live-debugging.md new file mode 100644 index 000000000..d75b3b82c --- /dev/null +++ b/engines/stage-tamagotchi-godot/docs/live-debugging.md @@ -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_ + ``` + +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=` 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. diff --git a/engines/stage-tamagotchi-godot/docs/vendor-patches.md b/engines/stage-tamagotchi-godot/docs/vendor-patches.md new file mode 100644 index 000000000..cb9da2908 --- /dev/null +++ b/engines/stage-tamagotchi-godot/docs/vendor-patches.md @@ -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. diff --git a/engines/stage-tamagotchi-godot/docs/vrm-runtime-import.md b/engines/stage-tamagotchi-godot/docs/vrm-runtime-import.md new file mode 100644 index 000000000..165d272a6 --- /dev/null +++ b/engines/stage-tamagotchi-godot/docs/vrm-runtime-import.md @@ -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//` 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. diff --git a/engines/stage-tamagotchi-godot/project.godot b/engines/stage-tamagotchi-godot/project.godot index 7063e921b..fb069a9bb 100644 --- a/engines/stage-tamagotchi-godot/project.godot +++ b/engines/stage-tamagotchi-godot/project.godot @@ -19,6 +19,10 @@ config/icon="res://icon.svg" project/assembly_name="stage-tamagotchi-godot" +[editor_plugins] + +enabled=PackedStringArray("res://addons/Godot-MToon-Shader/plugin.cfg", "res://addons/vrm/plugin.cfg") + [physics] 3d/physics_engine="Jolt Physics" diff --git a/engines/stage-tamagotchi-godot/scenes/stage-root.tscn b/engines/stage-tamagotchi-godot/scenes/stage-root.tscn index cea40d137..7a1d0d481 100644 --- a/engines/stage-tamagotchi-godot/scenes/stage-root.tscn +++ b/engines/stage-tamagotchi-godot/scenes/stage-root.tscn @@ -1,15 +1,14 @@ -[gd_scene format=3 uid="uid://c4t68p7ibkjxg"] +[gd_scene load_steps=2 format=3 uid="uid://c4t68p7ibkjxg"] [ext_resource type="Script" uid="uid://cwngwb6eal55x" path="res://scripts/StageRoot.cs" id="1_k8kx6"] -[sub_resource type="BoxMesh" id="BoxMesh_mr4xw"] - -[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_f1a2p"] -albedo_color = Color(0.772549, 0.882353, 0.984314, 1) - [node name="Node3D" type="Node3D" unique_id=1704384288] script = ExtResource("1_k8kx6") +[node name="EditorPreviewRoot" type="Node3D" parent="." unique_id=1275565450] + +[node name="AvatarRoot" type="Node3D" parent="." unique_id=840596041] + [node name="Camera3D" type="Camera3D" parent="." unique_id=1342789791] transform = Transform3D(1, 0, 0, 0, 0.93969274, 0.34201992, 0, -0.34201992, 0.93969274, 0, 1.2, 3.5) current = true @@ -18,7 +17,3 @@ current = true transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 2, 2, 2) light_color = Color(1, 0.956863, 0.909804, 1) omni_range = 12.0 - -[node name="Box" type="MeshInstance3D" parent="." unique_id=840596041] -mesh = SubResource("BoxMesh_mr4xw") -surface_material_override/0 = SubResource("StandardMaterial3D_f1a2p") diff --git a/engines/stage-tamagotchi-godot/scripts/StageRoot.cs b/engines/stage-tamagotchi-godot/scripts/StageRoot.cs index dfa87a858..00061430c 100644 --- a/engines/stage-tamagotchi-godot/scripts/StageRoot.cs +++ b/engines/stage-tamagotchi-godot/scripts/StageRoot.cs @@ -1,10 +1,34 @@ using System; -using System.IO; using System.Text.Json; using Godot; +/// +/// Root node for the Godot sidecar stage runtime. +/// +/// Use when: +/// - Running the desktop Godot stage through Electron main. +/// - Receiving scene input from the current stage settings model selection. +/// +/// Expects: +/// - Electron launches Godot with --airi-ws-url=<runtime-url>. +/// - The scene contains or can create an avatar root node. +/// +/// Returns: +/// - A running stage process that reports ready/applied/error envelopes to Electron main. +/// +/// Call stack: +/// +/// Godot scene tree +/// -> +/// -> +/// -> +/// -> +/// -> +/// public partial class StageRoot : Node3D { + private const string AvatarRootNodeName = "AvatarRoot"; + private const string EditorPreviewRootNodeName = "EditorPreviewRoot"; private const string WebSocketUrlArgumentPrefix = "--airi-ws-url="; private readonly JsonSerializerOptions _jsonOptions = new() @@ -12,27 +36,34 @@ public partial class StageRoot : Node3D PropertyNameCaseInsensitive = true, }; - private readonly WebSocketPeer _socket = new(); - + private StageBridge _bridge = null!; + private StageSceneController _sceneController = null!; private Label3D _statusLabel = null!; - private bool _readyAnnounced; private bool _shutdownRequested; + /// public override void _Ready() { + HideEditorPreviewRoot(); _statusLabel = CreateStatusLabel(); AddChild(_statusLabel); + _sceneController = new StageSceneController(ResolveAvatarRoot(), new VrmAvatarLoader()); + var webSocketUrl = ResolveWebSocketUrl(); if (string.IsNullOrWhiteSpace(webSocketUrl)) { UpdateStatus("Missing Electron bridge URL."); - GD.PushError("Godot stage missing --airi-ws-url argument."); - GetTree().Quit(); + GD.PushWarning("Godot stage missing --airi-ws-url argument."); return; } - var connectError = _socket.ConnectToUrl(webSocketUrl); + _bridge = new StageBridge(_jsonOptions); + _bridge.Opened += HandleBridgeOpened; + _bridge.MessageReceived += HandleMessage; + _bridge.Closed += HandleBridgeClosed; + + var connectError = _bridge.Connect(webSocketUrl); if (connectError != Error.Ok) { UpdateStatus("Failed to connect to Electron main."); @@ -42,52 +73,75 @@ public partial class StageRoot : Node3D } UpdateStatus("Connecting to Electron main..."); - GD.Print($"StageRoot connecting to {webSocketUrl}"); } + /// public override void _Process(double delta) { - _socket.Poll(); - - switch (_socket.GetReadyState()) + if (_bridge == null) { - case WebSocketPeer.State.Open: - if (!_readyAnnounced) - { - SendEnvelope("stage.ready"); - _readyAnnounced = true; - UpdateStatus("Connected to Electron main."); - } - - while (_socket.GetAvailablePacketCount() > 0) - { - HandleMessage(_socket.GetPacket().GetStringFromUtf8()); - } - break; - case WebSocketPeer.State.Closed: - if (_shutdownRequested) - { - GetTree().Quit(); - return; - } - - var message = $"Electron bridge closed ({_socket.GetCloseCode()})."; - UpdateStatus(message); - GD.PushWarning(message); - GetTree().Quit(); - break; + return; } + + _bridge.Poll(); } - private Label3D CreateStatusLabel() + private void HandleBridgeOpened() + { + _bridge.SendEnvelope("stage.ready"); + UpdateStatus("Connected to Electron main."); + } + + private void HandleBridgeClosed(string message) + { + if (_shutdownRequested) + { + GetTree().Quit(); + return; + } + + UpdateStatus(message); + GD.PushWarning(message); + GetTree().Quit(); + } + + private Node3D ResolveAvatarRoot() + { + var avatarRoot = GetNodeOrNull(AvatarRootNodeName); + if (avatarRoot != null) + { + return avatarRoot; + } + + avatarRoot = new Node3D + { + Name = AvatarRootNodeName, + }; + AddChild(avatarRoot); + return avatarRoot; + } + + private void HideEditorPreviewRoot() + { + var editorPreviewRoot = GetNodeOrNull(EditorPreviewRootNodeName); + if (editorPreviewRoot == null) + { + return; + } + + editorPreviewRoot.Visible = false; + editorPreviewRoot.ProcessMode = ProcessModeEnum.Disabled; + } + + private static Label3D CreateStatusLabel() { return new Label3D { Billboard = BaseMaterial3D.BillboardModeEnum.Enabled, - FontSize = 56, + FontSize = 34, Modulate = new Color(0.95f, 0.98f, 1.0f), PixelSize = 0.0035f, - Position = new Vector3(0.0f, 1.35f, 0.0f), + Position = new Vector3(-1.45f, 1.75f, 0.0f), Text = "Godot Stage (experimental)", }; } @@ -96,7 +150,7 @@ public partial class StageRoot : Node3D { try { - var envelope = JsonSerializer.Deserialize(rawMessage, _jsonOptions); + var envelope = JsonSerializer.Deserialize(rawMessage, _jsonOptions); if (envelope == null || string.IsNullOrWhiteSpace(envelope.Type)) { return; @@ -118,10 +172,7 @@ public partial class StageRoot : Node3D { var message = $"Failed to parse Electron message: {error.Message}"; UpdateStatus(message); - SendEnvelope("scene.error", new - { - message, - }); + SendSceneError(message); } } @@ -129,24 +180,23 @@ public partial class StageRoot : Node3D { if (payloadElement == null) { - SendEnvelope("scene.error", new - { - message = "Scene input payload was empty.", - }); + SendSceneError("Scene input payload was empty."); return; } try { - var payload = payloadElement.Value.Deserialize(_jsonOptions); + var payload = payloadElement.Value.Deserialize(_jsonOptions); if (payload == null) { throw new InvalidOperationException("Scene input payload could not be parsed."); } - var fileName = Path.GetFileName(payload.Path); + _sceneController.Apply(payload); + var fileName = System.IO.Path.GetFileName(payload.Path); UpdateStatus($"Connected to Electron main.\nModel: {payload.Name}\nAsset: {fileName}"); - SendEnvelope("scene.applied", new + + _bridge.SendEnvelope("scene.applied", new { modelId = payload.ModelId, }); @@ -155,16 +205,19 @@ public partial class StageRoot : Node3D { var message = $"Failed to apply scene input: {error.Message}"; UpdateStatus(message); - SendEnvelope("scene.error", new - { - message, - }); + SendSceneError(message); } } private static string ResolveWebSocketUrl() { - foreach (var argument in OS.GetCmdlineUserArgs()) + string[] arguments = OS.GetCmdlineUserArgs(); + if (arguments.Length == 0) + { + arguments = OS.GetCmdlineArgs(); + } + + foreach (var argument in arguments) { if (argument.StartsWith(WebSocketUrlArgumentPrefix, StringComparison.Ordinal)) { @@ -175,31 +228,14 @@ public partial class StageRoot : Node3D return string.Empty; } - private void SendEnvelope(string type, object payload = null) + private void SendSceneError(string message) { - if (_socket.GetReadyState() != WebSocketPeer.State.Open) + _bridge.SendEnvelope("scene.error", new { - return; - } - - _socket.SendText(JsonSerializer.Serialize(new - { - type, - payload, - }, _jsonOptions)); + message, + }); } - private void UpdateStatus(string message) - { + private void UpdateStatus(string message) => _statusLabel.Text = $"Godot Stage (experimental)\n{message}"; - } - - private sealed record GodotEnvelope(string Type, JsonElement? Payload); - - private sealed record SceneApplyPayload( - string ModelId, - string Format, - string Name, - string Path - ); } diff --git a/engines/stage-tamagotchi-godot/scripts/scene/StageSceneController.cs b/engines/stage-tamagotchi-godot/scripts/scene/StageSceneController.cs new file mode 100644 index 000000000..a960acfa7 --- /dev/null +++ b/engines/stage-tamagotchi-godot/scripts/scene/StageSceneController.cs @@ -0,0 +1,106 @@ +using System; +using Godot; + +/// +/// Owns the currently displayed avatar node and applies new scene input atomically. +/// +/// Use when: +/// - Electron main sends a materialized VRM file path. +/// - Godot must replace the active stage avatar after a successful load. +/// +/// Expects: +/// - The payload has already arrived through the stage bridge. +/// - can import the file into a detached Godot node. +/// +/// Returns: +/// - The newly loaded node is added under the configured avatar root. +/// - The previous avatar is removed only after the new import succeeds. +/// +/// Call stack: +/// +/// StageRoot.HandleMessage +/// -> +/// -> +/// -> VrmRuntimeImporter.gd +/// +public sealed class StageSceneController +{ + private const string SupportedFormat = "vrm"; + + private readonly Node3D _avatarRoot; + private readonly VrmAvatarLoader _vrmAvatarLoader; + + private Node _currentAvatar; + + public StageSceneController(Node3D avatarRoot, VrmAvatarLoader vrmAvatarLoader) + { + _avatarRoot = avatarRoot; + _vrmAvatarLoader = vrmAvatarLoader; + } + + public Node Apply(StageSceneApplyPayload payload) + { + if (!string.Equals(payload.Format, SupportedFormat, StringComparison.Ordinal)) + { + throw new InvalidOperationException($"Unsupported scene input format: {payload.Format}."); + } + + var nextAvatar = _vrmAvatarLoader.Load(payload); + nextAvatar.Name = AvatarNodeName(payload.ModelId); + + CommitAvatar(nextAvatar); + return nextAvatar; + } + + private void CommitAvatar(Node nextAvatar) + { + var previousAvatar = _currentAvatar; + + _avatarRoot.AddChild(nextAvatar); + RefreshSkeletonPoseState(nextAvatar); + _currentAvatar = nextAvatar; + + if (previousAvatar == null) + { + return; + } + + if (previousAvatar.GetParent() == _avatarRoot) + { + _avatarRoot.RemoveChild(previousAvatar); + } + + previousAvatar.QueueFree(); + } + + private static string AvatarNodeName(string modelId) + { + return $"Avatar_{modelId}"; + } + + private static void RefreshSkeletonPoseState(Node node) + { + if (node is Skeleton3D skeleton) + { + // NOTICE: + // Godot 4.6 runtime GLTFDocument import can leave Skeleton3D pose-global state stale + // after VRM 0.x retargeting, even though rest, pose, Skin binds, and global rest are + // correct. Reapplying the current local pose after the node enters the scene tree marks + // the pose state dirty and makes get_bone_global_pose() match editor-imported VRM + // scenes. Remove when Godot or the vendored VRM importer initializes runtime skeleton + // pose globals consistently with editor import. + for (var boneIndex = 0; boneIndex < skeleton.GetBoneCount(); boneIndex++) + { + var pose = skeleton.GetBonePose(boneIndex); + skeleton.SetBonePosePosition(boneIndex, pose.Origin); + skeleton.SetBonePoseRotation(boneIndex, pose.Basis.GetRotationQuaternion()); + skeleton.SetBonePoseScale(boneIndex, pose.Basis.Scale); + } + } + + foreach (Node child in node.GetChildren()) + { + RefreshSkeletonPoseState(child); + } + } +} diff --git a/engines/stage-tamagotchi-godot/scripts/scene/StageSceneController.cs.uid b/engines/stage-tamagotchi-godot/scripts/scene/StageSceneController.cs.uid new file mode 100644 index 000000000..9a7dee446 --- /dev/null +++ b/engines/stage-tamagotchi-godot/scripts/scene/StageSceneController.cs.uid @@ -0,0 +1 @@ +uid://c3eurggsu51vc diff --git a/engines/stage-tamagotchi-godot/scripts/transport/StageBridge.cs b/engines/stage-tamagotchi-godot/scripts/transport/StageBridge.cs new file mode 100644 index 000000000..da9ac6d4a --- /dev/null +++ b/engines/stage-tamagotchi-godot/scripts/transport/StageBridge.cs @@ -0,0 +1,158 @@ +using System; +using System.Text.Json; +using Godot; + +/// +/// Owns the localhost WebSocket connection to the Electron host process. +/// +/// Use when: +/// - The Godot stage needs to receive host messages. +/// - The Godot stage needs to report ready, applied, or error events. +/// +/// Expects: +/// - Electron main has already opened a local WebSocket endpoint. +/// - is called from Godot's frame loop. +/// +/// Returns: +/// - Message events through . +/// - Host-visible status messages through . +/// +/// Call stack: +/// +/// StageRoot._Process +/// -> +/// -> +/// -> StageRoot.HandleMessage +/// +public sealed class StageBridge +{ + private readonly JsonSerializerOptions _jsonOptions; + private readonly WebSocketPeer _socket = new(); + + private bool _closedAnnounced; + private bool _readyAnnounced; + + public event Action Opened; + + public event Action MessageReceived; + + public event Action Closed; + + /// + /// Creates a bridge with the JSON options shared by the runtime message parser. + /// + /// Use when: + /// - Stage runtime starts and needs a host bridge. + /// + /// Expects: + /// - matches the host envelope casing policy. + /// + /// Returns: + /// - A bridge ready to connect to Electron main. + /// + public StageBridge(JsonSerializerOptions jsonOptions) + { + _jsonOptions = jsonOptions; + } + + /// + /// Connects to Electron main's localhost WebSocket endpoint. + /// + /// Use when: + /// - The Godot scene has resolved --airi-ws-url. + /// + /// Expects: + /// - The URL points to a local endpoint created by Electron main. + /// + /// Returns: + /// - Godot's connection error code. + /// + public Error Connect(string webSocketUrl) + { + return _socket.ConnectToUrl(webSocketUrl); + } + + /// + /// Pumps the WebSocket connection and dispatches received host messages. + /// + /// Use when: + /// - Called from . + /// + /// Expects: + /// - has already been called. + /// + /// Returns: + /// - Events emitted through , , and . + /// + public void Poll() + { + _socket.Poll(); + + switch (_socket.GetReadyState()) + { + case WebSocketPeer.State.Open: + AnnounceReadyOnce(); + DrainMessages(); + break; + case WebSocketPeer.State.Closed: + AnnounceClosedOnce(); + break; + } + } + + /// + /// Sends a typed JSON envelope to Electron main. + /// + /// Use when: + /// - Godot reports runtime status back to the host. + /// + /// Expects: + /// - The WebSocket is already open; otherwise the message is ignored. + /// + /// Returns: + /// - No value. The message is queued through Godot's WebSocket peer. + /// + public void SendEnvelope(string type, object payload = null) + { + if (_socket.GetReadyState() != WebSocketPeer.State.Open) + { + return; + } + + _socket.SendText(JsonSerializer.Serialize(new + { + type, + payload, + }, _jsonOptions)); + } + + private void AnnounceReadyOnce() + { + if (_readyAnnounced) + { + return; + } + + _readyAnnounced = true; + Opened?.Invoke(); + } + + private void DrainMessages() + { + while (_socket.GetAvailablePacketCount() > 0) + { + MessageReceived?.Invoke(_socket.GetPacket().GetStringFromUtf8()); + } + } + + private void AnnounceClosedOnce() + { + if (_closedAnnounced) + { + return; + } + + _closedAnnounced = true; + Closed?.Invoke($"Electron bridge closed ({_socket.GetCloseCode()})."); + } +} diff --git a/engines/stage-tamagotchi-godot/scripts/transport/StageBridge.cs.uid b/engines/stage-tamagotchi-godot/scripts/transport/StageBridge.cs.uid new file mode 100644 index 000000000..9272fa543 --- /dev/null +++ b/engines/stage-tamagotchi-godot/scripts/transport/StageBridge.cs.uid @@ -0,0 +1 @@ +uid://brx5eplxrokyc diff --git a/engines/stage-tamagotchi-godot/scripts/transport/StageEnvelope.cs b/engines/stage-tamagotchi-godot/scripts/transport/StageEnvelope.cs new file mode 100644 index 000000000..bb7c0fa48 --- /dev/null +++ b/engines/stage-tamagotchi-godot/scripts/transport/StageEnvelope.cs @@ -0,0 +1,8 @@ +using System.Text.Json; + +/// +/// Message envelope exchanged between Electron main and the Godot sidecar. +/// +/// Stable message type string, such as host.scene.apply. +/// Optional JSON payload owned by the message type. +public sealed record StageEnvelope(string Type, JsonElement? Payload); diff --git a/engines/stage-tamagotchi-godot/scripts/transport/StageEnvelope.cs.uid b/engines/stage-tamagotchi-godot/scripts/transport/StageEnvelope.cs.uid new file mode 100644 index 000000000..0b96b388a --- /dev/null +++ b/engines/stage-tamagotchi-godot/scripts/transport/StageEnvelope.cs.uid @@ -0,0 +1 @@ +uid://csysfgt2v2kkm diff --git a/engines/stage-tamagotchi-godot/scripts/transport/StageSceneApplyPayload.cs b/engines/stage-tamagotchi-godot/scripts/transport/StageSceneApplyPayload.cs new file mode 100644 index 000000000..0cb813f59 --- /dev/null +++ b/engines/stage-tamagotchi-godot/scripts/transport/StageSceneApplyPayload.cs @@ -0,0 +1,13 @@ +/// +/// Scene input payload sent by Electron main after it materializes a selected VRM model. +/// +/// Stable selected-model id from the stage settings store. +/// Supported asset format. G1.1 accepts vrm only. +/// Human-readable model name for the runtime status UI. +/// Native absolute file path under Electron's userData materialization directory. +public sealed record StageSceneApplyPayload( + string ModelId, + string Format, + string Name, + string Path +); diff --git a/engines/stage-tamagotchi-godot/scripts/transport/StageSceneApplyPayload.cs.uid b/engines/stage-tamagotchi-godot/scripts/transport/StageSceneApplyPayload.cs.uid new file mode 100644 index 000000000..f904d20b7 --- /dev/null +++ b/engines/stage-tamagotchi-godot/scripts/transport/StageSceneApplyPayload.cs.uid @@ -0,0 +1 @@ +uid://b5gmttdl3ipr0 diff --git a/engines/stage-tamagotchi-godot/scripts/vrm/AiriVrmRuntimeExtension.gd b/engines/stage-tamagotchi-godot/scripts/vrm/AiriVrmRuntimeExtension.gd new file mode 100644 index 000000000..2f73fda49 --- /dev/null +++ b/engines/stage-tamagotchi-godot/scripts/vrm/AiriVrmRuntimeExtension.gd @@ -0,0 +1,31 @@ +extends "res://addons/vrm/vrm_extension.gd" + +# NOTICE: +# Godot 4.6 reports an internal Dictionary::operator[] bug when +# GLTFState.get_additional_data() reads a missing key from this import preflight. +# The AIRI runtime importer seeds `vrm/already_processed` before append_from_file, +# then this extension treats only `true` as already processed. This keeps the +# V-Sekai VRM 0.x preflight behavior while avoiding the missing-key error. +# Source context: +# - `addons/vrm/vrm_extension.gd:_import_preflight` +# - Godot docs: GLTFState.get_additional_data() should return null if unset. +# Removal condition: +# - Remove when the vendored VRM add-on or Godot 4.6+ no longer emits the missing-key error. + + +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 gstate.get_additional_data(&"vrm/already_processed") == true: + 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 diff --git a/engines/stage-tamagotchi-godot/scripts/vrm/AiriVrmRuntimeExtension.gd.uid b/engines/stage-tamagotchi-godot/scripts/vrm/AiriVrmRuntimeExtension.gd.uid new file mode 100644 index 000000000..bde831503 --- /dev/null +++ b/engines/stage-tamagotchi-godot/scripts/vrm/AiriVrmRuntimeExtension.gd.uid @@ -0,0 +1 @@ +uid://b4fdd8yvl6c34 diff --git a/engines/stage-tamagotchi-godot/scripts/vrm/VrmAvatarLoader.cs b/engines/stage-tamagotchi-godot/scripts/vrm/VrmAvatarLoader.cs new file mode 100644 index 000000000..fe5990cb8 --- /dev/null +++ b/engines/stage-tamagotchi-godot/scripts/vrm/VrmAvatarLoader.cs @@ -0,0 +1,79 @@ +using System; +using System.IO; +using Godot; + +/// +/// Imports materialized VRM files into detached Godot scene nodes. +/// +/// Use when: +/// - Electron main has written the selected VRM bytes to a native file path. +/// - Godot needs to create a runtime avatar node from that file. +/// +/// Expects: +/// - The vendored VRM add-on exists under res://addons/vrm. +/// - res://scripts/vrm/VrmRuntimeImporter.gd mirrors the add-on's runtime import path. +/// +/// Returns: +/// - A detached generated by Godot's GLTF runtime importer. +/// +/// Call stack: +/// +/// StageSceneController.Apply +/// -> +/// -> VrmRuntimeImporter.gd.import_vrm +/// -> res://addons/vrm/vrm_extension.gd +/// +public sealed class VrmAvatarLoader +{ + private const string ImporterScriptPath = "res://scripts/vrm/VrmRuntimeImporter.gd"; + + private static readonly StringName ImportVrmMethod = new("import_vrm"); + private static readonly StringName GetLastErrorMethod = new("get_last_error"); + + public Node Load(StageSceneApplyPayload payload) + { + if (string.IsNullOrWhiteSpace(payload.Path)) + { + throw new InvalidOperationException("VRM file path was empty."); + } + + // Electron materializes selected models under app userData, outside Godot's res:// tree. + // System.IO checks the native absolute path before the GDScript importer hands it to GLTFDocument. + if (!File.Exists(payload.Path)) + { + throw new FileNotFoundException("VRM file does not exist.", payload.Path); + } + + using var importer = CreateImporter(); + var importedObject = importer.Call(ImportVrmMethod, payload.Path).AsGodotObject(); + if (importedObject is Node importedNode) + { + return importedNode; + } + + var importerError = importer.Call(GetLastErrorMethod).AsString(); + if (string.IsNullOrWhiteSpace(importerError)) + { + importerError = "VRM importer returned an empty scene."; + } + + throw new InvalidOperationException(importerError); + } + + private static GodotObject CreateImporter() + { + var script = ResourceLoader.Load(ImporterScriptPath); + if (script == null) + { + throw new InvalidOperationException($"Failed to load VRM runtime importer script: {ImporterScriptPath}."); + } + + var importer = script.New().AsGodotObject(); + if (importer == null) + { + throw new InvalidOperationException("Failed to create VRM runtime importer."); + } + + return importer; + } +} diff --git a/engines/stage-tamagotchi-godot/scripts/vrm/VrmAvatarLoader.cs.uid b/engines/stage-tamagotchi-godot/scripts/vrm/VrmAvatarLoader.cs.uid new file mode 100644 index 000000000..5185092a8 --- /dev/null +++ b/engines/stage-tamagotchi-godot/scripts/vrm/VrmAvatarLoader.cs.uid @@ -0,0 +1 @@ +uid://o5llip3rssu8 diff --git a/engines/stage-tamagotchi-godot/scripts/vrm/VrmRuntimeImporter.gd b/engines/stage-tamagotchi-godot/scripts/vrm/VrmRuntimeImporter.gd new file mode 100644 index 000000000..ded091f5d --- /dev/null +++ b/engines/stage-tamagotchi-godot/scripts/vrm/VrmRuntimeImporter.gd @@ -0,0 +1,61 @@ +extends RefCounted + +# NOTICE: +# Godot editor import plugins are not active in the exported/runtime sidecar path. +# This bridge mirrors the V-Sekai VRM add-on import flow from `addons/vrm/import_vrm.gd` +# so C# can import a materialized VRM file after Electron sends a native path. +# Remove this bridge only when the add-on exposes a stable runtime API for direct C# use. + +const vrm_constants = preload("res://addons/vrm/vrm_constants.gd") +const vrm_extension_class = preload("res://scripts/vrm/AiriVrmRuntimeExtension.gd") + +# NOTICE: +# Godot exposes this named-skin-bind flag through EditorSceneFormatImporter, but +# GLTFDocument also consumes the same bit during runtime append_from_file import. +# Keep the literal here because exported runtime code should not depend on the +# editor importer API. +# Source: Godot 4.6.2 `modules/gltf/gltf_document.cpp` +# `GLTF_IMPORT_USE_NAMED_SKIN_BINDS = 16`. +# Removal condition: +# - Replace with a public GLTFDocument runtime flag if Godot exposes one. +const IMPORT_USE_NAMED_SKIN_BINDS := 16 + +var _last_error := "" + + +func get_last_error() -> String: + return _last_error + + +func import_vrm(path: String) -> Node: + _last_error = "" + + if not FileAccess.file_exists(path): + _last_error = "VRM file does not exist: %s" % path + return null + + var gltf := GLTFDocument.new() + var vrm_extension: GLTFDocumentExtension = vrm_extension_class.new() + GLTFDocument.register_gltf_document_extension(vrm_extension, true) + + var state := GLTFState.new() + state.set_additional_data(&"vrm/already_processed", false) + state.set_additional_data(&"vrm/head_hiding_method", vrm_constants.HeadHidingSetting.ThirdPersonOnly) + state.set_additional_data(&"vrm/first_person_layers", 2) + state.set_additional_data(&"vrm/third_person_layers", 4) + state.handle_binary_image = GLTFState.HANDLE_BINARY_EMBED_AS_UNCOMPRESSED + + var err := gltf.append_from_file(path, state, IMPORT_USE_NAMED_SKIN_BINDS) + if err != OK: + GLTFDocument.unregister_gltf_document_extension(vrm_extension) + _last_error = "Failed to import VRM: %s" % error_string(err) + return null + + var generated_scene := gltf.generate_scene(state) + GLTFDocument.unregister_gltf_document_extension(vrm_extension) + + if generated_scene == null: + _last_error = "VRM importer generated an empty scene." + return null + + return generated_scene diff --git a/engines/stage-tamagotchi-godot/scripts/vrm/VrmRuntimeImporter.gd.uid b/engines/stage-tamagotchi-godot/scripts/vrm/VrmRuntimeImporter.gd.uid new file mode 100644 index 000000000..7640c9bb3 --- /dev/null +++ b/engines/stage-tamagotchi-godot/scripts/vrm/VrmRuntimeImporter.gd.uid @@ -0,0 +1 @@ +uid://bl7jnn20187kx diff --git a/packages/electron-screen-capture/package.json b/packages/electron-screen-capture/package.json index 3a477c658..16fea9a72 100644 --- a/packages/electron-screen-capture/package.json +++ b/packages/electron-screen-capture/package.json @@ -51,7 +51,7 @@ }, "inlinedDependencies": { "@electron-toolkit/preload": "3.0.2", - "@moeru/eventa": "catalog:", + "@moeru/eventa": "1.0.0-beta.5", "async-mutex": "0.5.0", "nanoid": [ "5.1.11", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3b2ca2858..03887f771 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -932,7 +932,7 @@ importers: version: 1.40.1 nanoid: specifier: ^5.1.9 - version: 5.1.9 + version: 5.1.11 node-vibrant: specifier: ^4.0.4 version: 4.0.4(encoding@0.1.13) @@ -1450,7 +1450,7 @@ importers: version: 3.0.2(electron@41.2.1) '@electron-toolkit/tsconfig': specifier: ^2.0.0 - version: 2.0.0(@types/node@25.6.0) + version: 2.0.0(@types/node@24.12.2) '@electron-toolkit/utils': specifier: ^4.0.0 version: 4.0.0(electron@41.2.1) @@ -1489,7 +1489,7 @@ importers: version: 3.1.0 '@intlify/unplugin-vue-i18n': specifier: ^11.0.7 - version: 11.0.7(@vue/compiler-dom@3.5.32)(eslint@10.2.1(jiti@2.6.1))(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)) + version: 11.0.7(@vue/compiler-dom@3.5.32)(eslint@10.2.1(jiti@2.6.1))(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)) '@modelcontextprotocol/sdk': specifier: 'catalog:' version: 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6) @@ -1525,10 +1525,10 @@ importers: version: link:../../packages/ui-transitions '@proj-airi/unplugin-fetch': specifier: 'catalog:' - version: 0.2.3(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 0.2.3(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) '@proj-airi/unplugin-live2d-sdk': specifier: ^0.1.7 - version: 0.1.7(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + version: 0.1.7(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) '@types/audioworklet': specifier: 'catalog:' version: 0.0.97 @@ -1555,7 +1555,7 @@ importers: version: 2.10.3 '@vitejs/plugin-vue': specifier: ^6.0.6 - version: 6.0.6(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3)) + version: 6.0.6(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3)) '@vue-macros/volar': specifier: ^3.1.2 version: 3.1.2(typescript@5.9.3)(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3)) @@ -1588,7 +1588,7 @@ importers: version: 6.8.3 electron-vite: specifier: ^5.0.0 - version: 5.0.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 5.0.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) get-port-please: specifier: 'catalog:' version: 3.2.0 @@ -1609,31 +1609,31 @@ importers: version: 2.2.6 unocss-preset-scrollbar: specifier: ^4.0.0 - version: 4.0.0(unocss@66.6.8(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))) + version: 4.0.0(unocss@66.6.8(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))) unplugin-info: specifier: ^1.3.2 - version: 1.3.2(esbuild@0.27.2)(rollup@4.60.1)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 1.3.2(esbuild@0.27.2)(rollup@4.60.1)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) unplugin-yaml: specifier: ^4.1.0 - version: 4.1.0(@nuxt/kit@3.20.2(magicast@0.5.2))(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.0(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) vite: specifier: 'catalog:' - version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + version: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vite-bundle-visualizer: specifier: ^1.2.1 version: 1.2.1(rolldown@1.0.0-rc.16)(rollup@4.60.1) vite-plugin-mkcert: specifier: 'catalog:' - version: 2.0.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 2.0.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) vite-plugin-vue-devtools: specifier: ^8.1.1 - version: 8.1.1(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3)) + version: 8.1.1(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3)) vite-plugin-vue-layouts: specifier: ^0.11.0 - version: 0.11.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-router@5.0.4(@pinia/colada@1.2.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)) + version: 0.11.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-router@5.0.4(@pinia/colada@1.2.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)) vue-macros: specifier: ^3.1.2 - version: 3.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3)))(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3)) + version: 3.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3)))(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3)) vue-tsc: specifier: ^3.2.6 version: 3.2.6(typescript@5.9.3) @@ -1816,7 +1816,7 @@ importers: version: 1.40.1 nanoid: specifier: ^5.1.9 - version: 5.1.9 + version: 5.1.11 node-vibrant: specifier: ^4.0.4 version: 4.0.4(encoding@0.1.13) @@ -2093,7 +2093,7 @@ importers: version: 4.1.0 nanoid: specifier: ^5.1.9 - version: 5.1.9 + version: 5.1.11 nprogress: specifier: ^0.2.0 version: 0.2.0 @@ -2497,7 +2497,7 @@ importers: version: 0.5.0-beta.2(zod-to-json-schema@3.25.2(zod@4.3.6))(zod@4.3.6) nanoid: specifier: ^5.1.9 - version: 5.1.9 + version: 5.1.11 vieval: specifier: 'catalog:' version: 0.0.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(chokidar@5.0.0)(dotenv@17.4.2)(esbuild@0.27.2)(giget@2.0.0)(jiti@2.6.1)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.2.3))(less@4.6.4)(magicast@0.5.2)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) @@ -2908,7 +2908,7 @@ importers: version: 1.10.0 nanoid: specifier: ^5.1.9 - version: 5.1.9 + version: 5.1.11 node-vibrant: specifier: ^4.0.4 version: 4.0.4(encoding@0.1.13) @@ -3029,7 +3029,7 @@ importers: version: 3.4.0 nanoid: specifier: ^5.1.9 - version: 5.1.9 + version: 5.1.11 node-vibrant: specifier: ^4.0.4 version: 4.0.4(encoding@0.1.13) @@ -3325,7 +3325,7 @@ importers: version: 1.40.1 nanoid: specifier: ^5.1.9 - version: 5.1.9 + version: 5.1.11 node-vibrant: specifier: ^4.0.4 version: 4.0.4(encoding@0.1.13) @@ -4038,7 +4038,7 @@ importers: version: 1.0.3(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3))(wxt@0.20.24(@types/node@25.6.0)(canvas@3.2.3)(eslint@10.2.1(jiti@2.6.1))(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) nanoid: specifier: ^5.1.9 - version: 5.1.9 + version: 5.1.11 vue: specifier: 'catalog:' version: 3.5.32(typescript@5.9.3) @@ -4268,7 +4268,7 @@ importers: version: 1.45.1 nanoid: specifier: ^5.1.9 - version: 5.1.9 + version: 5.1.11 valibot: specifier: ^1.3.1 version: 1.3.1(typescript@5.9.3) @@ -4368,7 +4368,7 @@ importers: version: 1.42.0(encoding@0.1.13) nanoid: specifier: ^5.1.9 - version: 5.1.9 + version: 5.1.11 p-limit: specifier: ^7.3.0 version: 7.3.0 @@ -14934,11 +14934,6 @@ packages: engines: {node: ^18 || >=20} hasBin: true - nanoid@5.1.9: - resolution: {integrity: sha512-ZUvP7KeBLe3OZ1ypw6dI/TzYJuvHP77IM4Ry73waSQTLn8/g8rpdjfyVAh7t1/+FjBtG4lCP42MEbDxOsRpBMw==} - engines: {node: ^18 || >=20} - hasBin: true - nanospinner@1.2.2: resolution: {integrity: sha512-Zt/AmG6qRU3e+WnzGGLuMCEAO/dAu45stNbHY223tUxldaDAeE+FxSPsd9Q+j+paejmm0ZbrNVs5Sraqy3dRxA==} @@ -19809,9 +19804,9 @@ snapshots: dependencies: electron: 41.2.1 - '@electron-toolkit/tsconfig@2.0.0(@types/node@25.6.0)': + '@electron-toolkit/tsconfig@2.0.0(@types/node@24.12.2)': dependencies: - '@types/node': 25.6.0 + '@types/node': 24.12.2 '@electron-toolkit/utils@4.0.0(electron@41.2.1)': dependencies: @@ -20705,6 +20700,31 @@ snapshots: - supports-color - typescript + '@intlify/unplugin-vue-i18n@11.0.7(@vue/compiler-dom@3.5.32)(eslint@10.2.1(jiti@2.6.1))(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.6.1)) + '@intlify/bundle-utils': 11.0.7(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3))) + '@intlify/shared': 11.3.2 + '@intlify/vue-i18n-extensions': 8.0.0(@intlify/shared@11.3.2)(@vue/compiler-dom@3.5.32)(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)) + '@rollup/pluginutils': 5.3.0(rollup@4.60.1) + '@typescript-eslint/scope-manager': 8.58.1 + '@typescript-eslint/typescript-estree': 8.58.1(typescript@5.9.3) + debug: 4.4.3(supports-color@10.2.2) + fast-glob: 3.3.3 + pathe: 2.0.3 + picocolors: 1.1.1 + unplugin: 2.3.11 + vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vue: 3.5.32(typescript@5.9.3) + optionalDependencies: + vue-i18n: 11.3.2(vue@3.5.32(typescript@5.9.3)) + transitivePeerDependencies: + - '@vue/compiler-dom' + - eslint + - rollup + - supports-color + - typescript + '@intlify/unplugin-vue-i18n@11.0.7(@vue/compiler-dom@3.5.32)(eslint@10.2.1(jiti@2.6.1))(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.6.1)) @@ -21050,7 +21070,7 @@ snapshots: '@moeru/eventa@0.3.0(electron@41.2.1)(h3@2.0.1-rc.20(crossws@0.4.5(srvx@0.11.15)))': dependencies: - nanoid: 5.1.9 + nanoid: 5.1.11 picomatch: 4.0.4 optionalDependencies: electron: 41.2.1 @@ -22680,11 +22700,35 @@ snapshots: transitivePeerDependencies: - magicast + '@proj-airi/unplugin-fetch@0.2.3(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': + dependencies: + ofetch: 1.5.1 + vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + '@proj-airi/unplugin-fetch@0.2.3(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: ofetch: 1.5.1 vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + '@proj-airi/unplugin-live2d-sdk@0.1.7(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)': + dependencies: + ofetch: 1.5.1 + vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + yauzl: 3.3.0 + transitivePeerDependencies: + - '@types/node' + - '@vitejs/devtools' + - esbuild + - jiti + - less + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + '@proj-airi/unplugin-live2d-sdk@0.1.7(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)': dependencies: ofetch: 1.5.1 @@ -23585,6 +23629,7 @@ snapshots: '@types/node@25.6.0': dependencies: undici-types: 7.19.2 + optional: true '@types/nprogress@0.2.3': {} @@ -24250,6 +24295,12 @@ snapshots: vite: 6.4.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vue: 3.5.32(typescript@5.9.3) + '@vitejs/plugin-vue@6.0.6(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3))': + dependencies: + '@rolldown/pluginutils': 1.0.0-rc.13 + vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vue: 3.5.32(typescript@5.9.3) + '@vitejs/plugin-vue@6.0.6(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.13 @@ -24552,6 +24603,15 @@ snapshots: transitivePeerDependencies: - vue + '@vue-macros/devtools@3.1.2(typescript@5.9.3)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': + dependencies: + sirv: 3.0.2 + vue: 3.5.32(typescript@5.9.3) + optionalDependencies: + vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + transitivePeerDependencies: + - typescript + '@vue-macros/devtools@3.1.2(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: sirv: 3.0.2 @@ -26693,7 +26753,7 @@ snapshots: transitivePeerDependencies: - supports-color - electron-vite@5.0.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + electron-vite@5.0.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.0) @@ -26701,7 +26761,7 @@ snapshots: esbuild: 0.25.12 magic-string: 0.30.21 picocolors: 1.1.1 - vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - supports-color @@ -29971,8 +30031,6 @@ snapshots: nanoid@5.1.6: {} - nanoid@5.1.9: {} - nanospinner@1.2.2: dependencies: picocolors: 1.1.1 @@ -32607,7 +32665,8 @@ snapshots: undici-types@7.16.0: {} - undici-types@7.19.2: {} + undici-types@7.19.2: + optional: true undici@6.24.1: {} @@ -32719,11 +32778,6 @@ snapshots: '@unocss/preset-mini': 66.6.8 unocss: 66.6.8(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) - unocss-preset-scrollbar@4.0.0(unocss@66.6.8(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))): - dependencies: - '@unocss/preset-mini': 66.6.8 - unocss: 66.6.8(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) - unocss@66.6.8(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vite@6.4.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@unocss/cli': 66.6.8 @@ -32820,6 +32874,14 @@ snapshots: unplugin: 2.3.11 vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + unplugin-combine@2.3.0(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(unplugin@2.3.11)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + optionalDependencies: + esbuild: 0.27.2 + rolldown: 1.0.0-rc.16 + rollup: 4.60.1 + unplugin: 2.3.11 + vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + unplugin-combine@2.3.0(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(unplugin@2.3.11)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): optionalDependencies: esbuild: 0.27.2 @@ -32854,6 +32916,19 @@ snapshots: transitivePeerDependencies: - supports-color + unplugin-info@1.3.2(esbuild@0.27.2)(rollup@4.60.1)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + dependencies: + ci-info: 4.4.0 + git-url-parse: 16.1.0 + simple-git: 3.36.0 + unplugin: 2.3.11 + optionalDependencies: + esbuild: 0.27.2 + rollup: 4.60.1 + vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + transitivePeerDependencies: + - supports-color + unplugin-info@1.3.2(esbuild@0.27.2)(rollup@4.60.1)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: ci-info: 4.4.0 @@ -32963,6 +33038,17 @@ snapshots: rollup: 4.60.1 vite: 6.4.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + unplugin-yaml@4.1.0(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.60.1) + unplugin: 3.0.0 + yaml: 2.8.3 + optionalDependencies: + esbuild: 0.27.2 + rolldown: 1.0.0-rc.16 + rollup: 4.60.1 + vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + unplugin@2.3.11: dependencies: '@jridgewell/remapping': 2.3.5 @@ -33194,12 +33280,22 @@ snapshots: - rollup - supports-color + vite-dev-rpc@1.1.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + dependencies: + birpc: 2.9.0 + vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite-hot-client: 2.1.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + vite-dev-rpc@1.1.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: birpc: 2.9.0 vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vite-hot-client: 2.1.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + vite-hot-client@2.1.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + dependencies: + vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite-hot-client@2.1.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) @@ -33245,6 +33341,21 @@ snapshots: - tsx - yaml + vite-plugin-inspect@11.3.3(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + dependencies: + ansis: 4.2.0 + debug: 4.4.3(supports-color@10.2.2) + error-stack-parser-es: 1.0.5 + ohash: 2.0.11 + open: 10.2.0 + perfect-debounce: 2.1.0 + sirv: 3.0.2 + unplugin-utils: 0.3.1 + vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite-dev-rpc: 1.1.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + transitivePeerDependencies: + - supports-color + vite-plugin-inspect@11.3.3(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: ansis: 4.2.0 @@ -33276,6 +33387,13 @@ snapshots: - typescript - ws + vite-plugin-mkcert@2.0.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + dependencies: + debug: 4.4.3(supports-color@10.2.2) + supports-color: 10.2.2 + undici: 8.1.0 + vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite-plugin-mkcert@2.0.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: debug: 4.4.3(supports-color@10.2.2) @@ -33294,6 +33412,20 @@ snapshots: transitivePeerDependencies: - supports-color + vite-plugin-vue-devtools@8.1.1(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3)): + dependencies: + '@vue/devtools-core': 8.1.1(vue@3.5.32(typescript@5.9.3)) + '@vue/devtools-kit': 8.1.1 + '@vue/devtools-shared': 8.1.1 + sirv: 3.0.2 + vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite-plugin-inspect: 11.3.3(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + vite-plugin-vue-inspector: 5.3.2(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + transitivePeerDependencies: + - '@nuxt/kit' + - supports-color + - vue + vite-plugin-vue-devtools@8.1.1(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3)): dependencies: '@vue/devtools-core': 8.1.1(vue@3.5.32(typescript@5.9.3)) @@ -33308,6 +33440,21 @@ snapshots: - supports-color - vue + vite-plugin-vue-inspector@5.3.2(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-proposal-decorators': 7.28.0(@babel/core@7.29.0) + '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-transform-typescript': 7.28.5(@babel/core@7.29.0) + '@vue/babel-plugin-jsx': 1.5.0(@babel/core@7.29.0) + '@vue/compiler-dom': 3.5.32 + kolorist: 1.8.0 + magic-string: 0.30.21 + vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + transitivePeerDependencies: + - supports-color + vite-plugin-vue-inspector@5.3.2(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@babel/core': 7.29.0 @@ -33323,6 +33470,16 @@ snapshots: transitivePeerDependencies: - supports-color + vite-plugin-vue-layouts@0.11.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-router@5.0.4(@pinia/colada@1.2.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)): + dependencies: + debug: 4.4.3(supports-color@10.2.2) + fast-glob: 3.3.3 + vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vue: 3.5.32(typescript@5.9.3) + vue-router: 5.0.4(@pinia/colada@1.2.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)) + transitivePeerDependencies: + - supports-color + vite-plugin-vue-layouts@0.11.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-router@5.0.4(@pinia/colada@1.2.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)): dependencies: debug: 4.4.3(supports-color@10.2.2) @@ -33589,6 +33746,54 @@ snapshots: - vue-tsc - webpack + vue-macros@3.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3)))(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3)): + dependencies: + '@vue-macros/better-define': 3.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/boolean-prop': 3.1.2(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/chain-call': 3.1.2(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/common': 3.1.2(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/config': 3.1.2(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/define-emit': 3.1.2(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/define-models': 3.1.2(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/define-prop': 3.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/define-props': 3.1.2(@vue-macros/reactivity-transform@3.1.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/define-props-refs': 3.1.2(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/define-render': 3.1.2(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/define-slots': 3.1.2(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/define-stylex': 3.1.2(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/devtools': 3.1.2(typescript@5.9.3)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vue-macros/export-expose': 3.1.2(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/export-props': 3.1.2(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/export-render': 3.1.2(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/hoist-static': 3.1.2(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/jsx-directive': 3.1.2(typescript@5.9.3) + '@vue-macros/named-template': 3.1.2(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/reactivity-transform': 3.1.2(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/script-lang': 3.1.2(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/setup-block': 3.1.2(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/setup-component': 3.1.2(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/setup-sfc': 3.1.2(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/short-bind': 3.1.2(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/short-emits': 3.1.2(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/short-vmodel': 3.1.2(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/volar': 3.1.2(typescript@5.9.3)(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3)) + unplugin: 2.3.11 + unplugin-combine: 2.3.0(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(unplugin@2.3.11)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + unplugin-vue-define-options: 3.1.2(vue@3.5.32(typescript@5.9.3)) + vue: 3.5.32(typescript@5.9.3) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + - '@rspack/core' + - '@vueuse/core' + - esbuild + - rolldown + - rollup + - typescript + - vite + - vue-tsc + - webpack + vue-macros@3.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3)))(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3)): dependencies: '@vue-macros/better-define': 3.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vue@3.5.32(typescript@5.9.3))