SpecularHelpers.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325
  1. import { Fn, If, PI, clamp, cos, cross, dot, equirectUV, float, log, max, mix, normalize, pow, reflect, sin, sqrt, struct, vec3 } from 'three/tsl';
  2. /**
  3. * Specular / microfacet BRDF helpers: VNDF sampling, GTR distribution, Smith geometry,
  4. * Fresnel, reflection importance sampling, parallax-corrected ray-length terms, and
  5. * equirectangular environment sampling / MIS helpers.
  6. * Pure TSL functions of their inputs (no scene/camera state).
  7. */
  8. /**
  9. * Sentinel ray length the SSR pass writes for environment misses (no screen-space hit), set far above
  10. * any real hit distance so a single magnitude test separates misses from hits and survives `.max( 0 )`.
  11. *
  12. * @type {number}
  13. */
  14. export const ENV_RAY_LENGTH = 1e4;
  15. /**
  16. * Classification threshold for {@link ENV_RAY_LENGTH}: above this is an env miss, below a real hit.
  17. * An order of magnitude under the sentinel, robust to fp16 storage and bilinear blending at borders.
  18. *
  19. * @type {number}
  20. */
  21. export const ENV_RAY_LENGTH_THRESHOLD = 1e3;
  22. // Bounded-VNDF sampler (Eto & Tokuyoshi 2023; spherical-cap form, Dupuy & Benyoub 2023)
  23. const SampleGGXVNDF = Fn( ( [ V, ax, ay, r1, r2 ] ) => {
  24. // Warp the view direction to the hemisphere ("standard") configuration.
  25. const wiStd = normalize( vec3( ax.mul( V.x ), ay.mul( V.y ), V.z ) ).toVar();
  26. // Isotropic bound on the spherical cap (Eto & Tokuyoshi eq. 5). alpha ∈ [0,1] here,
  27. // so the sign term in `s` is always +1 and is dropped.
  28. const a = ax.min( ay ).toVar();
  29. const s = float( 1.0 ).add( V.xy.length() ).toVar();
  30. const a2 = a.mul( a ).toVar();
  31. const s2 = s.mul( s ).toVar();
  32. const k = a2.oneMinus().mul( s2 ).div( s2.add( a2.mul( V.z ).mul( V.z ) ) ).toVar();
  33. // Tighten the cap with the bound (upper hemisphere; N·V ≥ 0 in our usage).
  34. const b = wiStd.z.mul( k ).toVar();
  35. // Sample the (bounded) spherical cap.
  36. const phi = float( 6.283185307179586 ).mul( r1 ).toVar(); // 2*pi
  37. const z = r2.oneMinus().mul( float( 1.0 ).add( b ) ).sub( b ).toVar();
  38. const sinTheta = sqrt( max( float( 0.0 ), float( 1.0 ).sub( z.mul( z ) ) ) ).toVar();
  39. const c = vec3( sinTheta.mul( cos( phi ) ), sinTheta.mul( sin( phi ) ), z ).toVar();
  40. // Microfacet normal in the standard config, then warp back to the ellipsoid (unstretch).
  41. const wmStd = c.add( wiStd ).toVar();
  42. const Ne = normalize( vec3(
  43. ax.mul( wmStd.x ),
  44. ay.mul( wmStd.y ),
  45. max( float( 0.0 ), wmStd.z )
  46. ) ).toVar();
  47. return Ne;
  48. }, {
  49. name: 'SampleGGXVNDF',
  50. type: 'vec3',
  51. inputs: [
  52. { name: 'V', type: 'vec3' },
  53. { name: 'ax', type: 'float' },
  54. { name: 'ay', type: 'float' },
  55. { name: 'r1', type: 'float' },
  56. { name: 'r2', type: 'float' },
  57. ]
  58. } );
  59. // Generalized Trowbridge-Reitz (GTR). For GGX set k=2.
  60. // D_GTR(roughness, NoH, k) where roughness = α (not α²).
  61. export const D_GTR = Fn( ( [ roughness, NoH, k ] ) => {
  62. // see: Filament - Normal distribution function (specular D) - 4.4.1
  63. const a2 = roughness.mul( roughness ).toVar(); // α²
  64. const NoH2 = NoH.mul( NoH ).toVar();
  65. const base = NoH2.mul( a2.sub( float( 1.0 ) ) ).add( float( 1.0 ) ).toVar();
  66. // a² / (π * base^k)
  67. return a2.div( PI.mul( pow( base, k ) ) ).toVar(); // float
  68. } );
  69. // Smith G1 (Heitz): expects alpha (not squared); it squares internally
  70. export const SmithG = Fn( ( [ NDotX, alpha ] ) => {
  71. // see: Filament - Geometric shadowing (specular G) - 4.4.2
  72. const a2 = alpha.mul( alpha ).toVar(); // α²
  73. const NDotX2 = NDotX.mul( NDotX ).toVar(); // (N·X)²
  74. return float( 2.0 ).mul( NDotX ).div(
  75. NDotX.add( sqrt(
  76. a2.add( a2.oneMinus().mul( NDotX2 ) )
  77. ) )
  78. );
  79. } );
  80. // Geometry term G = G1(N·L, α_G) * G1(N·V, α_G) (α_G is NOT squared here)
  81. export const GeometryTerm = Fn( ( [ NoL, NoV, alphaG ] ) => {
  82. const G1v = SmithG( NoV, alphaG ).toVar();
  83. const G1l = SmithG( NoL, alphaG ).toVar();
  84. return G1v.mul( G1l ).toVar();
  85. } );
  86. // Bounded VNDF direction PDF (reflection mapping), matching SampleGGXVNDF above.
  87. // p(L) = D_GTR(roughness, NoH, 2) / ( 2 * (k * N·V + t) ) (Eto & Tokuyoshi eq. 8)
  88. // with the isotropic cap bound k and t = ‖(α·V.xy, V.z)‖. Here 'roughness' is α, not α².
  89. const GGXVNDFPdf = Fn( ( [ NoH, NoV, roughness ] ) => {
  90. const D = D_GTR( roughness, NoH, float( 2.0 ) ).toVar();
  91. const a2 = roughness.mul( roughness ).toVar();
  92. const sinV2 = max( float( 0.0 ), float( 1.0 ).sub( NoV.mul( NoV ) ) ).toVar(); // ‖V.xy‖²
  93. const s = float( 1.0 ).add( sqrt( sinV2 ) ).toVar();
  94. const s2 = s.mul( s ).toVar();
  95. const k = float( 1.0 ).sub( a2 ).mul( s2 ).div( s2.add( a2.mul( NoV ).mul( NoV ) ) ).toVar();
  96. const t = sqrt( a2.mul( sinV2 ).add( NoV.mul( NoV ) ) ).toVar();
  97. return D.div( max( float( 1e-6 ), float( 2.0 ).mul( k.mul( NoV ).add( t ) ) ) ).toVar();
  98. } );
  99. /**
  100. * Fresnel reflectance for the Schlick approximation.
  101. */
  102. export const F_Schlick = Fn( ( [ f0, theta ] ) => {
  103. const oneMinus = float( 1.0 ).sub( theta ).toVar();
  104. const oneMinus2 = oneMinus.mul( oneMinus ).toVar();
  105. const oneMinus5 = oneMinus2.mul( oneMinus2 ).mul( oneMinus ).toVar();
  106. return f0.add( vec3( 1.0 ).sub( f0 ).mul( oneMinus5 ) ).toVar(); // vec3
  107. } );
  108. /**
  109. * Specular dominant factor for parallax-corrected ray length.
  110. * From REBLUR: A Hierarchical Recurrent Denoiser (NRD).
  111. */
  112. export const getSpecularDominantFactor = Fn( ( [ NoV, roughness ] ) => {
  113. const a = float( 0.298475 ).mul(
  114. log( float( 39.4115 ).sub( float( 39.0029 ).mul( roughness ) ) )
  115. );
  116. const f = float( 1.0 ).sub( NoV ).pow( 10.8649 )
  117. .mul( float( 1.0 ).sub( a ) )
  118. .add( a );
  119. return clamp( f );
  120. } ).setLayout( {
  121. name: 'getSpecularDominantFactor',
  122. type: 'float',
  123. inputs: [
  124. { name: 'NoV', type: 'float' },
  125. { name: 'roughness', type: 'float' }
  126. ]
  127. } );
  128. /**
  129. * Everything a single GGX reflection sample produces. `reflectDir` and `sampleWeight`
  130. * drive the SSR ray-march and compositing; `pdf`, `NdotV`, `alpha` and `f0` are the GGX
  131. * terms the env-miss MIS fallback needs so the caller never re-derives microfacet math.
  132. */
  133. const ggxReflectionStruct = struct( {
  134. reflectDir: 'vec3', // view-space reflected ray direction
  135. sampleWeight: 'vec3', // chromatic weight (incl. Fresnel tint) to multiply gathered radiance by
  136. pdf: 'float', // VNDF direction pdf (for MIS against the env CDF)
  137. NdotV: 'float',
  138. alpha: 'float', // GGX alpha (roughness²), clamped
  139. f0: 'vec3' // Fresnel reflectance at normal incidence
  140. } );
  141. /**
  142. * Importance-samples the GGX/VNDF specular lobe for one pixel and returns the reflected
  143. * ray direction plus the Monte-Carlo weight to apply to the gathered radiance, along with
  144. * the GGX terms the SSR env-miss MIS fallback needs.
  145. *
  146. * @param {Node<vec3>} N - View-space shading normal (normalized).
  147. * @param {Node<vec3>} V - View-space surface→camera direction (normalized).
  148. * @param {Node<float>} roughness - Perceptual roughness in `[0,1]`.
  149. * @param {Node<float>} metalness - Metalness in `[0,1]`.
  150. * @param {Node<vec3>} albedo - Surface base color; tints the metal Fresnel reflectance (`f0`).
  151. * @param {Node<vec4>} Xi - Per-pixel random numbers; only `.xy` are used.
  152. * @return {ggxReflectionStruct}
  153. */
  154. export const ggxReflectionSample = Fn( ( [ N, V, roughness, metalness, albedo, Xi ] ) => {
  155. // GGX alpha (use r^2, clamp to avoid degenerate)
  156. const a = roughness.mul( roughness ).max( 0.001 ).toVar();
  157. const ax = a.toVar();
  158. const ay = a.toVar();
  159. // TBN from view-space normal
  160. const up = vec3( 0, 0, 1 );
  161. let T = cross( up, N ).toVar();
  162. T = T.normalize().toVar();
  163. If( T.length().lessThan( 1e-3 ), () => {
  164. T.assign( cross( vec3( 0, 1, 0 ), N ).normalize() );
  165. } );
  166. const B = cross( N, T ).normalize().toVar();
  167. // transform V to LOCAL frame (N = +Z)
  168. const Vlocal = vec3( dot( T, V ), dot( B, V ), dot( N, V ) ).toVar();
  169. // VNDF sample **in local frame**
  170. const Hlocal = SampleGGXVNDF( Vlocal, ax, ay, Xi.x, Xi.y ).toVar();
  171. If( Hlocal.z.lessThan( 0 ), () => {
  172. Hlocal.assign( Hlocal.negate() );
  173. } );
  174. // transform H back to VIEW space
  175. const h = normalize( T.mul( Hlocal.x ).add( B.mul( Hlocal.y ) ).add( N.mul( Hlocal.z ) ) ).toVar();
  176. // reflect with V (surface->camera) and H
  177. const viewReflectDir = reflect( V.negate(), h ).normalize().toVar();
  178. // BRDF/PDF evaluation for the sampled direction
  179. // V: surface -> camera, L: reflected direction, N: normal, H: half-vector
  180. const L = viewReflectDir.toVar();
  181. const H = normalize( V.add( L ) ).toVar(); // ~h; recomputed for robustness
  182. const NdotV = max( float( 0.0 ), dot( N, V ) ).toVar();
  183. const NdotL = max( float( 0.0 ), dot( N, L ) ).toVar();
  184. const NdotH = max( float( 0.0 ), dot( N, H ) ).toVar();
  185. const VdotH = max( float( 0.0 ), dot( V, H ) ).toVar();
  186. const f0 = mix( vec3( 0.04 ), albedo, metalness ).toVar();
  187. // Chromatic Fresnel reflectance: for metals f0 = albedo, so the reflection is tinted and desaturates
  188. // toward white at grazing angles. Kept as vec3 so colored metals reflect with the correct chroma.
  189. const fresnelWeight = F_Schlick( f0, VdotH ).toVar(); // vec3
  190. // Bounded-VNDF direction pdf — still needed for the env-miss MIS path.
  191. const pdf = GGXVNDFPdf( NdotH, NdotV, ax ).toVar();
  192. // Numerically stable importance weight: brdf·NdotL/pdf ≡ fresnel·G2·(k·NdotV + t)/(2·NdotV), which
  193. // cancels the GGX D analytically. Evaluating D explicitly is catastrophic at low roughness
  194. // (D → 3e5 at α = 0.001 wrecks f32 precision); the cancelled form stays stable down to a mirror.
  195. // (k·NdotV + t) is the bounded-cap normalization; k shrinks the cap to drop below-horizon samples.
  196. const a2 = ax.mul( ax ).toVar();
  197. const sinV2 = NdotV.mul( NdotV ).oneMinus().max( 0.0 ).toVar(); // ‖V.xy‖²
  198. const sB = float( 1.0 ).add( sqrt( sinV2 ) ).toVar();
  199. const s2B = sB.mul( sB ).toVar();
  200. const kB = a2.oneMinus().mul( s2B ).div( s2B.add( a2.mul( NdotV ).mul( NdotV ) ) ).toVar();
  201. const tB = sqrt( a2.mul( sinV2 ).add( NdotV.mul( NdotV ) ) ).toVar();
  202. const glossyWeight = fresnelWeight
  203. .mul( GeometryTerm( NdotL, NdotV, ax ) )
  204. .mul( kB.mul( NdotV ).add( tB ) )
  205. .div( float( 2.0 ).mul( NdotV ).max( 1e-4 ) ).toVar();
  206. return ggxReflectionStruct( viewReflectDir, glossyWeight, pdf, NdotV, ax, f0 );
  207. } );
  208. // Equirectangular environment sampling
  209. /**
  210. * Equirectangular direction / UV / PDF helpers and MIS weighting shared by environment sampling code.
  211. * Env-miss MIS integration lives in {@link ImportanceSampledEnvironment}.
  212. *
  213. * Equirectangular parameterization helpers used with CDF importance sampling are adapted from
  214. * [three-gpu-pathtracer](https://github.com/gkjohnson/three-gpu-pathtracer).
  215. *
  216. * @see {@link https://github.com/gkjohnson/three-gpu-pathtracer}
  217. */
  218. // uv -> direction (equirectangular)
  219. export const equirectUvToDir = Fn( ( [ uvIn ] ) => {
  220. const phi = uvIn.x.mul( Math.PI * 2 ).sub( Math.PI );
  221. const lat = uvIn.y.sub( 0.5 ).mul( Math.PI );
  222. const cosLat = cos( lat );
  223. return normalize( vec3(
  224. cosLat.mul( cos( phi ) ),
  225. sin( lat ),
  226. cosLat.mul( sin( phi ) )
  227. ) );
  228. } ).setLayout( {
  229. name: 'equirectUvToDir',
  230. type: 'vec3',
  231. inputs: [ { name: 'uv', type: 'vec2' } ]
  232. } );
  233. // Solid-angle PDF of a direction under equirectangular parameterization.
  234. export const equirectDirPdf = Fn( ( [ direction ] ) => {
  235. const uvDir = equirectUV( direction );
  236. const sinTheta = sin( uvDir.y.mul( Math.PI ) );
  237. return sinTheta.abs().lessThan( float( 1e-6 ) ).select(
  238. float( 0 ),
  239. float( 1 ).div( float( 2 * Math.PI * Math.PI ).mul( sinTheta ) )
  240. );
  241. } ).setLayout( {
  242. name: 'equirectDirPdf',
  243. type: 'float',
  244. inputs: [ { name: 'direction', type: 'vec3' } ]
  245. } );
  246. /**
  247. * MIS power heuristic with β = 2: `pdfA² / (pdfA² + pdfB²)`.
  248. * Weights the contribution of the strategy that produced `pdfA` against the other strategy.
  249. *
  250. * @see Eric Veach, *Optimally Combining Sampling Techniques for Monte Carlo Rendering*
  251. * @tsl
  252. */
  253. export const misPowerHeuristic = Fn( ( [ pdfA, pdfB ] ) => {
  254. const pdfASq = pdfA.mul( pdfA );
  255. const pdfBSq = pdfB.mul( pdfB );
  256. return pdfASq.div( pdfASq.add( pdfBSq ) );
  257. } ).setLayout( {
  258. name: 'misPowerHeuristic',
  259. type: 'float',
  260. inputs: [
  261. { name: 'pdfA', type: 'float' },
  262. { name: 'pdfB', type: 'float' }
  263. ]
  264. } );
粤ICP备19079148号