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
This commit is contained in:
Lilia_Chen
2026-03-25 08:49:21 +08:00
committed by GitHub
parent 45ce8ba602
commit 088ce34a2a
3 changed files with 61 additions and 30 deletions
@@ -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<VrmFrameHook>()
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)
}
})
}
@@ -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')
@@ -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()