From 088ce34a2a666c18dd1d4b5afc585282f2e55e97 Mon Sep 17 00:00:00 2001 From: Lilia_Chen Date: Wed, 25 Mar 2026 00:49:21 +0000 Subject: [PATCH] fix(stage-ui-three): cutout bug fix (#1471) * fix(stage-ui-three): cutout bug fix * fix(stage-ui-three): gemini review comment * fix(stage-ui-three): codex review comment --- .../src/components/Model/VRMModel.vue | 66 ++++++++++++------- .../src/composables/shader/ibl.ts | 5 +- .../stage-ui-three/src/utils/vrm-preview.ts | 20 +++++- 3 files changed, 61 insertions(+), 30 deletions(-) diff --git a/packages/stage-ui-three/src/components/Model/VRMModel.vue b/packages/stage-ui-three/src/components/Model/VRMModel.vue index 679248540..aa501ec49 100644 --- a/packages/stage-ui-three/src/components/Model/VRMModel.vue +++ b/packages/stage-ui-three/src/components/Model/VRMModel.vue @@ -9,6 +9,7 @@ import type { VRM } from '@pixiv/three-vrm' import type { Group, + Material, Object3D, PerspectiveCamera, ShaderMaterial, @@ -180,6 +181,11 @@ type VrmFrameHook = (vrm: VRM, delta: number) => void const vrmFrameHook = shallowRef() let disposeBeforeRenderLoop: (() => void | undefined) | undefined +// material type with optional update function for per-frame update, used for three-vrm's MToon material and custom shader materials with IBL injection +type UpdatableMaterial = Material & { + update?: (delta: number) => void +} + // Expressions const blink = useBlink() const idleEyeSaccades = useIdleEyeSaccades() @@ -324,6 +330,15 @@ function shouldStashVrmResources(reason: VrmLifecycleReason) { return reason === 'component-unmount' } +function updateManagedVrmMaterials(activeVrm: VRM | undefined, delta: number) { + // NOTICE: three-vrm drives MToon per-frame uniforms, including alphaTest used by MASK cutout, + // through material.update(delta). Our render loop updates VRM subsystems manually instead of + // calling vrm.update(delta), so material updates must be forwarded here as well. + activeVrm?.materials?.forEach((material) => { + (material as UpdatableMaterial).update?.(delta) + }) +} + function bindManagedVrmInstanceRenderLoop() { disposeBeforeRenderLoop?.() @@ -335,6 +350,7 @@ function bindManagedVrmInstanceRenderLoop() { vrmAnimationMixer.value?.update(delta) }) const activeVrm = vrm.value + updateManagedVrmMaterials(activeVrm, delta) const vrmFrameHookMs = measureFrameStep(tracingEnabled, () => { if (activeVrm && vrmFrameHook.value) { try { @@ -686,13 +702,28 @@ async function loadModel() { /* * Shader setting */ - // material selection - function isMToon(mat: any): boolean { - return !!(mat?.isShaderMaterial && mat.userData?.vrmMaterialType === 'MToon' - ) - } const isShaderMat = (m: any): m is ShaderMaterial => !!m?.isShaderMaterial + function configureInjectedShaderMaterial(mat: ShaderMaterial) { + if ('toneMapped' in mat) + mat.toneMapped = false + if ('envMap' in mat && mat.envMap) + mat.envMap = null + + // NPR materials usually use sRGB textures. + const tex = (mat as any).map as Texture | undefined + if (tex && (tex as any).colorSpace !== undefined) { + try { + (tex as any).colorSpace = SRGBColorSpace + } + catch (e) { + console.warn('Failed to set colorSpace on texture:', e) + } + } + + injectDiffuseIBL(mat) + } + // MToon material sky box lightProbe setting if (!airiIblProbe && scene.value) airiIblProbe = createIblProbeController(scene.value) @@ -707,31 +738,16 @@ async function loadModel() { mat.envMapIntensity = 1.0 mat.needsUpdate = true } - else if (isMToon(mat)) { - // --- MToon material, add IBL lightProbe only --- - // close tone mapping for NPR materials - if ('toneMapped' in mat) - mat.toneMapped = false + else if (mat?.isMToonMaterial) { + // --- MToon material --- + // MToon is also shader-based and should receive the custom IBL injection. + configureInjectedShaderMaterial(mat) } else if (isShaderMat(mat)) { // --- Shader material, further IBL injection needed --- // TODO: stylised shader injection // Lilia: I plan to replace all injected shader code to be my own, so that it can always avoid double injection and unknown user upload VRM injected shader behaviour... - if ('toneMapped' in mat) - mat.toneMapped = false - if ('envMap' in mat && mat.envMap) - mat.envMap = null - // NPR materials usually use sRGB textures - const tex = (mat as any).map as Texture | undefined - if (tex && (tex as any).colorSpace !== undefined) { - try { - (tex as any).colorSpace = SRGBColorSpace - } - catch (e) { - console.warn('Failed to set colorSpace on texture:', e) - } - } - injectDiffuseIBL(mat) + configureInjectedShaderMaterial(mat) } }) } diff --git a/packages/stage-ui-three/src/composables/shader/ibl.ts b/packages/stage-ui-three/src/composables/shader/ibl.ts index 1417d41a3..1f082f7fc 100644 --- a/packages/stage-ui-three/src/composables/shader/ibl.ts +++ b/packages/stage-ui-three/src/composables/shader/ibl.ts @@ -25,9 +25,9 @@ uniform vec3 uSHCoeffs[9]; varying vec3 vWorldNormal; // 3rd-order SH constants -const float C0=0.2820947918; +const float C0=0.2820947918; const float C1=0.4886025119; -const float C2=1.0925484306; +const float C2=1.0925484306; const float C3=0.3153915653; const float C4=0.5462742153; @@ -62,7 +62,6 @@ export type EnvMode = 'off' | 'skyBox' | 'hemisphere' export const isShaderMat = (m: any): m is THREE.ShaderMaterial => !!m?.isShaderMaterial export const isRawShader = (m: any): m is THREE.RawShaderMaterial => !!m?.isRawShaderMaterial -export const isMToon = (mat: any) => !!(mat?.isShaderMaterial && mat.userData?.vrmMaterialType === 'MToon') export function normalizeEnvMode(v?: string | null): EnvMode { if (v === 'skyBox') diff --git a/packages/stage-ui-three/src/utils/vrm-preview.ts b/packages/stage-ui-three/src/utils/vrm-preview.ts index 7cf6dda82..637b78a71 100644 --- a/packages/stage-ui-three/src/utils/vrm-preview.ts +++ b/packages/stage-ui-three/src/utils/vrm-preview.ts @@ -1,5 +1,5 @@ import type { VRM } from '@pixiv/three-vrm' -import type { Group, Object3D } from 'three' +import type { Group, Material, Object3D } from 'three' import { VRMUtils } from '@pixiv/three-vrm' import { AmbientLight, AnimationMixer, DirectionalLight, PerspectiveCamera, Scene, WebGLRenderer } from 'three' @@ -21,6 +21,20 @@ function disposePreviewRenderer(renderer: WebGLRenderer) { renderer.forceContextLoss() } +function hasMaterialUpdate(material: Material): material is Material & { update: (delta: number) => void } { + return typeof (material as { update?: unknown }).update === 'function' +} + +function updatePreviewVrmMaterials(vrm: VRM | undefined, delta: number) { + // NOTICE: three-vrm drives MToon per-frame uniforms, including alphaTest used by MASK cutout, + // through material.update(delta). The preview path renders a one-shot offscreen frame instead of + // using VRM.update(delta), so we need to forward material updates manually before rendering. + vrm?.materials?.forEach((material) => { + if (hasMaterialUpdate(material)) + material.update(delta) + }) +} + /** * Render a VRM file to an offscreen canvas and return a preview data URL. */ @@ -49,6 +63,7 @@ export async function loadVrmModelPreview(file: File) { let vrmInstance: VRM | undefined let vrmGroup: Group | undefined let mixer: AnimationMixer | undefined + const previewDelta = 0.1 try { const vrmData = await loadVrm(objUrl, { scene, lookAt: true }) @@ -71,13 +86,14 @@ export async function loadVrmModelPreview(file: File) { mixer = new AnimationMixer(vrmData._vrm.scene) const action = mixer.clipAction(clip) action.play() - mixer.update(0.1) + mixer.update(previewDelta) } } catch (err) { console.warn('Failed to load VRM animation for preview:', err) } + updatePreviewVrmMaterials(vrmInstance, previewDelta) renderer.render(scene, camera) const dataUrl = offscreenCanvas.toDataURL()