Skip to content

Commit 0ef110a

Browse files
committed
feat(stage-ui-three): support loading three as preview cover of model
1 parent 3a97d8d commit 0ef110a

4 files changed

Lines changed: 127 additions & 5 deletions

File tree

packages/stage-ui-three/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
},
1717
"exports": {
1818
"./assets/vrm": "./src/assets/vrm/index.ts",
19+
"./composables/vrm": "./src/composables/vrm/index.ts",
1920
".": "./src/index.ts"
2021
},
2122
"scripts": {
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
export * from './animation'
22
export * from './core'
33
export * from './expression'
4+
export * from './lip-sync'
45
export * from './loader'
6+
export * from './utils'
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './eye-motions'

packages/stage-ui/src/stores/display-models.ts

Lines changed: 123 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
1+
import type { VRM } from '@pixiv/three-vrm'
2+
13
import cropImg from '@lemonneko/crop-empty-pixels'
24
import localforage from 'localforage'
35

46
import { Application } from '@pixi/app'
57
import { extensions } from '@pixi/extensions'
68
import { 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'
711
import { until } from '@vueuse/core'
812
import { nanoid } from 'nanoid'
913
import { defineStore } from 'pinia'
1014
import { Live2DFactory, Live2DModel } from 'pixi-live2d-display/cubism4'
15+
import { AmbientLight, AnimationMixer, DirectionalLight, PerspectiveCamera, Scene, WebGLRenderer } from 'three'
1116
import { ref } from 'vue'
1217

1318
import '../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

Comments
 (0)