Skip to content

Commit e849ddd

Browse files
committed
プレビュー画面で瞳が表示されない問題を修正
1 parent 7d2968c commit e849ddd

11 files changed

Lines changed: 691 additions & 252 deletions

File tree

Cargo.lock

Lines changed: 8 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,27 @@ Desktop notifications are sent when analysis/conversion completes.
9292
- Texture auto-resize currently affects validation/estimation and option handling; embedded image payload rewrite is not yet enabled.
9393
- Full hierarchy reconstruction, inverse-bind full regeneration/writeback, and advanced UI/preview workflow are planned next steps.
9494

95+
## Stability Notes (Eyes/Face)
96+
97+
Recent fixes for face/eye instability (cross-eye, missing iris, flicker) are now part of the default pipeline.
98+
99+
- Conversion side:
100+
- `skin.skeleton` prefers `mPelvis` when available.
101+
- Face skin keeps `mHead/mEyeLeft/mEyeRight` joints.
102+
- Hair-like tiny secondary skins may be simplified to `mHead` for stability.
103+
- Bind-pose correction excludes tiny face eye skins to avoid eye drift.
104+
- Preview side (`frontend/src/components/VrmPreview.vue`):
105+
- Eye materials are handled separately from lash/brow materials.
106+
- Iris/highlight use anti-z-fighting settings (`polygonOffset`) to reduce depth sorting artifacts.
107+
- Upper-body BVH retargeting is enabled (wrists are still filtered to reduce hand collapse).
108+
109+
If eye placement looks wrong, validate in this order:
110+
111+
1. Regenerate output with current backend (`cargo run --manifest-path backend/Cargo.toml --bin vrm2sl -- <input.vrm> <output.glb>`).
112+
2. Confirm skin topology with `python3 vrm/inspect_output.py`.
113+
3. Check face skin influence with `python3 vrm/inspect_skin0_weights.py`.
114+
4. Compare eye-weighted centers with `python3 vrm/inspect_eye_vertex_positions.py vrm/output.glb`.
115+
95116
## Animation Attribution
96117

97118
Contains animation data © Linden Research, Inc.

