From da7f870dbc987e993b822083f9a1ab0bb8e068c6 Mon Sep 17 00:00:00 2001 From: Paul Flynn Date: Sun, 3 May 2026 16:19:43 -0400 Subject: [PATCH 1/4] feat: Silhouette render mode + additive directional rim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an opt-in stylised render path designed for silhouette + rim aesthetics (e.g. menu hosts, accent compositions) where the standard MToon rim — which multiplies against base albedo — cannot survive a crushed-to-black material. Renderer (VRMRenderer): - disableAutoMaterialOverrides: bypasses the renderer's automatic face/eye/body/skin overrides (white baseColor, zeroed emissive, clobbered shadeColor/shadingToony/shadingShift) so callers retain full CPU-side material control. Default false; legacy paths unchanged. - additiveDirectionalRimEnabled / additiveDirectionalRimPower: pipe an opt-in fresnel rim through to the MToon fragment shader. - Final emissive write on the MToon path now consolidated into a single deterministic branch — the prior code re-zeroed emissive three lines after restoring it, breaking iris-glow routing. Shaders (MToonShader.metal): - New rim term, additive over the lit pass, gated by uniforms: pow(saturate(1 - N·V), max(power, 0.0001)) * saturate(N · -lightDir) * lightColor * intensity summed over the three scene lights. Sits before saturate(), before the baseColor*0.08 floor (which is 0 when baseColor=0), so it survives on a black silhouette. - SkinnedShader Uniforms struct kept in sync (no fragment-side use yet); 432-byte total preserved. CLI (VRMRender, VRMVideoRenderer): - --silhouette wires the renderer flags, crushes baseColorFactor, shadeColorFactor, parametricRimColorFactor and matcapFactor to zero per material, kills ambient and the rim/back light, and warms the key light. Result: jet-black silhouette with a warm directional rim. - --rim-power tunes the fresnel exponent (default 5). .gitignore: cover xcode-derived dirs, *.profraw, emote-output, and the VRMA pack folders so common local artefacts stop showing in git status. Verified visually with VRMRender (still PNG) and VRMVideoRenderer (.mov) against AliciaSolid (VRM 0.0) and VRM1_Constraint_Twist_Sample (VRM 1.0). All 1188 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 7 ++ .../VRMMetalKit/Renderer/VRMRenderer.swift | 40 ++++++++++- .../VRMMetalKit/Renderer/VRMUniforms.swift | 11 ++- .../Resources/VRMMetalKitShaders.metallib | Bin 234820 -> 235684 bytes Sources/VRMMetalKit/Shaders/MToonShader.metal | 35 +++++++++- .../VRMMetalKit/Shaders/SkinnedShader.metal | 4 +- Sources/VRMRender/main.swift | 65 ++++++++++++++---- Sources/VRMVideoRenderer/main.swift | 41 +++++++++-- 8 files changed, 175 insertions(+), 28 deletions(-) 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.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 b2ea78be5308f6e93cc37853e3ab55e8fa450149..cf78799df42534adf39f595cc6a1ac0d569cedde 100644 GIT binary patch delta 12065 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

