diff --git a/.gitignore b/.gitignore index e7b2f9e..f1a6adc 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,13 @@ /Packages xcuserdata/ DerivedData/ +.xcode-derived/ +.xcode-derived-main/ +default.profraw +*.profraw +emote-output/ +VRMA_Avatar_Mega_Pack/ +VRMA_Locomotion_Pack/ .swiftpm/configuration/registries.json .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .netrc diff --git a/Sources/VRMMetalKit/Renderer/VRMRenderer+Silhouette.swift b/Sources/VRMMetalKit/Renderer/VRMRenderer+Silhouette.swift new file mode 100644 index 0000000..81d8db5 --- /dev/null +++ b/Sources/VRMMetalKit/Renderer/VRMRenderer+Silhouette.swift @@ -0,0 +1,217 @@ +// +// Copyright 2025 Arkavo +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import simd + +/// Configuration for the renderer's stylised silhouette mode. Every parameter +/// has a sensible default; callers typically override one or two for their +/// scene composition. +public struct SilhouetteRenderConfig: Sendable { + + // MARK: Lighting + + /// Direction-of-travel of the rim light. The MToon shader negates this + /// vector when computing `N·L`, so for a source at world (+X) — screen- + /// right under the standard camera — store (-X, 0, ±Z). Default: strictly + /// lateral from screen-right with a small +Z bias to catch the front + /// profile. Y must stay near zero; any Y component lights the top + /// horizontal surfaces (bow tops, shoulders), which reads as "top-lit" + /// rather than the intended side-rim aesthetic. + public var rimLightDirection: SIMD3 = SIMD3(-1.0, 0.0, 0.2) + + /// Linear-RGB color of the rim. Default: warm ember (~#F28C4D). Stored + /// pre-clamped — combined with `rimLightIntensity` it must stay ≤ 1.0 + /// in every channel or the rim renders as pale yellow / white once the + /// rasterizer clamps. See `rimLightIntensity`. + public var rimLightColor: SIMD3 = SIMD3(0.95, 0.55, 0.30) + + /// Rim brightness multiplier. The shader output is `color * intensity * + /// fresnel * NdotL` and is clamped to [0,1] per channel before display, + /// so `color * intensity` must stay ≤ 1.0 in every channel to preserve + /// hue at the rim peak. With the default warm ember (`max channel 0.95`) + /// the safe ceiling is ~1.05 — any higher and the red channel saturates + /// to white. Use `rimFresnelPower` for edge sharpness, not intensity. + public var rimLightIntensity: Float = 1.0 + + /// Fresnel exponent for the additive rim (`pow(1 - N·V, p)`). Higher = + /// narrower edge clamped to grazing angles. Typical 8..16. + public var rimFresnelPower: Float = 14.0 + + // MARK: Materials + + /// Emissive multiplier for eye materials. The iris/sclera texture is + /// sampled, multiplied by this scalar, and added to the lit pass. The + /// rasterizer then clamps to [0,1] per channel. Useful values: + /// - 1.0: iris glows at the texture's authored brightness — usually + /// subtle and reads as "lit eyes" rather than "luminous eyes." + /// - 2.5: brighter iris colours saturate, sclera blows out to white, + /// giving the "hologram / ghost in the machine" host aesthetic + /// where the eyes act as a bright focal point against the + /// crushed body. Default. + /// - 4.0+: hard-saturated white iris cores; iris hue only survives + /// where the source texture is darkest. Use sparingly. + public var eyeEmissiveScale: Float = 2.5 + + /// Predicate returning `true` for material names that should self- + /// illuminate (eye sclera/iris/pupil) instead of being crushed to black. + /// Default catches the standard VRoid naming convention; pass your own + /// for models that use a different scheme. + public var isEyeMaterial: @Sendable (String?) -> Bool = SilhouetteRenderConfig.defaultIsEyeMaterial + + /// Eye-material name predicate covering VRoid (English) and native VRM + /// (Japanese) naming conventions, with eyebrow/eyelash/eyeliner excluded. + /// English tokens: `eye / iris / sclera / pupil / highlight / eyeball`. + /// Japanese tokens: `瞳 (pupil) / 白目 (sclera) / ハイライト (highlight)`. + /// English match is case-insensitive; Japanese match is exact. + /// + /// Exclusion runs first: `lash / brow / line` always returns `false`. + /// This catches eyebrows, eyelashes, eyeliner, and any name containing + /// "outline" — they need to be part of the body crush. + /// + /// Note on `highlight`: included by design so VRoid exporters that name + /// the iris-highlight decal as bare `Highlight` (no `Eye` prefix) still + /// self-illuminate. If a model uses the literal name `Highlight` for a + /// non-eye material, override `SilhouetteRenderConfig.isEyeMaterial` + /// with a custom predicate. See `SilhouetteRenderConfigTests`. + public static let defaultIsEyeMaterial: @Sendable (String?) -> Bool = { name in + guard let raw = name else { return false } + let lower = raw.lowercased() + for excluded in ["lash", "brow", "line"] where lower.contains(excluded) { + return false + } + for token in ["eye", "iris", "sclera", "pupil", "highlight", "eyeball"] + where lower.contains(token) { + return true + } + for token in ["瞳", "白目", "ハイライト"] where raw.contains(token) { + return true + } + return false + } + + public init() {} +} + +extension VRMRenderer { + + /// Configure the renderer for stylised silhouette rendering: pure-black + /// body albedo with a single warm directional rim from `config.rimLight*`, + /// eye materials re-routed through emissive so they self-illuminate at + /// the iris's own colour. Idempotent — safe to call before or after + /// `loadModel`. Any subsequent gameplay-style scene reset would need to + /// undo each step manually (see comments below). + /// + /// Effects on the renderer: + /// - `disableAutoMaterialOverrides = true` + /// - `additiveDirectionalRimEnabled = true` + /// - `additiveDirectionalRimPower = config.rimFresnelPower` + /// - Light 0 disabled; Light 1 set to the rim; Light 2 disabled + /// - Ambient zeroed + /// + /// Effects on the model's materials: + /// - Outline (MToon inverted-hull) zeroed on every material + /// - For names matching `config.isEyeMaterial`: + /// - `baseColorTexture` (when present) re-routed to `emissiveTexture`, + /// so the iris pattern self-emits at `eyeEmissiveScale`. Without a + /// texture, falls back to writing the literal albedo factor scaled. + /// - `baseColorFactor.rgb` and `shadeColorFactor` collapse to black. + /// - For all other materials: + /// - `baseColorFactor.rgb` = 0, `shadeColorFactor` = 0, + /// `emissiveFactor` = 0, `matcapFactor` = 0, `giIntensityFactor` = 0 + /// - MToon's parametric rim disabled (the additive directional rim + /// takes over via the shader path). + public func applySilhouetteMode(model: VRMModel, + config: SilhouetteRenderConfig = SilhouetteRenderConfig()) { + // Renderer flags + self.disableAutoMaterialOverrides = true + self.additiveDirectionalRimEnabled = true + self.additiveDirectionalRimPower = config.rimFresnelPower + + // Lighting: single warm directional rim, nothing else. + self.disableLight(0) + self.setLight(1, + direction: config.rimLightDirection, + color: config.rimLightColor, + intensity: config.rimLightIntensity) + self.disableLight(2) + self.setAmbientColor(SIMD3(0, 0, 0)) + + // Material overrides + for material in model.materials { + // 1. Kill MToon's inverted-hull outline on every material — + // independent of base color, so it survives the crush below + // and would otherwise pop as bright artifacts. + if var mtoon = material.mtoon { + mtoon.outlineWidthMode = .none + mtoon.outlineWidthFactor = 0 + mtoon.outlineColorFactor = SIMD3(0, 0, 0) + mtoon.outlineLightingMixFactor = 0 + material.mtoon = mtoon + } + + if config.isEyeMaterial(material.name) { + applyEyeOverride(to: material, config: config) + } else { + applyBlackBodyOverride(to: material) + } + } + } + + private func applyEyeOverride(to material: VRMMaterial, config: SilhouetteRenderConfig) { + // Route the iris pattern through the emissive sampler so the eye + // self-illuminates at full original colour. VRoid models typically + // store the iris hue in `baseColorTexture` with `baseColorFactor = + // (1,1,1)`, so emitting the texture * white-factor preserves the + // pattern. Textureless fallback uses the literal albedo factor. + if let baseTex = material.baseColorTexture { + material.emissiveTexture = baseTex + material.emissiveFactor = SIMD3(repeating: config.eyeEmissiveScale) + } else { + let rgb = SIMD3(material.baseColorFactor.x, + material.baseColorFactor.y, + material.baseColorFactor.z) + material.emissiveFactor = rgb * config.eyeEmissiveScale + material.emissiveTexture = nil + } + // Lit + shade pass contributes nothing — only emissive shows. + material.baseColorFactor = SIMD4(0, 0, 0, material.baseColorFactor.w) + if var mtoon = material.mtoon { + mtoon.shadeColorFactor = SIMD3(0, 0, 0) + mtoon.giIntensityFactor = 0 + mtoon.parametricRimColorFactor = SIMD3(0, 0, 0) + mtoon.parametricRimLiftFactor = 0 + mtoon.matcapFactor = SIMD3(0, 0, 0) + material.mtoon = mtoon + } + } + + private func applyBlackBodyOverride(to material: VRMMaterial) { + // Pure-black base + shade. Visible warmth comes solely from the + // additive directional rim shader path (driven by the renderer's + // scene lights, independent of base albedo). + material.baseColorFactor = SIMD4(0, 0, 0, material.baseColorFactor.w) + material.emissiveFactor = SIMD3(0, 0, 0) + if var mtoon = material.mtoon { + mtoon.shadeColorFactor = SIMD3(0, 0, 0) + mtoon.giIntensityFactor = 0 + mtoon.parametricRimColorFactor = SIMD3(0, 0, 0) + mtoon.parametricRimLiftFactor = 0 + mtoon.matcapFactor = SIMD3(0, 0, 0) + material.mtoon = mtoon + } + } +} diff --git a/Sources/VRMMetalKit/Renderer/VRMRenderer.swift b/Sources/VRMMetalKit/Renderer/VRMRenderer.swift index 07d2e3f..06322e0 100644 --- a/Sources/VRMMetalKit/Renderer/VRMRenderer.swift +++ b/Sources/VRMMetalKit/Renderer/VRMRenderer.swift @@ -518,6 +518,30 @@ public final class VRMRenderer: NSObject, @unchecked Sendable { /// Renders only the first mesh for debugging. public var debugSingleMesh = false + /// Disables the renderer's automatic material overrides — namely: + /// • forcing `baseColorFactor` to white for face/eye/body/skin materials + /// • forcing `emissiveFactor` to zero on every material + /// • clobbering `shadeColorFactor` / `shadingToonyFactor` / + /// `shadingShiftFactor` on face materials + /// Default `false` retains legacy gameplay rendering. Set `true` for + /// stylized / silhouette use cases (e.g. menu hosts) that need full + /// CPU-side control of material values. + public var disableAutoMaterialOverrides = false + + /// Enables an additive directional rim term in the MToon fragment shader. + /// For every enabled scene light, the shader adds + /// `pow(1 - N·V, additiveDirectionalRimPower) * max(0, N·L) * lightColor * intensity` + /// on top of the lit pass — independently of base albedo. Designed for + /// pure-black silhouette setups where the standard rim (which multiplies + /// against base) cannot survive. Default `false`; legacy gameplay paths + /// see no behaviour change. + public var additiveDirectionalRimEnabled = false + + /// Fresnel exponent for the additive directional rim. Higher = narrower + /// edge. Typical 4..12. Only applies when `additiveDirectionalRimEnabled` + /// is true. + public var additiveDirectionalRimPower: Float = 5.0 + // Frame counter for debug logging var frameCounter = 0 @@ -1393,6 +1417,11 @@ public final class VRMRenderer: NSObject, @unchecked Sendable { uniforms.lightNormalizationFactor = max(0.0, factor) // Clamp to non-negative } + // Pipe the additive directional rim toggle + power through to the + // shader. The shader gates on `> 0.5`. + uniforms.additiveDirectionalRimEnabled = additiveDirectionalRimEnabled ? 1.0 : 0.0 + uniforms.additiveDirectionalRimPower = additiveDirectionalRimPower + // DEBUG: Log lighting values to verify 3-point lighting is configured #if DEBUG if frameCounter % 60 == 0 { // Log every second at 60fps @@ -2415,8 +2444,10 @@ public final class VRMRenderer: NSObject, @unchecked Sendable { } #endif - // PHASE 4 FIX: Force face materials to render with full brightness - if isFaceMaterial { + // PHASE 4 FIX: Force face materials to render with full brightness. + // Skipped under disableAutoMaterialOverrides so silhouette / + // stylized renderers retain authored baseColor / shadeColor. + if isFaceMaterial && !self.disableAutoMaterialOverrides { // AGGRESSIVE FIX: Always force white baseColorFactor for face materials // This ensures the texture shows at full brightness if frameCounter <= 2 { @@ -2479,6 +2510,11 @@ public final class VRMRenderer: NSObject, @unchecked Sendable { } } + // Restore authored emissive after MToonMaterialUniforms.init(from:) + // resets it. Required for silhouette/stylized renderers that route + // iris glow / accent colors through emissiveFactor. + mtoonUniforms.emissiveFactor = material.emissiveFactor + // ALPHA FIX: Restore effectiveAlphaMode AFTER MToon init // MToon extension may have wrong alphaMode; use our detected/fixed value switch item.effectiveAlphaMode { diff --git a/Sources/VRMMetalKit/Renderer/VRMUniforms.swift b/Sources/VRMMetalKit/Renderer/VRMUniforms.swift index 8ba5276..905e756 100644 --- a/Sources/VRMMetalKit/Renderer/VRMUniforms.swift +++ b/Sources/VRMMetalKit/Renderer/VRMUniforms.swift @@ -48,8 +48,15 @@ struct Uniforms { var _padding2: Float = 0 // 4 bytes padding var _padding3: Float = 0 // 4 bytes padding to align to 16 bytes var toonBands: Int32 = 3 // 4 bytes, offset 416 - var _padding5: Float = 0 // 4 bytes padding - var _padding6: Float = 0 // 4 bytes padding + /// 0 = legacy MToon rim only. >0.5 = enable an additive directional rim + /// term in the fragment shader: + /// `pow(1 - N·V, power) * max(0, N·L) * lightColor * intensity`, summed + /// over all enabled lights and added on top of the lit pass independently + /// of base albedo. Lets a pure-black silhouette still show a directional + /// warm edge. + var additiveDirectionalRimEnabled: Float = 0 // 4 bytes, offset 420 + /// Fresnel exponent for the additive rim. Higher = narrower rim. Typical 4..12. + var additiveDirectionalRimPower: Float = 5 // 4 bytes, offset 424 var _padding7: Float = 0 // 4 bytes padding to align to 16 bytes // Total: 432 bytes (27 x 16-byte blocks) diff --git a/Sources/VRMMetalKit/Resources/VRMMetalKitShaders.metallib b/Sources/VRMMetalKit/Resources/VRMMetalKitShaders.metallib index b2ea78b..cf78799 100644 Binary files a/Sources/VRMMetalKit/Resources/VRMMetalKitShaders.metallib and b/Sources/VRMMetalKit/Resources/VRMMetalKitShaders.metallib differ diff --git a/Sources/VRMMetalKit/Shaders/MToonShader.metal b/Sources/VRMMetalKit/Shaders/MToonShader.metal index 658f4e2..7ea9f71 100644 --- a/Sources/VRMMetalKit/Shaders/MToonShader.metal +++ b/Sources/VRMMetalKit/Shaders/MToonShader.metal @@ -41,8 +41,8 @@ struct Uniforms { float _padding2; float _padding3; int toonBands; // Number of cel-shading bands - float _padding5; - float _padding6; + float additiveDirectionalRimEnabled; // 0 = off (legacy), >0.5 = enable additive directional rim + float additiveDirectionalRimPower; // Fresnel exponent for the additive rim (typical 4..12) float _padding7; }; @@ -664,7 +664,36 @@ return float4(0.0, 0.0, 0.0, 1.0); // Black = no matcap // ADD rim to final color (standard MToon behavior per spec) litColor += finalRim; } - + + // Additive directional rim — opt-in via uniforms.additiveDirectionalRimEnabled. + // Computes `pow(1 - N·V, power) * max(0, N·L) * lightColor * intensity` for + // each enabled scene light and adds the result on top of litColor, completely + // independent of base albedo. Lets a fully crushed (base = 0) material still + // show a warm directional edge — silhouette + rim aesthetic. + if (uniforms.additiveDirectionalRimEnabled > 0.5) { + float3 Nworld = normalize(in.worldNormal); + if (!isFrontFace) Nworld = -Nworld; + float3 Vworld = normalize(in.viewDirection); + float NdotV_world = saturate(dot(Nworld, Vworld)); + float fresnel = pow(saturate(1.0 - NdotV_world), + max(uniforms.additiveDirectionalRimPower, 0.0001)); + + float3 dirRim = float3(0.0); + if (intensity0 > 0.0) { + float NdotL = saturate(dot(Nworld, -uniforms.lightDirection.xyz)); + dirRim += fresnel * NdotL * uniforms.lightColor.xyz * intensity0; + } + if (intensity1 > 0.0) { + float NdotL = saturate(dot(Nworld, -uniforms.light1Direction.xyz)); + dirRim += fresnel * NdotL * uniforms.light1Color.xyz * intensity1; + } + if (intensity2 > 0.0) { + float NdotL = saturate(dot(Nworld, -uniforms.light2Direction.xyz)); + dirRim += fresnel * NdotL * uniforms.light2Color.xyz * intensity2; + } + litColor += dirRim; + } + // DEBUG 35: Final lit color before gamma/sRGB conversion if (uniforms.debugUVs == 35) { return float4(litColor, 1.0); diff --git a/Sources/VRMMetalKit/Shaders/SkinnedShader.metal b/Sources/VRMMetalKit/Shaders/SkinnedShader.metal index 9cf5e27..36046f0 100644 --- a/Sources/VRMMetalKit/Shaders/SkinnedShader.metal +++ b/Sources/VRMMetalKit/Shaders/SkinnedShader.metal @@ -41,8 +41,8 @@ struct Uniforms { float _padding2; float _padding3; int toonBands; - float _padding5; - float _padding6; + float additiveDirectionalRimEnabled; + float additiveDirectionalRimPower; float _padding7; }; diff --git a/Sources/VRMRender/main.swift b/Sources/VRMRender/main.swift index 7676126..492f9b4 100644 --- a/Sources/VRMRender/main.swift +++ b/Sources/VRMRender/main.swift @@ -98,6 +98,8 @@ struct RenderOptions { var bgColorBottom: SIMD3 = SIMD3(0.08, 0.08, 0.12) var expression: String? = nil var expressionWeight: Float = 1.0 + var silhouette: Bool = false + var rimPower: Float = 5.0 } // MARK: - Errors @@ -150,6 +152,10 @@ func printUsage() { --expression Apply VRM expression (happy, angry, sad, relaxed, surprised, aa, ih, ou, ee, oh, blink, etc.) --expression-weight <0-1> Expression weight (default: 1.0) + --silhouette Render avatar as a pure-black silhouette + with an additive directional rim + --rim-power Fresnel exponent for silhouette rim + (typical 4..12, default: 5) --list-debug List all debug modes --help Show this help message @@ -245,6 +251,13 @@ func parseArguments() -> RenderOptions? { if i < args.count, let val = Float(args[i]) { options.expressionWeight = max(0, min(1, val)) } + case "--silhouette": + options.silhouette = true + case "--rim-power": + i += 1 + if i < args.count, let val = Float(args[i]) { + options.rimPower = max(0, val) + } default: if !arg.hasPrefix("-") { positionalArgs.append(arg) @@ -348,21 +361,28 @@ struct VRMRenderCLI { let renderer = VRMRenderer(device: device, config: config) renderer.debugUVs = Int32(options.debugMode) renderer.loadModel(model) - - // Pure anime/cel-shading: Single key light for hard step shadows - // No fill light = hard edges between light and shadow (traditional anime look) - renderer.setLight(0, direction: SIMD3(-0.2, 0.5, -0.85), - color: SIMD3(1.0, 1.0, 1.0), intensity: 1.0) - - // Fill light disabled - crucial for cel-shading - renderer.disableLight(1) - - // Subtle rim light for edge definition only - renderer.setLight(2, direction: SIMD3(0.0, 0.2, 1.0), - color: SIMD3(1.0, 1.0, 1.0), intensity: 0.3) - - // Very low ambient for high contrast (anime style) - renderer.setAmbientColor(SIMD3(0.04, 0.04, 0.04)) // Neutral gray, no blue tint + + if options.silhouette { + var sil = SilhouetteRenderConfig() + sil.rimFresnelPower = options.rimPower + renderer.applySilhouetteMode(model: model, config: sil) + print(" ✓ Silhouette mode (rim power \(options.rimPower))") + } else { + // Pure anime/cel-shading: Single key light for hard step shadows + // No fill light = hard edges between light and shadow (traditional anime look) + renderer.setLight(0, direction: SIMD3(-0.2, 0.5, -0.85), + color: SIMD3(1.0, 1.0, 1.0), intensity: 1.0) + + // Fill light disabled - crucial for cel-shading + renderer.disableLight(1) + + // Subtle rim light for edge definition only + renderer.setLight(2, direction: SIMD3(0.0, 0.2, 1.0), + color: SIMD3(1.0, 1.0, 1.0), intensity: 0.3) + + // Very low ambient for high contrast (anime style) + renderer.setAmbientColor(SIMD3(0.04, 0.04, 0.04)) // Neutral gray, no blue tint + } // Calculate bounding box for auto-framing let (minBounds, maxBounds) = model.calculateBoundingBox() diff --git a/Sources/VRMVideoRenderer/main.swift b/Sources/VRMVideoRenderer/main.swift index d17b474..bb9c13a 100644 --- a/Sources/VRMVideoRenderer/main.swift +++ b/Sources/VRMVideoRenderer/main.swift @@ -111,6 +111,10 @@ func printUsage() { --hero-lighting Use a softer 3-point lighting + lifted ambient for hero/portrait shots instead of the cel-shading default. Pair with `--outline-scale 0.0` for a clean stillshot. + --silhouette Render avatar as a pure-black silhouette + with an additive directional rim + --rim-power Fresnel exponent for silhouette rim + (typical 4..12, default: 5) --help Show this help message EXAMPLES: @@ -166,6 +170,8 @@ struct RenderOptions { var dumpBonesFilter: String? = nil var outlineScale: Float = 1.0 var heroLighting: Bool = false + var silhouette: Bool = false + var rimPower: Float = 5.0 } func parseArguments() -> RenderOptions? { @@ -240,6 +246,13 @@ func parseArguments() -> RenderOptions? { } case "--hero-lighting": options.heroLighting = true + case "--silhouette": + options.silhouette = true + case "--rim-power": + i += 1 + if i < args.count, let val = Float(args[i]) { + options.rimPower = max(0, val) + } default: break } @@ -377,15 +390,17 @@ struct VRMVideoRendererCLI { renderer.enableSpringBone = true // Set up lighting - if options.heroLighting { + if options.silhouette { + var sil = SilhouetteRenderConfig() + sil.rimFresnelPower = options.rimPower + renderer.applySilhouetteMode(model: model, config: sil) + print(" 🌒 Silhouette mode (rim power \(options.rimPower))") + } else if options.heroLighting { // Hero/portrait setup: 3-point with soft fill and lifted ambient. - // Key: front-right, slightly warm. renderer.setLight(0, direction: SIMD3(0.3, -0.3, -0.85), color: SIMD3(1.0, 0.97, 0.92), intensity: 1.0) - // Fill: front-left, cool, half-strength. renderer.setLight(1, direction: SIMD3(-0.5, -0.1, -0.85), color: SIMD3(0.85, 0.9, 1.0), intensity: 0.55) - // Rim: behind, slightly warm, edge highlight. renderer.setLight(2, direction: SIMD3(0.0, -0.4, 0.85), color: SIMD3(1.0, 0.95, 0.9), intensity: 0.4) renderer.setAmbientColor(SIMD3(0.18, 0.18, 0.2)) diff --git a/Tests/VRMMetalKitTests/SilhouetteRenderConfigTests.swift b/Tests/VRMMetalKitTests/SilhouetteRenderConfigTests.swift new file mode 100644 index 0000000..80d3cdd --- /dev/null +++ b/Tests/VRMMetalKitTests/SilhouetteRenderConfigTests.swift @@ -0,0 +1,276 @@ +// +// Copyright 2025 Arkavo +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// + +import XCTest +import Metal +import simd +@testable import VRMMetalKit + +/// Tests for `SilhouetteRenderConfig` and `VRMRenderer.applySilhouetteMode`. +/// +/// Three layers: +/// 1. **Predicate** — pure-Swift tests of `defaultIsEyeMaterial` against +/// VRoid English, native VRM Japanese, exclusion edge cases, nil handling. +/// 2. **Renderer flag invariants** — `applySilhouetteMode` sets the documented +/// flags, configures the rim light, zeros ambient. +/// 3. **Material-mutation invariants** — uses the bundled VRM 1.0 fixture +/// to verify body materials are crushed, eye materials route texture → +/// emissive, outlines are zeroed everywhere. +@MainActor +final class SilhouetteRenderConfigTests: XCTestCase { + + var device: MTLDevice! + + override func setUp() async throws { + guard let device = MTLCreateSystemDefaultDevice() else { + throw XCTSkip("Metal not available") + } + self.device = device + } + + // MARK: - 1. Predicate tests (pure) + + /// VRoid English convention: any name containing `eye / iris / sclera / + /// pupil / highlight / eyeball` should self-illuminate. Case-insensitive. + func testDefaultIsEyeMaterial_includesStandardEyeNames() { + let pred = SilhouetteRenderConfig.defaultIsEyeMaterial + XCTAssertTrue(pred("EyeIris"), "EyeIris should be eye") + XCTAssertTrue(pred("eye_iris"), "eye_iris should be eye") + XCTAssertTrue(pred("EYEBALL"), "EYEBALL (uppercase) should be eye") + XCTAssertTrue(pred("Sclera"), "Sclera should be eye") + XCTAssertTrue(pred("EyeWhite"), "EyeWhite should be eye (matches 'eye')") + XCTAssertTrue(pred("Iris_R"), "Iris_R should be eye") + XCTAssertTrue(pred("M_Pupil"), "M_Pupil should be eye") + } + + /// `lash / brow / line` exclusion takes precedence over inclusion. This + /// catches eyebrows, eyelashes, eyeliner, and any name containing + /// "outline" — they all need to be part of the body crush. + func testDefaultIsEyeMaterial_excludesEyebrowsAndEyelashes() { + let pred = SilhouetteRenderConfig.defaultIsEyeMaterial + XCTAssertFalse(pred("FaceBrow"), "FaceBrow excluded via 'brow'") + XCTAssertFalse(pred("Eyelash"), "Eyelash excluded via 'lash'") + XCTAssertFalse(pred("FaceEyeline"), "FaceEyeline excluded via 'line'") + XCTAssertFalse(pred("Eyeliner"), "Eyeliner excluded via 'line'") + XCTAssertFalse(pred("Hairline"), "Hairline excluded via 'line'") + XCTAssertFalse(pred("Outline"), "Outline excluded via 'line'") + } + + /// Native VRM models may name materials in Japanese. Match is exact + /// (case-insensitive doesn't apply to ideographs). + func testDefaultIsEyeMaterial_includesJapaneseNames() { + let pred = SilhouetteRenderConfig.defaultIsEyeMaterial + XCTAssertTrue(pred("瞳"), "瞳 (pupil) should be eye") + XCTAssertTrue(pred("白目"), "白目 (sclera) should be eye") + XCTAssertTrue(pred("ハイライト"), "ハイライト (highlight) should be eye") + XCTAssertTrue(pred("Mat_瞳_R"), "Japanese token in mixed name") + } + + /// `nil` name (rare but legal in glTF) should not crash and should not + /// be treated as an eye. + func testDefaultIsEyeMaterial_handlesNilName() { + let pred = SilhouetteRenderConfig.defaultIsEyeMaterial + XCTAssertFalse(pred(nil)) + XCTAssertFalse(pred("")) + } + + /// Documents (does not change) that a bare "highlight" name without an + /// eye prefix is currently included. This is intentional for VRoid + /// exporters that name iris-highlight decals as "Highlight" alone. If a + /// future model authors a non-eye material named "Highlight", it will + /// self-illuminate; the workaround is a custom predicate via + /// `SilhouetteRenderConfig.isEyeMaterial`. + func testDefaultIsEyeMaterial_includesBareHighlight_byDesign() { + let pred = SilhouetteRenderConfig.defaultIsEyeMaterial + XCTAssertTrue(pred("Highlight")) + XCTAssertTrue(pred("EyeHighlight")) + } + + /// A custom predicate via `SilhouetteRenderConfig.isEyeMaterial` overrides + /// the default and is honored by `applySilhouetteMode`. (Predicate-only + /// test; the integration test below confirms the renderer respects it.) + func testCustomIsEyeMaterialPredicateIsHonored() { + var config = SilhouetteRenderConfig() + config.isEyeMaterial = { name in + name?.lowercased().contains("custom_marker") ?? false + } + XCTAssertTrue(config.isEyeMaterial("MyCustom_Marker_Iris")) + XCTAssertFalse(config.isEyeMaterial("EyeIris")) + } + + // MARK: - 2. Renderer flag invariants + + /// `applySilhouetteMode` must set exactly the documented renderer flags. + func testApplySilhouetteMode_setsRendererFlags() async throws { + try requireFixture(vrm10Path, hint: testVRM10Filename) + let model = try await VRMModel.load(from: URL(fileURLWithPath: vrm10Path), device: device) + let renderer = VRMRenderer(device: device) + + XCTAssertFalse(renderer.disableAutoMaterialOverrides, "default off") + XCTAssertFalse(renderer.additiveDirectionalRimEnabled, "default off") + + var config = SilhouetteRenderConfig() + config.rimFresnelPower = 9.5 + renderer.applySilhouetteMode(model: model, config: config) + + XCTAssertTrue(renderer.disableAutoMaterialOverrides) + XCTAssertTrue(renderer.additiveDirectionalRimEnabled) + XCTAssertEqual(renderer.additiveDirectionalRimPower, 9.5, accuracy: 0.0001) + } + + /// Light 0 and Light 2 must be disabled (zero color). Light 1 must be + /// configured to the rim params from the config. Ambient must be zero. + func testApplySilhouetteMode_clearsAmbientAndConfiguresRimLight() async throws { + try requireFixture(vrm10Path, hint: testVRM10Filename) + let model = try await VRMModel.load(from: URL(fileURLWithPath: vrm10Path), device: device) + let renderer = VRMRenderer(device: device) + + var config = SilhouetteRenderConfig() + config.rimLightDirection = SIMD3(-1, 0, 0) + config.rimLightColor = SIMD3(0.9, 0.5, 0.3) + config.rimLightIntensity = 0.8 + renderer.applySilhouetteMode(model: model, config: config) + + XCTAssertEqual(renderer.uniforms.ambientColor, SIMD3(0, 0, 0)) + XCTAssertEqual(renderer.uniforms.lightColor, SIMD3(0, 0, 0), + "Light 0 disabled by silhouette mode") + XCTAssertEqual(renderer.uniforms.light2Color, SIMD3(0, 0, 0), + "Light 2 disabled by silhouette mode") + // Light 1 = rim. setLight() multiplies color by intensity. + let expectedLight1Color = SIMD3(0.9, 0.5, 0.3) * 0.8 + XCTAssertEqual(renderer.uniforms.light1Color.x, expectedLight1Color.x, accuracy: 0.001) + XCTAssertEqual(renderer.uniforms.light1Color.y, expectedLight1Color.y, accuracy: 0.001) + XCTAssertEqual(renderer.uniforms.light1Color.z, expectedLight1Color.z, accuracy: 0.001) + } + + // MARK: - 3. Material-mutation invariants (real fixture) + + private var vrm10Path: String { getTestVRM10ModelPath() } + + /// Every material's MToon outline must be zeroed after silhouette apply — + /// the inverted-hull outline is albedo-independent and would otherwise pop + /// as a bright artifact on the crushed body. + func testApplySilhouetteMode_zerosOutlinesOnAllMaterials() async throws { + try requireFixture(vrm10Path, hint: testVRM10Filename) + let model = try await VRMModel.load(from: URL(fileURLWithPath: vrm10Path), device: device) + let renderer = VRMRenderer(device: device) + renderer.applySilhouetteMode(model: model, config: SilhouetteRenderConfig()) + + for material in model.materials { + guard let mtoon = material.mtoon else { continue } + XCTAssertEqual(mtoon.outlineWidthFactor, 0, + "outlineWidthFactor on '\(material.name ?? "?")' should be zeroed") + XCTAssertEqual(mtoon.outlineColorFactor, SIMD3(0, 0, 0), + "outlineColorFactor on '\(material.name ?? "?")' should be zeroed") + } + } + + /// Body materials (not matched by the eye predicate) must collapse to + /// pure black on every contributing channel. + func testApplySilhouetteMode_crushesBodyMaterials() async throws { + try requireFixture(vrm10Path, hint: testVRM10Filename) + let model = try await VRMModel.load(from: URL(fileURLWithPath: vrm10Path), device: device) + let renderer = VRMRenderer(device: device) + let config = SilhouetteRenderConfig() + renderer.applySilhouetteMode(model: model, config: config) + + for material in model.materials where !config.isEyeMaterial(material.name) { + let rgb = SIMD3(material.baseColorFactor.x, + material.baseColorFactor.y, + material.baseColorFactor.z) + XCTAssertEqual(rgb, SIMD3(0, 0, 0), + "Body '\(material.name ?? "?")' baseColorFactor.rgb should be zero") + XCTAssertEqual(material.emissiveFactor, SIMD3(0, 0, 0), + "Body '\(material.name ?? "?")' emissiveFactor should be zero") + if let mtoon = material.mtoon { + XCTAssertEqual(mtoon.shadeColorFactor, SIMD3(0, 0, 0), + "Body '\(material.name ?? "?")' shadeColorFactor should be zero") + XCTAssertEqual(mtoon.matcapFactor, SIMD3(0, 0, 0), + "Body '\(material.name ?? "?")' matcapFactor should be zero") + XCTAssertEqual(mtoon.parametricRimColorFactor, SIMD3(0, 0, 0), + "Body '\(material.name ?? "?")' parametric rim should be zero") + XCTAssertEqual(mtoon.giIntensityFactor, 0, + "Body '\(material.name ?? "?")' giIntensityFactor should be zero") + } + } + } + + /// Eye materials with a `baseColorTexture` must route it through + /// `emissiveTexture`, scaled by `eyeEmissiveScale`. Their `baseColorFactor` + /// rgb still collapses to black so only the emissive lights up. + func testApplySilhouetteMode_routesEyeBaseTextureToEmissive() async throws { + try requireFixture(vrm10Path, hint: testVRM10Filename) + let model = try await VRMModel.load(from: URL(fileURLWithPath: vrm10Path), device: device) + let renderer = VRMRenderer(device: device) + + // Pre-snapshot: capture each eye's original baseColorTexture pointer. + var preSnapshots: [(name: String?, baseTex: VRMTexture?, baseFactor: SIMD4)] = [] + for material in model.materials where SilhouetteRenderConfig.defaultIsEyeMaterial(material.name) { + preSnapshots.append((material.name, material.baseColorTexture, material.baseColorFactor)) + } + XCTAssertGreaterThan(preSnapshots.count, 0, + "Fixture should contain at least one eye material") + + var config = SilhouetteRenderConfig() + config.eyeEmissiveScale = 2.5 + renderer.applySilhouetteMode(model: model, config: config) + + for snapshot in preSnapshots { + let material = model.materials.first(where: { $0.name == snapshot.name })! + // Emissive routing: texture variant + if snapshot.baseTex != nil { + XCTAssertNotNil(material.emissiveTexture, + "Eye '\(snapshot.name ?? "?")' should have emissive texture after apply") + XCTAssertEqual(material.emissiveTexture === snapshot.baseTex, true, + "Eye '\(snapshot.name ?? "?")' emissiveTexture should == original baseColorTexture") + XCTAssertEqual(material.emissiveFactor.x, 2.5, accuracy: 0.0001, + "Eye '\(snapshot.name ?? "?")' emissiveFactor should equal eyeEmissiveScale") + } else { + // Textureless variant: emissive should be the original albedo * scale. + let rgb = SIMD3(snapshot.baseFactor.x, snapshot.baseFactor.y, snapshot.baseFactor.z) * 2.5 + XCTAssertEqual(material.emissiveFactor.x, rgb.x, accuracy: 0.001) + XCTAssertEqual(material.emissiveFactor.y, rgb.y, accuracy: 0.001) + XCTAssertEqual(material.emissiveFactor.z, rgb.z, accuracy: 0.001) + } + // baseColorFactor.rgb collapsed to zero (alpha preserved). + let baseRGB = SIMD3(material.baseColorFactor.x, + material.baseColorFactor.y, + material.baseColorFactor.z) + XCTAssertEqual(baseRGB, SIMD3(0, 0, 0), + "Eye '\(snapshot.name ?? "?")' baseColorFactor.rgb should be zero") + XCTAssertEqual(material.baseColorFactor.w, snapshot.baseFactor.w, accuracy: 0.0001, + "Eye '\(snapshot.name ?? "?")' baseColorFactor.w (alpha) should be preserved") + } + } + + /// Custom eye predicate routes a non-default material name into the eye + /// path. End-to-end check that `config.isEyeMaterial` is the only contract. + func testApplySilhouetteMode_honorsCustomEyePredicate() async throws { + try requireFixture(vrm10Path, hint: testVRM10Filename) + let model = try await VRMModel.load(from: URL(fileURLWithPath: vrm10Path), device: device) + let renderer = VRMRenderer(device: device) + + // Pick a body material to forcibly classify as "eye". + guard let target = model.materials.first(where: { name in + !SilhouetteRenderConfig.defaultIsEyeMaterial(name.name) && name.baseColorTexture != nil + }) else { + throw XCTSkip("No suitable body material with texture in fixture") + } + let targetName = target.name + let originalTex = target.baseColorTexture + + var config = SilhouetteRenderConfig() + config.isEyeMaterial = { name in name == targetName } + renderer.applySilhouetteMode(model: model, config: config) + + XCTAssertTrue(target.emissiveTexture === originalTex, + "Custom predicate target should have emissive routed from base texture") + } +}