Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
207 changes: 207 additions & 0 deletions Sources/VRMMetalKit/Renderer/VRMRenderer+Silhouette.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
//
// 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<Float> = SIMD3<Float>(-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<Float> = SIMD3<Float>(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.
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<Float>(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<Float>(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<Float>(repeating: config.eyeEmissiveScale)
} else {
let rgb = SIMD3<Float>(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<Float>(0, 0, 0, material.baseColorFactor.w)
if var mtoon = material.mtoon {
mtoon.shadeColorFactor = SIMD3<Float>(0, 0, 0)
mtoon.giIntensityFactor = 0
mtoon.parametricRimColorFactor = SIMD3<Float>(0, 0, 0)
mtoon.parametricRimLiftFactor = 0
mtoon.matcapFactor = SIMD3<Float>(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<Float>(0, 0, 0, material.baseColorFactor.w)
material.emissiveFactor = SIMD3<Float>(0, 0, 0)
if var mtoon = material.mtoon {
mtoon.shadeColorFactor = SIMD3<Float>(0, 0, 0)
mtoon.giIntensityFactor = 0
mtoon.parametricRimColorFactor = SIMD3<Float>(0, 0, 0)
mtoon.parametricRimLiftFactor = 0
mtoon.matcapFactor = SIMD3<Float>(0, 0, 0)
material.mtoon = mtoon
}
}
}
55 changes: 49 additions & 6 deletions Sources/VRMMetalKit/Renderer/VRMRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,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

Expand Down Expand Up @@ -1364,6 +1388,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
Expand Down Expand Up @@ -2372,8 +2401,13 @@ public final class VRMRenderer: NSObject, @unchecked Sendable {
mtoonUniforms.roughnessFactor = material.roughnessFactor
mtoonUniforms.emissiveFactor = material.emissiveFactor

// LIGHTING FIX: Zero out emissive to prevent washout
mtoonUniforms.emissiveFactor = SIMD3<Float>(0, 0, 0)
// LIGHTING FIX: Zero out emissive to prevent washout.
// Skipped under disableAutoMaterialOverrides so silhouette
// / stylized renderers can route iris glow etc. through
// emissiveFactor without it being clobbered.
if !self.disableAutoMaterialOverrides {
mtoonUniforms.emissiveFactor = SIMD3<Float>(0, 0, 0)
}

// DEBUG: Log original baseColorFactor for all materials
#if DEBUG
Expand All @@ -2386,8 +2420,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 {
Expand Down Expand Up @@ -2450,8 +2486,15 @@ public final class VRMRenderer: NSObject, @unchecked Sendable {
}
}

// LIGHTING FIX: Zero emissive AFTER MToon init to prevent washout
mtoonUniforms.emissiveFactor = SIMD3<Float>(0, 0, 0)
// Final emissive write for the MToon path. MToonMaterialUniforms.init(from:)
// resets emissive to (0,0,0); legacy mode keeps it zero to avoid washout,
// silhouette mode restores material.emissiveFactor so iris glow / etc.
// can be routed through it.
if self.disableAutoMaterialOverrides {
mtoonUniforms.emissiveFactor = material.emissiveFactor
} else {
mtoonUniforms.emissiveFactor = SIMD3<Float>(0, 0, 0)
}

// ALPHA FIX: Restore effectiveAlphaMode AFTER MToon init
// MToon extension may have wrong alphaMode; use our detected/fixed value
Expand Down
11 changes: 9 additions & 2 deletions Sources/VRMMetalKit/Renderer/VRMUniforms.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Binary file modified Sources/VRMMetalKit/Resources/VRMMetalKitShaders.metallib
Binary file not shown.
35 changes: 32 additions & 3 deletions Sources/VRMMetalKit/Shaders/MToonShader.metal
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand Down Expand Up @@ -645,7 +645,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);
Expand Down
4 changes: 2 additions & 2 deletions Sources/VRMMetalKit/Shaders/SkinnedShader.metal
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ struct Uniforms {
float _padding2;
float _padding3;
int toonBands;
float _padding5;
float _padding6;
float additiveDirectionalRimEnabled;
float additiveDirectionalRimPower;
float _padding7;
};

Expand Down
Loading
Loading