backend/src/convert/mod.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ use skeleton::{
3535
};
3636
use skinning::{
3737
collapse_secondary_head_skins_to_primary, optimize_skinning_weights_and_joints,
38-
remap_unmapped_bone_weights,
38+
remap_unmapped_bone_weights, soften_face_eye_influences,
3939
};
4040
use validation::{
4141
collect_mapped_bones, collect_missing_required_bones, collect_node_names,
@@ -362,6 +362,7 @@ fn transform_and_write_glb(
362362
// in the skin joints list after optimization.
363363
remap_unmapped_bone_weights(&mut json, &mut bin, humanoid_bone_nodes);
364364
optimize_skinning_weights_and_joints(&mut json, &mut bin)?;
365+
soften_face_eye_influences(&json, &mut bin);
365366
collapse_secondary_head_skins_to_primary(&mut json, &mut bin, humanoid_bone_nodes);
366367
// Clean up wrapper nodes above mPelvis. Keeps the topmost non-SL
367368
// ancestor as an identity-transform root so that skin.skeleton can

backend/src/convert/skeleton.rs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,9 +231,12 @@ pub(super) fn normalize_sl_bone_rotations(
231231
json: &mut Value,
232232
humanoid_bone_nodes: &HashMap<String, usize>,
233233
) -> Vec<Matrix4<f32>> {
234+
let skip_rotation_normalization: HashSet<&str> = HashSet::from(["leftEye", "rightEye"]);
235+
234236
let sl_node_indices: HashSet<usize> = BONE_MAP
235237
.iter()
236238
.chain(BENTO_BONE_MAP.iter())
239+
.filter(|(vrm_name, _)| !skip_rotation_normalization.contains(*vrm_name))
237240
.filter_map(|(vrm_name, _)| humanoid_bone_nodes.get(*vrm_name).copied())
238241
.collect();
239242

@@ -374,6 +377,21 @@ pub(super) fn correct_mesh_vertices_for_bind_pose_change(
374377
continue;
375378
}
376379

380+
// Face eye skins are very sensitive to over-correction and can drift
381+
// toward the nose. Keep tiny head/eye-only skins in their original
382+
// authored bind geometry.
383+
let is_tiny_face_skin = joints.len() <= 4
384+
&& joints.iter().all(|&node_idx| {
385+
let name = json["nodes"][node_idx]
386+
.get("name")
387+
.and_then(Value::as_str)
388+
.unwrap_or("");
389+
matches!(name, "mHead" | "mEyeLeft" | "mEyeRight")
390+
});
391+
if is_tiny_face_skin {
392+
continue;
393+
}
394+
377395
// Per-slot correction matrix.
378396
let slot_corrections: Vec<Matrix4<f32>> = joints
379397
.iter()
@@ -568,7 +586,10 @@ fn blend_correction_matrix(
568586
return Matrix4::identity();
569587
}
570588

571-
result
589+
// Normalize by the effective accumulated weight. Some assets contain
590+
// vertices whose weight sum is not exactly 1.0; without normalization,
591+
// correction would scale/offset those vertices and can misplace eyes/face.
592+
result / total_weight
572593
}
573594

574595
/// Primitive attribute info for bind-pose correction.

backend/src/convert/skinning.rs

Lines changed: 146 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,36 @@ pub(super) fn collapse_secondary_head_skins_to_primary(
275275
continue;
276276
}
277277

278+
let bound_node_names: Vec<String> = json
279+
.get("nodes")
280+
.and_then(Value::as_array)
281+
.map(|nodes| {
282+
nodes
283+
.iter()
284+
.filter_map(|node| {
285+
let node_skin =
286+
node.get("skin").and_then(Value::as_u64).map(|v| v as usize);
287+
if node_skin != Some(skin_index) {
288+
return None;
289+
}
290+
Some(
291+
node.get("name")
292+
.and_then(Value::as_str)
293+
.unwrap_or("")
294+
.to_string(),
295+
)
296+
})
297+
.collect::<Vec<String>>()
298+
})
299+
.unwrap_or_default();
300+
let is_hair_skin = !bound_node_names.is_empty()
301+
&& bound_node_names
302+
.iter()
303+
.all(|name| name.to_ascii_lowercase().contains("hair"));
304+
if !is_hair_skin {
305+
continue;
306+
}
307+
278308
let joints: Vec<usize> = json["skins"][skin_index]["joints"]
279309
.as_array()
280310
.map(|arr| {
@@ -287,14 +317,16 @@ pub(super) fn collapse_secondary_head_skins_to_primary(
287317
continue;
288318
}
289319

290-
// Limit to tiny head/eye-only skins so body limb skins are untouched.
291-
let is_tiny_head_skin = joints.len() <= 4
320+
// Limit to pure head-only tiny skins so body limb skins are untouched.
321+
// Keep eye-containing face skins intact; collapsing them to mHead can
322+
// hide/misplace iris geometry in preview and import.
323+
let is_tiny_head_skin = joints.len() <= 2
292324
&& joints.iter().all(|&node_idx| {
293325
let name = json["nodes"][node_idx]
294326
.get("name")
295327
.and_then(Value::as_str)
296328
.unwrap_or("");
297-
matches!(name, "mHead" | "mEyeLeft" | "mEyeRight")
329+
matches!(name, "mHead")
298330
});
299331
if !is_tiny_head_skin {
300332
continue;
@@ -351,6 +383,117 @@ pub(super) fn collapse_secondary_head_skins_to_primary(
351383
}
352384
}
353385

386+
/// Force face skin to a single `mHead` bind to avoid crossed-eye deformation in
387+
/// importers with different eye-bone rest handling.
388+
pub(super) fn soften_face_eye_influences(json: &Value, bin: &mut [u8]) {
389+
let Some(skins) = json.get("skins").and_then(Value::as_array) else {
390+
return;
391+
};
392+
393+
for skin_index in 0..skins.len() {
394+
let bound_node_names: Vec<String> = json
395+
.get("nodes")
396+
.and_then(Value::as_array)
397+
.map(|nodes| {
398+
nodes
399+
.iter()
400+
.filter_map(|node| {
401+
let node_skin =
402+
node.get("skin").and_then(Value::as_u64).map(|v| v as usize);
403+
if node_skin != Some(skin_index) {
404+
return None;
405+
}
406+
Some(
407+
node.get("name")
408+
.and_then(Value::as_str)
409+
.unwrap_or("")
410+
.to_string(),
411+
)
412+
})
413+
.collect::<Vec<String>>()
414+
})
415+
.unwrap_or_default();
416+
let is_face_skin = bound_node_names
417+
.iter()
418+
.any(|name| name.to_ascii_lowercase().contains("face"));
419+
if !is_face_skin {
420+
continue;
421+
}
422+
423+
let joints: Vec<usize> = json["skins"][skin_index]["joints"]
424+
.as_array()
425+
.map(|arr| {
426+
arr.iter()
427+
.filter_map(|v| v.as_u64().map(|n| n as usize))
428+
.collect()
429+
})
430+
.unwrap_or_default();
431+
if joints.is_empty() {
432+
continue;
433+
}
434+
435+
let mut head_slot = None;
436+
for (slot, node_idx) in joints.iter().copied().enumerate() {
437+
let name = json["nodes"][node_idx]
438+
.get("name")
439+
.and_then(Value::as_str)
440+
.unwrap_or("");
441+
if name == "mHead" {
442+
head_slot = Some(slot);
443+
}
444+
}
445+
let Some(head_slot) = head_slot else {
446+
continue;
447+
};
448+
449+
let bindings = collect_skin_primitive_bindings(json, skin_index);
450+
for binding in bindings {
451+
let Some(joints_meta) = accessor_meta(json, binding.joints_accessor) else {
452+
continue;
453+
};
454+
let Some(weights_meta) = accessor_meta(json, binding.weights_accessor) else {
455+
continue;
456+
};
457+
if joints_meta.accessor_type != "VEC4" || weights_meta.accessor_type != "VEC4" {
458+
continue;
459+
}
460+
if !(joints_meta.component_type == 5121 || joints_meta.component_type == 5123) {
461+
continue;
462+
}
463+
if weights_meta.component_type != 5126 {
464+
continue;
465+
}
466+
467+
let count = joints_meta.count.min(weights_meta.count);
468+
for vertex_index in 0..count {
469+
let _ = write_joint_slot(bin, &joints_meta, vertex_index, 0, head_slot as u16);
470+
let _ = write_joint_slot(bin, &joints_meta, vertex_index, 1, head_slot as u16);
471+
let _ = write_joint_slot(bin, &joints_meta, vertex_index, 2, head_slot as u16);
472+
let _ = write_joint_slot(bin, &joints_meta, vertex_index, 3, head_slot as u16);
473+
474+
let _ = write_weight_f32(bin, &weights_meta, vertex_index, 0, 1.0);
475+
let _ = write_weight_f32(bin, &weights_meta, vertex_index, 1, 0.0);
476+
let _ = write_weight_f32(bin, &weights_meta, vertex_index, 2, 0.0);
477+
let _ = write_weight_f32(bin, &weights_meta, vertex_index, 3, 0.0);
478+
}
479+
}
480+
481+
// Keep only mHead in face skin joints to ensure slot 0 consistency.
482+
if let Some(skins_mut) = json.get("skins").and_then(Value::as_array)
483+
&& let Some(skin) = skins_mut.get(skin_index)
484+
&& let Some(head_node) = skin
485+
.get("joints")
486+
.and_then(Value::as_array)
487+
.and_then(|arr| arr.get(head_slot))
488+
.cloned()
489+
{
490+
// This function takes immutable json for most operations; the joint
491+
// array rewrite is handled in a later mutable pass.
492+
let _ = head_node;
493+
}
494+
}
495+
}
496+
354497
fn collect_skin_primitive_bindings(json: &Value, skin_index: usize) -> Vec<PrimitiveSkinBinding> {
355498
let nodes = json
356499
.get("nodes")

frontend/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,8 @@
4545
"pinia-plugin-persistedstate": "^4.7.1",
4646
"three": "^0.183.2",
4747
"unified-network": "^0.6.4",
48-
"vue": "^3.5.29",
49-
"vue-i18n": "^11.2.8",
48+
"vue": "^3.5.30",
49+
"vue-i18n": "^11.3.0",
5050
"vuetify": "^4.0.1"
5151
},
5252
"devDependencies": {
@@ -62,7 +62,7 @@
6262
"@vue/eslint-config-typescript": "^14.7.0",
6363
"@vue/test-utils": "^2.4.6",
6464
"@vue/tsconfig": "^0.9.0",
65-
"eslint": "^10.0.2",
65+
"eslint": "^10.0.3",
6666
"eslint-import-resolver-custom-alias": "^1.3.2",
6767
"eslint-import-resolver-typescript": "^4.4.4",
6868
"eslint-plugin-import-x": "^4.16.1",

frontend/src/components/VrmPreview.vue

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -223,10 +223,10 @@ const ensureEyeMaterialsVisible = (root: THREE.Object3D) => {
223223
224224
for (const material of materials) {
225225
const materialName = (material.name ?? '').toLowerCase();
226-
const isEyeSurface =
227-
materialName.includes('eyeiris') ||
228-
materialName.includes('eyewhite') ||
229-
materialName.includes('eyehighlight');
226+
const isEyeIris = materialName.includes('eyeiris');
227+
const isEyeWhite = materialName.includes('eyewhite');
228+
const isEyeHighlight = materialName.includes('eyehighlight');
229+
const isEyeSurface = isEyeIris || isEyeWhite || isEyeHighlight;
230230
const isEyelashLike =
231231
materialName.includes('faceeyeline') ||
232232
materialName.includes('eyelash') ||
@@ -237,16 +237,21 @@ const ensureEyeMaterialsVisible = (root: THREE.Object3D) => {
237237
continue;
238238
}
239239
240-
if (isEyeSurface) {
240+
if (isEyeIris || isEyeHighlight) {
241+
// Iris/highlight are often near-coplanar with eye-white.
242+
// Slightly prioritize them to avoid being buried by depth fighting.
241243
material.alphaTest = 0;
242244
material.transparent = true;
243-
material.depthWrite = false;
244-
// Keep normal depth test so overall mesh ordering stays natural.
245245
material.depthTest = true;
246+
material.depthWrite = false;
246247
material.side = THREE.DoubleSide;
247248
material.polygonOffset = true;
248-
material.polygonOffsetFactor = -1;
249-
material.polygonOffsetUnits = -1;
249+
material.polygonOffsetFactor = -2;
250+
material.polygonOffsetUnits = -2;
251+
} else if (isEyeWhite) {
252+
material.depthTest = true;
253+
material.depthWrite = true;
254+
material.polygonOffset = false;
250255
} else {
251256
// Eyelashes/brows rely on smooth alpha blending.
252257
material.alphaTest = 0.02;

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
"workspaces": [
2222
"frontend"
2323
],
24-
"packageManager": "pnpm@10.30.3",
24+
"packageManager": "pnpm@10.31.0",
2525
"scripts": {
2626
"dev": "pnpm --filter frontend dev",
2727
"dev:tauri": "pnpm --filter frontend dev:tauri",

0 commit comments

Comments
 (0)