1+ import type { VRM } from '@pixiv/three-vrm'
2+
13import cropImg from '@lemonneko/crop-empty-pixels'
24import localforage from 'localforage'
35
46import { Application } from '@pixi/app'
57import { extensions } from '@pixi/extensions'
68import { Ticker , TickerPlugin } from '@pixi/ticker'
9+ import { animations } from '@proj-airi/stage-ui-three/assets/vrm'
10+ import { clipFromVRMAnimation , loadVrm , loadVRMAnimation , reAnchorRootPositionTrack } from '@proj-airi/stage-ui-three/composables/vrm'
711import { until } from '@vueuse/core'
812import { nanoid } from 'nanoid'
913import { defineStore } from 'pinia'
1014import { Live2DFactory , Live2DModel } from 'pixi-live2d-display/cubism4'
15+ import { AmbientLight , AnimationMixer , DirectionalLight , PerspectiveCamera , Scene , WebGLRenderer } from 'three'
1116import { ref } from 'vue'
1217
1318import '../utils/live2d-zip-loader'
@@ -102,9 +107,13 @@ export const useDisplayModelsStore = defineStore('display-models', () => {
102107 Live2DModel . registerTicker ( Ticker )
103108 extensions . add ( TickerPlugin )
104109
110+ const previewWidth = 1440
111+ const previewHeight = 2560
112+ const previewResolution = 2
113+
105114 const offscreenCanvas = document . createElement ( 'canvas' )
106- offscreenCanvas . width = 720
107- offscreenCanvas . height = 1280
115+ offscreenCanvas . width = previewWidth * previewResolution
116+ offscreenCanvas . height = previewHeight * previewResolution
108117 offscreenCanvas . style . position = 'absolute'
109118 offscreenCanvas . style . top = '0'
110119 offscreenCanvas . style . left = '0'
@@ -116,11 +125,15 @@ export const useDisplayModelsStore = defineStore('display-models', () => {
116125
117126 const app = new Application ( {
118127 view : offscreenCanvas ,
128+ width : offscreenCanvas . width ,
129+ height : offscreenCanvas . height ,
119130 // Ensure the drawing buffer persists so toDataURL() can read pixels
120131 preserveDrawingBuffer : true ,
121132 backgroundAlpha : 0 ,
122- resizeTo : window ,
133+ autoDensity : false ,
134+ resolution : 1 ,
123135 } )
136+ app . stage . scale . set ( previewResolution )
124137
125138 const modelInstance = new Live2DModel ( )
126139 const objUrl = URL . createObjectURL ( file )
@@ -143,8 +156,8 @@ export const useDisplayModelsStore = defineStore('display-models', () => {
143156 // transforms
144157 modelInstance . x = 275
145158 modelInstance . y = 450
146- modelInstance . width = offscreenCanvas . width
147- modelInstance . height = offscreenCanvas . height
159+ modelInstance . width = previewWidth
160+ modelInstance . height = previewHeight
148161 modelInstance . scale . set ( 0.1 , 0.1 )
149162 modelInstance . anchor . set ( 0.5 , 0.5 )
150163
@@ -171,6 +184,104 @@ export const useDisplayModelsStore = defineStore('display-models', () => {
171184 return paddingDataUrl
172185 }
173186
187+ async function loadVrmModelPreview ( file : File ) {
188+ const offscreenCanvas = document . createElement ( 'canvas' )
189+ offscreenCanvas . width = 1440
190+ offscreenCanvas . height = 2560
191+ offscreenCanvas . style . position = 'absolute'
192+ offscreenCanvas . style . top = '0'
193+ offscreenCanvas . style . left = '0'
194+ offscreenCanvas . style . objectFit = 'cover'
195+ offscreenCanvas . style . display = 'block'
196+ offscreenCanvas . style . zIndex = '10000000000'
197+ offscreenCanvas . style . opacity = '0'
198+ document . body . appendChild ( offscreenCanvas )
199+
200+ const renderer = new WebGLRenderer ( {
201+ canvas : offscreenCanvas ,
202+ alpha : true ,
203+ antialias : true ,
204+ preserveDrawingBuffer : true ,
205+ } )
206+ renderer . setSize ( offscreenCanvas . width , offscreenCanvas . height , false )
207+ renderer . setPixelRatio ( 1 )
208+
209+ const scene = new Scene ( )
210+ const camera = new PerspectiveCamera ( 40 , offscreenCanvas . width / offscreenCanvas . height , 0.01 , 1000 )
211+ const ambientLight = new AmbientLight ( 0xFFFFFF , 0.8 )
212+ const directionalLight = new DirectionalLight ( 0xFFFFFF , 0.8 )
213+ directionalLight . position . set ( 1 , 1 , 1 )
214+ scene . add ( ambientLight , directionalLight )
215+
216+ const objUrl = URL . createObjectURL ( file )
217+ let vrmInstance : VRM | undefined
218+
219+ try {
220+ const vrmData = await loadVrm ( objUrl , { scene, lookAt : true } )
221+ if ( ! vrmData ) {
222+ return
223+ }
224+
225+ vrmInstance = vrmData . _vrm
226+ const { modelCenter, initialCameraOffset } = vrmData
227+
228+ camera . position . copy ( modelCenter ) . add ( initialCameraOffset )
229+ camera . lookAt ( modelCenter )
230+ camera . updateProjectionMatrix ( )
231+
232+ try {
233+ const animation = await loadVRMAnimation ( animations . idleLoop . toString ( ) )
234+ const clip = await clipFromVRMAnimation ( vrmData . _vrm , animation )
235+ if ( clip ) {
236+ reAnchorRootPositionTrack ( clip , vrmData . _vrm )
237+ const mixer = new AnimationMixer ( vrmData . _vrm . scene )
238+ mixer . clipAction ( clip ) . play ( )
239+ mixer . update ( 1 / 60 )
240+ }
241+ }
242+ catch ( error ) {
243+ console . warn ( 'Failed to load VRM idle animation for preview.' , error )
244+ }
245+
246+ await new Promise < void > ( ( resolve ) => {
247+ const start = performance . now ( )
248+ const step = ( time : number ) => {
249+ if ( time - start >= 2000 ) {
250+ resolve ( )
251+ return
252+ }
253+ requestAnimationFrame ( step )
254+ }
255+ requestAnimationFrame ( step )
256+ } )
257+ renderer . render ( scene , camera )
258+
259+ const croppedCanvas = cropImg ( offscreenCanvas )
260+
261+ // padding to 12:16
262+ const paddingCanvas = document . createElement ( 'canvas' )
263+ paddingCanvas . width = croppedCanvas . width > croppedCanvas . height / 16 * 12 ? croppedCanvas . width : croppedCanvas . height / 16 * 12
264+ paddingCanvas . height = paddingCanvas . width / 12 * 16
265+ const paddingCanvasCtx = paddingCanvas . getContext ( '2d' ) !
266+
267+ paddingCanvasCtx . drawImage ( croppedCanvas , ( paddingCanvas . width - croppedCanvas . width ) / 2 , ( paddingCanvas . height - croppedCanvas . height ) / 2 , croppedCanvas . width , croppedCanvas . height )
268+ return paddingCanvas . toDataURL ( )
269+ }
270+ catch ( error ) {
271+ console . error ( error )
272+ return
273+ }
274+ finally {
275+ if ( vrmInstance && 'dispose' in vrmInstance ) {
276+ ( vrmInstance as { dispose : ( ) => void } ) . dispose ( )
277+ }
278+
279+ renderer . dispose ( )
280+ document . body . removeChild ( offscreenCanvas )
281+ URL . revokeObjectURL ( objUrl )
282+ }
283+ }
284+
174285 async function addDisplayModel ( format : DisplayModelFormat , file : File ) {
175286 await until ( displayModelsFromIndexedDBLoading ) . toBe ( false )
176287 const newDisplayModel : DisplayModelFile = { id : `display-model-${ nanoid ( ) } ` , format, type : 'file' , file, name : file . name , importedAt : Date . now ( ) }
@@ -182,6 +293,13 @@ export const useDisplayModelsStore = defineStore('display-models', () => {
182293
183294 newDisplayModel . previewImage = previewImage
184295 }
296+ else if ( format === DisplayModelFormat . VRM ) {
297+ const previewImage = await loadVrmModelPreview ( file )
298+ if ( ! previewImage )
299+ return
300+
301+ newDisplayModel . previewImage = previewImage
302+ }
185303
186304 displayModels . value . unshift ( newDisplayModel )
187305
0 commit comments