e3OTtBuPQyJ-{MgTH)vw=!l!G z^)N77{#u*a%%PVWz*#Il=)cmCdDit`5uVL~f`Z^X# zN-bQQFC8wMnKAhXf&-ks4uS zk!4tOtdYee+^TE$!OAJX<>RJAW%rb2 z#IHRaV|9~eWi$C$QmHrnac`L+KJ#=|0O6Xflff9}-ITYHeVC;zMiNyeoYHb@!k1jn zx;5c4EFD_<$|^%PCc{<&#U=yvO#`*0$5`BQuz0vAx9*lr28a!0X!nT9?I{x`>^%?< zWRQs@krM7r24y>=)SK}OkRqY;bPgtk+$si&QO2dP+zK|ZyLsr)HZR=Yg^As#a|5U; zkz`Myc5*G|;KFZ!gNvs232m-aF`K{GcSD@-7|$0Zwmspt2q2*l-IQXA)6{$^OSAJV zlST`Bz^HLzb%#9=dID!6$xHI3QJUS29)Ee02~cd_^kH~v1qB$tb8UVh?uTbECG0^F zn-UDq>aSOKmsB?=RSzZZ*L`jc`G55K;G#ndE%V+AAOO*$pKI^e5?BMQVN&MmgRr8T zHS!@pvZ;4WeC0_IS8MeXY;Go)vd({T2>7+?+p?Ic4E?D6L9 zGU1XOzny=11p`oQ<}iK{tPIGPW>?iDd*P?Jz8sw5QdJUAk~I>tsMimjFJ-attg?L6 zrJGlmWC|~!D-RXglP~*#hz=d@u_sUW8D&*dLHe1ApSfc3`IeL(dC;!*pv*YGUoewx zrwfmnuKztebEeIB6lROgr^VbZfv@mBh|aTMe^&Kj5S=Y&^H zw}KVh>_Oy;(tZqiW6{}r&G!ih#-lUW-&DR!9%c*AJ5Pe}To70p$7L^~7l3qTPCh{z z<)zAKyI6zeLLmtJ*YLo<)ZOk9=?oVH=+kI5NYTehC&NKi&>Ysqpf`ts*k{wr@+nHX zaY8R|x_0x7$+FoXztyzR$kTjB*SP;NPrhS+u}TMaREKaTDaMKocwIp8OPt4LGh1no zy?~4O6dra237Qb39GM?UB3i?%xacht9(G8VA6XJ3i>f}Jy1SNub})q(qegiH+%SlD zvw?PwkuTkKY~U%_Jde#ZA9@TT)CX-eVI=w3^QqjE1eSa|{UoCJ0u^zCUZ|iiipSU; zXQLj2{cvBXM1;{xr1M-ckNN>&(>V}P-u&D@Ga%rGOr%Hz5wwSP5sRR>F?EY?_0apf zOFuJ~Ua3#@Eb}9^p(1n!8opkEXVymY>HLT+mu|!u7fEj1cA^@@!wx3Y8YKrYh~e-d z5HsLi%Q1Ic%y04D8%F2P4|%T*Pt4e+~cIPu0w zEM7iHkhd4%hvS1xa5ydr943zGjDO$iXJxsBZ@3fsx;+JCK!ws9?5r7nhWAr>r)A{jfHGU}SeNN*6@R$#-kLTk&Ou z(Z>wI#2G_zwxA|Xt9O|#A0+>Mnq z@cFX|cN+!nRyW@y0>|)1r6c(h-_c2m&jy_=X^}$<6?5)5c2>**`Spyv^i`4SBuKC? zF^yemn9!IqdEm%Mr*OpVIHGgB|KJV?C%{YB{}RD6mV#O>+!VWBG7T$ez28g!1FV{i zm6S<=r(=A{2_O*I)Ckx`xQ7KjYwA7n@xW83m%Z_XBM@cM#$9`U!Rg#8aCHe`IBUEN$iw&ZF391Ks#7{jz3CVi^ou+ zyQxh6BgzB|VO6cp@Xd(RN+oF32_OFIUJ{DGsWt$@tdO$Tw%8W<*J z+ij7jBeLE)>M{ksiew6_kqwG4)tWM`_p|!@`Pgo+6f9Y>`X3f35Ly zB}i!FrRYaXxm}7m!a0s%q>n|h%fZ~#1_z>>O6L%zcIa?y)TCv|4DQc@iJ`^uJ!ONX z-`Y20iKb}gxihUN@gi^|a3@iyWx|~#tFoshcrz1%NePyM*fX<$J4vZ>WlV-R*7UFp zI%3&a2O^`B(jsk8fJ!2io=%7hm9+Mh)gFnvaXEmHu#7xsN?;#2X;P}eamNOuujWb! z2)#v&Vev9@OiExAP7*qMZv$I{C!RC1&2BY4vcjL^RG?^l)gDD!T^BwW#>@=1~G_B2UJ%-YB!70~zhB%Yhu#YimL z2%IPHZVHPI9x1VCH_R1#Q@@iOF+|Z5741RxAfTq`oSri1VKwBytG9!dbMiM3&faV1 z;R&?{30d*izXof-x@G|UMyy@ffEs&!4Aw$6?1g13V9^CH;=dO6n07tA7ZC;t`TU#hqFZa9EEBG)bz28sxtD!yWjf;{Aj;AouI(v>!3%-X^a|+!YjtYZ z{!&;19>3|rRXe1`7d#!pu_p{JY3l(csDs3&*i;k~g5kW0;0#`LZ0_#Hr?;kyvj$lC zH@kblbZqw89n#?oGv64W{q`(2^KPZd%a?o=R6pj%)!@;|`31;$<1Z1ymaTm<%NDc@ ztL5CThBu%ZV2-&vma*!zmYt`$A4aD}tKSXBoQnJE?z#-{-e(!@#g$7(!=vwlkWoPI zT3#}V3_>DPAzyarw@2Z}Rz=^#3G|i6#tCDnsAAdsvur2q*cu?gc)^)DxtrIzM6d~z znX50zCrwgLL>|fJL_dS=vHHumpp#1(qXVp0sb97qhIJ+^xR1YvnZ6U08qDn8m?>;?bED`@dspac@&ny{8YcL$0Ouv+AbzCEwM z+A?soAFF{_aTS|w+2P(pkR9H48@Z2)WGf$GHn~K4!R_&G$w8fA{_mCFZ2JGZV5Vg!=&qcg3ysYgE**>g56GUnLWz zXx7^okqKS5Og8Pc8w9D}`o84p+MX)!TO-fD0q4caS8h4@{@c*LvT^swZcQXzqW`XQ z{-L+3_SUb@Uf}Pc1na!B{`MZ~(HYK!mFDe-u7{ykF^CkM9ZO)(cR%Lu4KJr_D9DC1&t`Ge?fAhf^6a1&kA#xSq zKfKHg_#5{3_n`%c>Z;2e!_HF=eL2oQmh1!yEOQ4auto~bfE8YwoX_`G3BSx;_zK?u zLmKn<1;%!U+7^%>bW;fxkUJ=(MThKSB8`mt<;NVu`lv^$`q7TE2drbj{bR|67O4qK zva#4UVt2h#2)zI_U{Don?%>lSb<58wK{=RI7|%7LJ*?xmc%+Tj-ULsH%9D`vkuB8- zuS>)e^#|feHpsy_k?sR7TVM9xE%0ViaE4))F8B))UydamM1956QAAKP@BylUv{riE+B7L95#5Gz6l(j8l=nPmgM!2zh@0A zJt?~hhas;X371yKE`~iRpR_3o8Av_5UKvQCZI;}CJaQNeqQ9Pb1Ii-_nw~8GNSAda zUwC)JP330gE%sAY{mr8%zruS`dvg=7px-y0HtT)YFm~WC<+p*i^+fRAJw>w$jMeisM(Q)F9r&d3T6tAdDAw2>EJ zN9`{?kh|8q7xK{V?S%^e@g1pC%e)P~BYmKsbsHKcfloS~UqRmxA({5|eenKYR-|Pz zVF~tS>!+8{&j0*dgO>3anxYSW3>~tE`yDjhrEKlp5oqKeqR6zp&mdnIChK25gGNa( z2{e9&a{s@CsT_r3C~${^w(Tj*)|>6%nUKa5Owus)B+P1rzkk&#)$%B?FEm%bgaTi* zgBP99+d0CIiEx$mtITp_xZxdKQ}zQ#QR7L;oxCXh3^}n2((hXZ-zI4fzlOg4-+ZUp zqu=WeAE!Z$TJR-ssy=EOoc+(wfi2p`>98*hQS|qw!wGiqaGTzf1Ajx*?$yK7?1r6( z{;w?=39D$(&-xFAa4n=y7r}d{Lo>C#*+5B0^)IsFKkQ)FaQ*XKOi8D$N{RwXI)r_b z^wD~4hbMvWSK5bq;H|gOGGd@3d?_O*2GXC%hkM}vSX0>d(n3Tt_re@`p69UI^ZQ26kzUUqd7|6M1_?PN1F4w4 zAbgtNB65}l@q<>}cJHpJh7*1 zHIY)A;|nR#1|)>}M7ab zCk{>R^Ks7d8Acorw_C|GdH^i+GRLjK$)SBX)zXf`_D~ZJ6{58`HEI}##T^*57^2KL ztPriksnHf3R(IgAJz8-M(~Pbl1*gs~#G$;tARC`NyA;R8@J5Ub&#A;=a{&%jb82wd zO5B7EhUV4*Sm@PRfJ5cn^Em7#ZpNwnW*puvz+hhozZIWrA}+w7D5edErExg`D&(pd zBaTNp=&mWiCe;Vr&)ZC5q6AgX119NoUL4y^xX?MJ+K^Kn@6iG!>)Pkn!Knfn`g;eR zvE{tdz2-pjs{)T|QcweC!^~uqSCH-So(KMh{5c^xL?r$Mc~0{$%cW5)+Z$ALb_Z-yNoTFTP< z`bO@rzHPkmU^v0P%~#tnGkWqTVIP0o9I0QVGHZWwgC@g*x!P@W;YqG6=br=0)7x(k ztsc2CGA!DVG@Z7A4#b%T>pVjfXPQe$9+uO5c0Ze_GS}VHbX&+O6Qka7oE| z)@Xd%!b}mv$Aip(s$BcI7GZszu}GL6KYd;V>Fp+^#U=KsOH4nvFa_&Zq(v~~kNo#F z`Fp5h`US5_PMEJB&tWZ&pErL)M}-e(eUL|yYX(=w`UiAFiY#f_2<lsNOR63kR-_QNp?|Ml@6wMZLTo*^9B%k$eegj0yaMFrcDCkcvyg zI1lUo`6D4lApRqvcKwqJu-*>p)W3HLen^D&<3%T=(>5%IeE(4-5O1T7|6c`yuU`Hc zJRj0N7=spS-3@Sr+;VNJB5_x7*RG1h-Q5*BEPJOEg1~r(o}2`-cVT>z;AlR`*JEOG zmf6`G+JZ!iPGZ?Vh>3`&DGl@zY*;ZEmW`e^(66TebRHme9XY&?n9@Q@X%n(r3R86? zwo#ajK?A!Lhdt2z8j?IEeh0(FUx}!v8atD9@%fbSZAbuf5L!?}N{Qbs#z~%i7^#^O ziIJW|3o+8Z3Kt(G0>07B_C^9AT5SUcbtr8v4o~&qQ~@mpho{XrRYH^C@C=uD6#+p% zB!evXW@1r)%uIf!q=jIC|E#K+7JiqKf-Q1hhG1NKXzTHL7rB!#Xr^W2utAJNhC>z( zn`AiTI%MOpxdMk`hfO%V+<-%wLk2bw&Tzwz#+qF2M!-eFu0%Vv=fIz3Y;o-+Kt0+bvTqcy^X_Rv2|gmy*QMA--AzX zaN3XKlV*H$o0ATQ&lpoMIZRF`arhG#gZl~2bpQ@ho{MpaI=_#@Ut~BGIM?IwMFj?5 z%AC*Pb1i0U;g=Q8AK>s*8%EVF)j40l@t<7cEX*bu8Du}3Nq$n&GBA@oFSzR*F7Rt4 zXPKsW_mXqX&h;kbtPEkWlSa9siOO`))6Db=rw3+u{;Wki;g5n>Kb|G=jmVTnWS@%2 z@pX%c_#{F*vned{$|BX*#_7QeKEHZ#e(sECx3*LgBDK&}5Fw0ZTHZ|JB$s??<|-d)p6W*J91< zK9Ig^M!Jf&^^`~Tm-Bxw=R<0~y_(Nd^PiXV-PL?=HAwyZr}A<>PtA{3^Mz{uU^zco z&0oZfS)t}&HtSl1G&W5yTmYEV%%L~yj@}fU1E3|ezZ%BP{U8w@E2+LD>ROx6C?IzIn)OZ znFVIQElnB28vb?t;C0x~US6ybEv$&{&x;qm)T9s&g)9Kf70=s=v?1o z4Kb}^Zhu~JVvqm96R5oDG+K`LEUxKNVq*gDKE|iH{;~~cbOf;%JTbcr0Yr{Qu aUHC4fHE$r2G;KnX20hNuH{63OKm9Kxg0|)W delta 11237 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 Y?0-p{`-c9D-6V?&$A=>Z$2kviJWB>pF 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..8bac3ce 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,43 @@ 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 { + renderer.disableAutoMaterialOverrides = true + renderer.additiveDirectionalRimEnabled = true + renderer.additiveDirectionalRimPower = options.rimPower + for material in model.materials { + 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.parametricRimColorFactor = SIMD3(0, 0, 0) + mtoon.matcapFactor = SIMD3(0, 0, 0) + material.mtoon = mtoon + } + } + renderer.setAmbientColor(SIMD3(0, 0, 0)) + renderer.setLight(0, direction: SIMD3(-0.2, 0.5, -0.85), + color: SIMD3(1.0, 0.9, 0.7), intensity: 1.0) + renderer.disableLight(1) + renderer.disableLight(2) + 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..d502d9e 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,35 @@ struct VRMVideoRendererCLI { renderer.enableSpringBone = true // Set up lighting - if options.heroLighting { + if options.silhouette { + // Silhouette mode (most specific override): black materials + warm rim. + // Disable auto-material overrides so the face baseColor stays black + // (renderer otherwise force-whitens face materials for contour readability). + renderer.disableAutoMaterialOverrides = true + renderer.additiveDirectionalRimEnabled = true + renderer.additiveDirectionalRimPower = options.rimPower + for material in model.materials { + 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.parametricRimColorFactor = SIMD3(0, 0, 0) + mtoon.matcapFactor = SIMD3(0, 0, 0) + material.mtoon = mtoon + } + } + renderer.setAmbientColor(SIMD3(0, 0, 0)) + renderer.setLight(0, direction: SIMD3(-0.2, 0.5, -0.85), + color: SIMD3(1.0, 0.9, 0.7), intensity: 1.0) + renderer.disableLight(1) + renderer.disableLight(2) + 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)) From 2463e4cc6a0531c0b625a09efb8668395eec0064 Mon Sep 17 00:00:00 2001 From: Paul Flynn Date: Sun, 3 May 2026 16:46:00 -0400 Subject: [PATCH 2/4] feat(silhouette): Renderer extension with eye self-illumination + lateral rim defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promotes the silhouette setup from duplicated CLI shims into a first-class VRMRenderer extension (VRMRenderer+Silhouette.swift) and applies three review fixes to the defaults: 1. Light vector — was (-0.2, 0.5, -0.85) (top-lit), now strictly lateral (-1.0, 0.0, 0.2). Y dominance was lighting the tops of horizontal surfaces (bow, shoulders) instead of the side rim. 2. Rim brightness — was color (1.0, 0.9, 0.7) × intensity 1.0, reading as pale yellow / off-white. Now warm ember (0.95, 0.55, 0.30) × intensity 1.0 — every channel ≤ 1.0 so the rasterizer doesn't clip the peak to white. Fresnel exponent does the edge- sharpness work, not intensity. 3. Eye preservation — previously every material's emissive and baseColor were crushed to zero, blacking out eyes. The extension now routes each eye material's baseColorTexture through the emissive sampler so the iris pattern self-illuminates against the silhouette. Eye-name predicate covers VRoid (English) and native VRM (Japanese: 瞳, 白目, ハイライト) naming, with eyebrow / eyelash / eyeliner explicitly excluded. The extension also kills MToon's inverted-hull outline on every material (it's albedo-independent and would otherwise pop as bright artefacts against the crushed body), and zeroes shadeColor / parametricRim / matcap / giIntensity so only the additive directional rim contributes light to non-eye geometry. CLI tools now call `renderer.applySilhouetteMode(model:config:)` instead of duplicating the setup. `--rim-power` continues to drive `config.rimFresnelPower`. All other defaults are tunable on `SilhouetteRenderConfig` for callers that want a different aesthetic. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Renderer/VRMRenderer+Silhouette.swift | 198 ++++++++++++++++++ Sources/VRMRender/main.swift | 21 +- Sources/VRMVideoRenderer/main.swift | 24 +-- 3 files changed, 204 insertions(+), 39 deletions(-) create mode 100644 Sources/VRMMetalKit/Renderer/VRMRenderer+Silhouette.swift diff --git a/Sources/VRMMetalKit/Renderer/VRMRenderer+Silhouette.swift b/Sources/VRMMetalKit/Renderer/VRMRenderer+Silhouette.swift new file mode 100644 index 0000000..8ad7b2d --- /dev/null +++ b/Sources/VRMMetalKit/Renderer/VRMRenderer+Silhouette.swift @@ -0,0 +1,198 @@ +// +// 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 + + /// 0..1 emissive scale for eye materials. 1.0 = the iris texture / colour + /// glows at full original brightness. Lower for a moodier read. + public var eyeEmissiveScale: Float = 1.0 + + /// 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(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/VRMRender/main.swift b/Sources/VRMRender/main.swift index 8bac3ce..492f9b4 100644 --- a/Sources/VRMRender/main.swift +++ b/Sources/VRMRender/main.swift @@ -363,24 +363,9 @@ struct VRMRenderCLI { renderer.loadModel(model) if options.silhouette { - renderer.disableAutoMaterialOverrides = true - renderer.additiveDirectionalRimEnabled = true - renderer.additiveDirectionalRimPower = options.rimPower - for material in model.materials { - 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.parametricRimColorFactor = SIMD3(0, 0, 0) - mtoon.matcapFactor = SIMD3(0, 0, 0) - material.mtoon = mtoon - } - } - renderer.setAmbientColor(SIMD3(0, 0, 0)) - renderer.setLight(0, direction: SIMD3(-0.2, 0.5, -0.85), - color: SIMD3(1.0, 0.9, 0.7), intensity: 1.0) - renderer.disableLight(1) - renderer.disableLight(2) + 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 diff --git a/Sources/VRMVideoRenderer/main.swift b/Sources/VRMVideoRenderer/main.swift index d502d9e..bb9c13a 100644 --- a/Sources/VRMVideoRenderer/main.swift +++ b/Sources/VRMVideoRenderer/main.swift @@ -391,27 +391,9 @@ struct VRMVideoRendererCLI { // Set up lighting if options.silhouette { - // Silhouette mode (most specific override): black materials + warm rim. - // Disable auto-material overrides so the face baseColor stays black - // (renderer otherwise force-whitens face materials for contour readability). - renderer.disableAutoMaterialOverrides = true - renderer.additiveDirectionalRimEnabled = true - renderer.additiveDirectionalRimPower = options.rimPower - for material in model.materials { - 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.parametricRimColorFactor = SIMD3(0, 0, 0) - mtoon.matcapFactor = SIMD3(0, 0, 0) - material.mtoon = mtoon - } - } - renderer.setAmbientColor(SIMD3(0, 0, 0)) - renderer.setLight(0, direction: SIMD3(-0.2, 0.5, -0.85), - color: SIMD3(1.0, 0.9, 0.7), intensity: 1.0) - renderer.disableLight(1) - renderer.disableLight(2) + 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. From 7f0b15261ef65b40222b2666e8c783cb7a67c371 Mon Sep 17 00:00:00 2001 From: Paul Flynn Date: Sun, 3 May 2026 17:02:56 -0400 Subject: [PATCH 3/4] feat(silhouette): Bump default eyeEmissiveScale to 2.5 for host aesthetic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous default (1.0) emitted the iris texture at its authored brightness, which on a fully crushed silhouette body read as "lit eyes" rather than "luminous eyes" — the iris hue showed but the eye didn't function as a focal point. 2.5 saturates the brighter regions of the iris and blows the sclera out to white, giving the eyes the "ghost in the machine / host that's watching" presence appropriate when the silhouette is composed against a UI (menu host, transition slates) and needs to anchor the user's gaze. Body stays bit-for-bit crushed; only the eye-material emissive contribution changes. Documented the curve at the call site: - 1.0 = subtle, lit-eye read - 2.5 = host / hologram (default) - 4.0+ = hard-saturated white iris cores Verified visually on AliciaSolid (VRM 0.0), VRM1_Constraint_Twist_Sample (VRM 1.0), Ao_dress (VRM 1.0), and Vita_clothing (VRM 1.0, heterochromia) — green and blue irises both register as distinct glowing pin-points. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Renderer/VRMRenderer+Silhouette.swift | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/Sources/VRMMetalKit/Renderer/VRMRenderer+Silhouette.swift b/Sources/VRMMetalKit/Renderer/VRMRenderer+Silhouette.swift index 8ad7b2d..f8fc6ca 100644 --- a/Sources/VRMMetalKit/Renderer/VRMRenderer+Silhouette.swift +++ b/Sources/VRMMetalKit/Renderer/VRMRenderer+Silhouette.swift @@ -53,9 +53,18 @@ public struct SilhouetteRenderConfig: Sendable { // MARK: Materials - /// 0..1 emissive scale for eye materials. 1.0 = the iris texture / colour - /// glows at full original brightness. Lower for a moodier read. - public var eyeEmissiveScale: Float = 1.0 + /// 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. From 8c2de511ae4e4ef5de8b0a3db4770a6afd7417c0 Mon Sep 17 00:00:00 2001 From: Paul Flynn Date: Sat, 9 May 2026 22:05:03 -0400 Subject: [PATCH 4/4] test(silhouette): add SilhouetteRenderConfig + applySilhouetteMode coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the review blocker on PR #137 (no automated tests for the new public surface). 12 new tests across three layers: 1. **Predicate (pure):** standard English tokens (eye/iris/sclera/pupil/ eyeball), Japanese tokens (瞳/白目/ハイライト), exclusion edge cases (FaceBrow / Eyelash / FaceEyeline / Outline → body), nil/empty handling, and a documented case for bare "Highlight" inclusion. 2. **Renderer flag invariants:** \`disableAutoMaterialOverrides\`, \`additiveDirectionalRimEnabled\`, \`additiveDirectionalRimPower\` set from config; lights 0/2 zeroed; light 1 = rim; ambient zero. 3. **Material-mutation invariants on the bundled VRM 1.0 fixture:** outlines zeroed everywhere; body materials crushed (base/shade/ emissive/matcap/parametric-rim/giIntensity all zero); eye materials route baseColorTexture → emissiveTexture and emissiveFactor = eyeEmissiveScale; alpha preserved on baseColorFactor.w; custom \`isEyeMaterial\` predicate is honored end-to-end. Also documents the \`highlight\` inclusion rationale and the exclusion- first ordering directly on \`defaultIsEyeMaterial\`. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Renderer/VRMRenderer+Silhouette.swift | 10 + .../SilhouetteRenderConfigTests.swift | 276 ++++++++++++++++++ 2 files changed, 286 insertions(+) create mode 100644 Tests/VRMMetalKitTests/SilhouetteRenderConfigTests.swift diff --git a/Sources/VRMMetalKit/Renderer/VRMRenderer+Silhouette.swift b/Sources/VRMMetalKit/Renderer/VRMRenderer+Silhouette.swift index f8fc6ca..81d8db5 100644 --- a/Sources/VRMMetalKit/Renderer/VRMRenderer+Silhouette.swift +++ b/Sources/VRMMetalKit/Renderer/VRMRenderer+Silhouette.swift @@ -77,6 +77,16 @@ public struct SilhouetteRenderConfig: Sendable { /// 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() 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") + } +}