@zephyr3d/scene 0.9.27 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (502) hide show
  1. package/README.md +60 -27
  2. package/dist/animation/animation_bank.js +634 -0
  3. package/dist/animation/animation_bank.js.map +1 -0
  4. package/dist/animation/animation_bank_gpu.js +256 -0
  5. package/dist/animation/animation_bank_gpu.js.map +1 -0
  6. package/dist/animation/animationset.js +54 -0
  7. package/dist/animation/animationset.js.map +1 -1
  8. package/dist/animation/cloth/gpu_cloth_component.js +406 -0
  9. package/dist/animation/cloth/gpu_cloth_component.js.map +1 -0
  10. package/dist/animation/cloth/gpu_cloth_system.js +332 -202
  11. package/dist/animation/cloth/gpu_cloth_system.js.map +1 -1
  12. package/dist/animation/gpu_animation_driver.js +289 -0
  13. package/dist/animation/gpu_animation_driver.js.map +1 -0
  14. package/dist/animation/joint_dynamics/collision.js +112 -1
  15. package/dist/animation/joint_dynamics/collision.js.map +1 -1
  16. package/dist/animation/joint_dynamics/controller.js +6 -0
  17. package/dist/animation/joint_dynamics/controller.js.map +1 -1
  18. package/dist/animation/joint_dynamics/joint_dynamics_system.js +11 -0
  19. package/dist/animation/joint_dynamics/joint_dynamics_system.js.map +1 -1
  20. package/dist/animation/joint_dynamics/solver.js +50 -12
  21. package/dist/animation/joint_dynamics/solver.js.map +1 -1
  22. package/dist/animation/joint_dynamics/types.js.map +1 -1
  23. package/dist/animation/skeleton.js +279 -55
  24. package/dist/animation/skeleton.js.map +1 -1
  25. package/dist/animation/skin_palette_atlas.js +252 -0
  26. package/dist/animation/skin_palette_atlas.js.map +1 -0
  27. package/dist/animation/spring/multi_chain_spring_system.js +298 -38
  28. package/dist/animation/spring/multi_chain_spring_system.js.map +1 -1
  29. package/dist/animation/spring/spring_chain.js +2 -0
  30. package/dist/animation/spring/spring_chain.js.map +1 -1
  31. package/dist/animation/spring/spring_collider.js +159 -21
  32. package/dist/animation/spring/spring_collider.js.map +1 -1
  33. package/dist/animation/spring/spring_particle.js +1 -0
  34. package/dist/animation/spring/spring_particle.js.map +1 -1
  35. package/dist/animation/spring/spring_solver.js +173 -0
  36. package/dist/animation/spring/spring_solver.js.map +1 -0
  37. package/dist/animation/spring/spring_system.js +249 -24
  38. package/dist/animation/spring/spring_system.js.map +1 -1
  39. package/dist/animation/spring_modifier.js +32 -1
  40. package/dist/animation/spring_modifier.js.map +1 -1
  41. package/dist/app/app.js +26 -0
  42. package/dist/app/app.js.map +1 -1
  43. package/dist/app/engine.js +86 -15
  44. package/dist/app/engine.js.map +1 -1
  45. package/dist/app/gamepad.js +191 -0
  46. package/dist/app/gamepad.js.map +1 -0
  47. package/dist/app/inputactions.js +271 -0
  48. package/dist/app/inputactions.js.map +1 -0
  49. package/dist/app/inputmgr.js +331 -22
  50. package/dist/app/inputmgr.js.map +1 -1
  51. package/dist/app/loading.js +138 -0
  52. package/dist/app/loading.js.map +1 -0
  53. package/dist/app/pipelinecache.js +209 -0
  54. package/dist/app/pipelinecache.js.map +1 -0
  55. package/dist/app/runtimescript.js +25 -0
  56. package/dist/app/runtimescript.js.map +1 -1
  57. package/dist/app/scriptingsystem.js +54 -0
  58. package/dist/app/scriptingsystem.js.map +1 -1
  59. package/dist/app/scriptregistry.js +7 -2
  60. package/dist/app/scriptregistry.js.map +1 -1
  61. package/dist/asset/asset_manifest.js +59 -0
  62. package/dist/asset/asset_manifest.js.map +1 -0
  63. package/dist/asset/asset_preload.js +258 -0
  64. package/dist/asset/asset_preload.js.map +1 -0
  65. package/dist/asset/assetmanager.js +117 -65
  66. package/dist/asset/assetmanager.js.map +1 -1
  67. package/dist/asset/loaders/ktx2/basis/LICENSE +201 -0
  68. package/dist/asset/loaders/ktx2/basis/basis_transcoder.js +2 -0
  69. package/dist/asset/loaders/ktx2/basis/basis_transcoder.wasm +0 -0
  70. package/dist/asset/loaders/ktx2/basis_location.js +17 -0
  71. package/dist/asset/loaders/ktx2/basis_location.js.map +1 -0
  72. package/dist/asset/loaders/ktx2/ktx2_loader.js +88 -0
  73. package/dist/asset/loaders/ktx2/ktx2_loader.js.map +1 -0
  74. package/dist/asset/loaders/ktx2/ktx2_targets.js +118 -0
  75. package/dist/asset/loaders/ktx2/ktx2_targets.js.map +1 -0
  76. package/dist/asset/loaders/ktx2/transcoder_pool.js +259 -0
  77. package/dist/asset/loaders/ktx2/transcoder_pool.js.map +1 -0
  78. package/dist/asset/mesh_lod_builder.js +104 -0
  79. package/dist/asset/mesh_lod_builder.js.map +1 -0
  80. package/dist/asset/mesh_settings.js +111 -0
  81. package/dist/asset/mesh_settings.js.map +1 -0
  82. package/dist/asset/meshopt_decoder.js +18 -0
  83. package/dist/asset/meshopt_decoder.js.map +1 -0
  84. package/dist/asset/meshopt_simplifier.js +26 -0
  85. package/dist/asset/meshopt_simplifier.js.map +1 -0
  86. package/dist/asset/model.js +171 -1
  87. package/dist/asset/model.js.map +1 -1
  88. package/dist/asset/texture_settings.js +231 -0
  89. package/dist/asset/texture_settings.js.map +1 -0
  90. package/dist/asset/zmsh_binary.js +131 -0
  91. package/dist/asset/zmsh_binary.js.map +1 -0
  92. package/dist/asset/zsdf_binary.js +111 -0
  93. package/dist/asset/zsdf_binary.js.map +1 -0
  94. package/dist/blitter/bilateralblur.js +1 -2
  95. package/dist/blitter/bilateralblur.js.map +1 -1
  96. package/dist/blitter/gaussianblur.js +1 -6
  97. package/dist/blitter/gaussianblur.js.map +1 -1
  98. package/dist/camera/base.js +31 -10
  99. package/dist/camera/base.js.map +1 -1
  100. package/dist/camera/camera.js +104 -178
  101. package/dist/camera/camera.js.map +1 -1
  102. package/dist/camera/follow.js +342 -0
  103. package/dist/camera/follow.js.map +1 -0
  104. package/dist/camera/fps.js +9 -7
  105. package/dist/camera/fps.js.map +1 -1
  106. package/dist/camera/orbit.js +46 -19
  107. package/dist/camera/orbit.js.map +1 -1
  108. package/dist/index.d.ts +25436 -15990
  109. package/dist/index.js +65 -11
  110. package/dist/index.js.map +1 -1
  111. package/dist/material/blinn.js +3 -0
  112. package/dist/material/blinn.js.map +1 -1
  113. package/dist/material/eye.js +1 -1
  114. package/dist/material/eye.js.map +1 -1
  115. package/dist/material/hair.js +2 -3
  116. package/dist/material/hair.js.map +1 -1
  117. package/dist/material/lambert.js +4 -1
  118. package/dist/material/lambert.js.map +1 -1
  119. package/dist/material/material.js +66 -4
  120. package/dist/material/material.js.map +1 -1
  121. package/dist/material/meshmaterial.js +118 -45
  122. package/dist/material/meshmaterial.js.map +1 -1
  123. package/dist/material/mixins/lightmodel/blinnphong.js +1 -1
  124. package/dist/material/mixins/lightmodel/blinnphong.js.map +1 -1
  125. package/dist/material/mixins/lightmodel/lambert.js +1 -1
  126. package/dist/material/mixins/lightmodel/lambert.js.map +1 -1
  127. package/dist/material/mixins/lightmodel/pbrblueprintmixin.js +34 -61
  128. package/dist/material/mixins/lightmodel/pbrblueprintmixin.js.map +1 -1
  129. package/dist/material/mixins/lightmodel/pbrmetallicroughness.js +16 -2
  130. package/dist/material/mixins/lightmodel/pbrmetallicroughness.js.map +1 -1
  131. package/dist/material/mixins/lightmodel/pbrspecularglossness.js +2 -1
  132. package/dist/material/mixins/lightmodel/pbrspecularglossness.js.map +1 -1
  133. package/dist/material/mixins/lit.js +191 -83
  134. package/dist/material/mixins/lit.js.map +1 -1
  135. package/dist/material/mixins/pbr/common.js +31 -206
  136. package/dist/material/mixins/pbr/common.js.map +1 -1
  137. package/dist/material/mtoon.js +1 -1
  138. package/dist/material/mtoon.js.map +1 -1
  139. package/dist/material/pbrmr.js +3 -0
  140. package/dist/material/pbrmr.js.map +1 -1
  141. package/dist/material/pbrsg.js +3 -0
  142. package/dist/material/pbrsg.js.map +1 -1
  143. package/dist/material/sdf_debug.js +78 -0
  144. package/dist/material/sdf_debug.js.map +1 -0
  145. package/dist/material/shader/helper.js +333 -191
  146. package/dist/material/shader/helper.js.map +1 -1
  147. package/dist/material/shader/terrain_blend.js +204 -43
  148. package/dist/material/shader/terrain_blend.js.map +1 -1
  149. package/dist/material/skin.js +331 -297
  150. package/dist/material/skin.js.map +1 -1
  151. package/dist/material/skinprofile.js +1058 -0
  152. package/dist/material/skinprofile.js.map +1 -0
  153. package/dist/material/sss.js +353 -0
  154. package/dist/material/sss.js.map +1 -0
  155. package/dist/material/sss_kernel.js +265 -0
  156. package/dist/material/sss_kernel.js.map +1 -0
  157. package/dist/material/sssprofile.js +879 -0
  158. package/dist/material/sssprofile.js.map +1 -0
  159. package/dist/material/subsurfaceprofile.js +4 -8
  160. package/dist/material/subsurfaceprofile.js.map +1 -1
  161. package/dist/material/terrain-cm.js +464 -188
  162. package/dist/material/terrain-cm.js.map +1 -1
  163. package/dist/material/unlit.js +3 -0
  164. package/dist/material/unlit.js.map +1 -1
  165. package/dist/material/water.js +1261 -1438
  166. package/dist/material/water.js.map +1 -1
  167. package/dist/node_modules/meshoptimizer/LICENSE +21 -0
  168. package/dist/node_modules/meshoptimizer/meshopt_decoder.js +197 -0
  169. package/dist/node_modules/meshoptimizer/meshopt_decoder.js.map +1 -0
  170. package/dist/node_modules/meshoptimizer/meshopt_simplifier.js +677 -0
  171. package/dist/node_modules/meshoptimizer/meshopt_simplifier.js.map +1 -0
  172. package/dist/physics/character.js +226 -0
  173. package/dist/physics/character.js.map +1 -0
  174. package/dist/physics/collider.js +209 -0
  175. package/dist/physics/collider.js.map +1 -0
  176. package/dist/physics/events.js +79 -0
  177. package/dist/physics/events.js.map +1 -0
  178. package/dist/physics/joint.js +272 -0
  179. package/dist/physics/joint.js.map +1 -0
  180. package/dist/physics/node_physics.js +234 -0
  181. package/dist/physics/node_physics.js.map +1 -0
  182. package/dist/physics/part.js +91 -0
  183. package/dist/physics/part.js.map +1 -0
  184. package/dist/physics/rigid_body.js +379 -0
  185. package/dist/physics/rigid_body.js.map +1 -0
  186. package/dist/physics/vehicle.js +335 -0
  187. package/dist/physics/vehicle.js.map +1 -0
  188. package/dist/physics/world.js +25 -0
  189. package/dist/physics/world.js.map +1 -0
  190. package/dist/posteffect/bloom.js +49 -14
  191. package/dist/posteffect/bloom.js.map +1 -1
  192. package/dist/posteffect/motionblur.js +106 -106
  193. package/dist/posteffect/motionblur.js.map +1 -1
  194. package/dist/posteffect/postsss.js +572 -0
  195. package/dist/posteffect/postsss.js.map +1 -0
  196. package/dist/posteffect/sao.js +2 -6
  197. package/dist/posteffect/sao.js.map +1 -1
  198. package/dist/posteffect/skinsss.js +605 -355
  199. package/dist/posteffect/skinsss.js.map +1 -1
  200. package/dist/posteffect/ssgi.js +91 -162
  201. package/dist/posteffect/ssgi.js.map +1 -1
  202. package/dist/posteffect/ssr.js +3 -11
  203. package/dist/posteffect/ssr.js.map +1 -1
  204. package/dist/posteffect/sss.js +4 -13
  205. package/dist/posteffect/sss.js.map +1 -1
  206. package/dist/posteffect/taa.js +1 -0
  207. package/dist/posteffect/taa.js.map +1 -1
  208. package/dist/render/abuffer_oit.js +3 -2
  209. package/dist/render/abuffer_oit.js.map +1 -1
  210. package/dist/render/clipmap.js +179 -64
  211. package/dist/render/clipmap.js.map +1 -1
  212. package/dist/render/cluster_light.js +958 -224
  213. package/dist/render/cluster_light.js.map +1 -1
  214. package/dist/render/cull_visitor.js +25 -5
  215. package/dist/render/cull_visitor.js.map +1 -1
  216. package/dist/render/debug_views.js +37 -0
  217. package/dist/render/debug_views.js.map +1 -0
  218. package/dist/render/depthpass.js +5 -1
  219. package/dist/render/depthpass.js.map +1 -1
  220. package/dist/render/drawable_mixin.js +53 -6
  221. package/dist/render/drawable_mixin.js.map +1 -1
  222. package/dist/render/dualdepthpeeling_oit.js +5 -5
  223. package/dist/render/dualdepthpeeling_oit.js.map +1 -1
  224. package/dist/render/envlight.js +196 -113
  225. package/dist/render/envlight.js.map +1 -1
  226. package/dist/render/fbm_wavegenerator.js +5 -10
  227. package/dist/render/fbm_wavegenerator.js.map +1 -1
  228. package/dist/render/fft_wavegenerator.js +247 -157
  229. package/dist/render/fft_wavegenerator.js.map +1 -1
  230. package/dist/render/gerstner_wavegenerator.js +16 -38
  231. package/dist/render/gerstner_wavegenerator.js.map +1 -1
  232. package/dist/render/gi/probe_volume.js +1334 -0
  233. package/dist/render/gi/probe_volume.js.map +1 -0
  234. package/dist/render/gi/scene_gi.js +282 -0
  235. package/dist/render/gi/scene_gi.js.map +1 -0
  236. package/dist/render/gi/voxel_atlas.js +198 -0
  237. package/dist/render/gi/voxel_atlas.js.map +1 -0
  238. package/dist/render/gi/voxel_lighting.js +652 -0
  239. package/dist/render/gi/voxel_lighting.js.map +1 -0
  240. package/dist/render/gi/voxel_scene.js +642 -0
  241. package/dist/render/gi/voxel_scene.js.map +1 -0
  242. package/dist/render/gi/voxelize_pass.js +223 -0
  243. package/dist/render/gi/voxelize_pass.js.map +1 -0
  244. package/dist/render/globalbindgroup_allocator.js +3 -2
  245. package/dist/render/globalbindgroup_allocator.js.map +1 -1
  246. package/dist/render/gpu_picking.js +9 -36
  247. package/dist/render/gpu_picking.js.map +1 -1
  248. package/dist/render/instance_culling.js +499 -0
  249. package/dist/render/instance_culling.js.map +1 -0
  250. package/dist/render/lightpass.js +19 -8
  251. package/dist/render/lightpass.js.map +1 -1
  252. package/dist/render/lod.js +46 -0
  253. package/dist/render/lod.js.map +1 -0
  254. package/dist/render/primitive.js +301 -9
  255. package/dist/render/primitive.js.map +1 -1
  256. package/dist/render/primitive_readback.js +209 -0
  257. package/dist/render/primitive_readback.js.map +1 -0
  258. package/dist/render/render_queue.js +162 -11
  259. package/dist/render/render_queue.js.map +1 -1
  260. package/dist/render/renderer.js +10 -7
  261. package/dist/render/renderer.js.map +1 -1
  262. package/dist/render/rendergraph/blackboard.js +14 -1
  263. package/dist/render/rendergraph/blackboard.js.map +1 -1
  264. package/dist/render/rendergraph/executor.js +136 -0
  265. package/dist/render/rendergraph/executor.js.map +1 -1
  266. package/dist/render/rendergraph/forward_plus_builder.js +272 -55
  267. package/dist/render/rendergraph/forward_plus_builder.js.map +1 -1
  268. package/dist/render/rendergraph/frame_graph_context.js +41 -0
  269. package/dist/render/rendergraph/frame_graph_context.js.map +1 -0
  270. package/dist/render/rendergraph/scene_render_context.js +31 -2
  271. package/dist/render/rendergraph/scene_render_context.js.map +1 -1
  272. package/dist/render/rendergraph/types.js.map +1 -1
  273. package/dist/render/renderpass.js +28 -5
  274. package/dist/render/renderpass.js.map +1 -1
  275. package/dist/render/sdf/box_allocator.js +98 -0
  276. package/dist/render/sdf/box_allocator.js.map +1 -0
  277. package/dist/render/sdf/global_sdf.js +865 -0
  278. package/dist/render/sdf/global_sdf.js.map +1 -0
  279. package/dist/render/sdf/scene_sdf.js +562 -0
  280. package/dist/render/sdf/scene_sdf.js.map +1 -0
  281. package/dist/render/sdf/sdf_atlas.js +227 -0
  282. package/dist/render/sdf/sdf_atlas.js.map +1 -0
  283. package/dist/render/sdf/sdf_programs.js +42 -0
  284. package/dist/render/sdf/sdf_programs.js.map +1 -0
  285. package/dist/render/sdf/sdf_view.js +199 -0
  286. package/dist/render/sdf/sdf_view.js.map +1 -0
  287. package/dist/render/sdf/waterline.js +610 -0
  288. package/dist/render/sdf/waterline.js.map +1 -0
  289. package/dist/render/shadow_mask_pass.js +142 -25
  290. package/dist/render/shadow_mask_pass.js.map +1 -1
  291. package/dist/render/shore_wave_generator.js +1193 -0
  292. package/dist/render/shore_wave_generator.js.map +1 -0
  293. package/dist/render/sky.js +516 -86
  294. package/dist/render/sky.js.map +1 -1
  295. package/dist/render/transmission_thickness_pass.js +477 -0
  296. package/dist/render/transmission_thickness_pass.js.map +1 -0
  297. package/dist/render/underwater.js +58 -158
  298. package/dist/render/underwater.js.map +1 -1
  299. package/dist/render/virtualtexture/virtual_texture.js +1214 -0
  300. package/dist/render/virtualtexture/virtual_texture.js.map +1 -0
  301. package/dist/render/volumetric_cloud/cloud_common.js +472 -0
  302. package/dist/render/volumetric_cloud/cloud_common.js.map +1 -0
  303. package/dist/render/volumetric_cloud/cloud_noise.js +189 -0
  304. package/dist/render/volumetric_cloud/cloud_noise.js.map +1 -0
  305. package/dist/render/volumetric_cloud/debug_view.js +131 -0
  306. package/dist/render/volumetric_cloud/debug_view.js.map +1 -0
  307. package/dist/render/volumetric_cloud/resources.js +380 -0
  308. package/dist/render/volumetric_cloud/resources.js.map +1 -0
  309. package/dist/render/volumetric_cloud/sky_cache.js +380 -0
  310. package/dist/render/volumetric_cloud/sky_cache.js.map +1 -0
  311. package/dist/render/volumetric_cloud/volumetric_cloud.js +318 -0
  312. package/dist/render/volumetric_cloud/volumetric_cloud.js.map +1 -0
  313. package/dist/render/water_caustics.js +134 -319
  314. package/dist/render/water_caustics.js.map +1 -1
  315. package/dist/render/water_foam.js +627 -0
  316. package/dist/render/water_foam.js.map +1 -0
  317. package/dist/render/water_interaction.js +1795 -0
  318. package/dist/render/water_interaction.js.map +1 -0
  319. package/dist/render/weightedblended_oit.js +3 -3
  320. package/dist/render/weightedblended_oit.js.map +1 -1
  321. package/dist/scene/basesprite.js +9 -4
  322. package/dist/scene/basesprite.js.map +1 -1
  323. package/dist/scene/batchgroup.js +108 -12
  324. package/dist/scene/batchgroup.js.map +1 -1
  325. package/dist/scene/environment.js +28 -7
  326. package/dist/scene/environment.js.map +1 -1
  327. package/dist/scene/foliage.js +1020 -0
  328. package/dist/scene/foliage.js.map +1 -0
  329. package/dist/scene/foliage_buckets.js +147 -0
  330. package/dist/scene/foliage_buckets.js.map +1 -0
  331. package/dist/scene/foliage_mask.js +358 -0
  332. package/dist/scene/foliage_mask.js.map +1 -0
  333. package/dist/scene/hair.js +15 -4
  334. package/dist/scene/hair.js.map +1 -1
  335. package/dist/scene/light.js +179 -13
  336. package/dist/scene/light.js.map +1 -1
  337. package/dist/scene/mesh.js +327 -45
  338. package/dist/scene/mesh.js.map +1 -1
  339. package/dist/scene/meshdrawable.js +6 -0
  340. package/dist/scene/meshdrawable.js.map +1 -1
  341. package/dist/scene/msdftext.js +9 -3
  342. package/dist/scene/msdftext.js.map +1 -1
  343. package/dist/scene/msdftextsprite.js +9 -3
  344. package/dist/scene/msdftextsprite.js.map +1 -1
  345. package/dist/scene/particlesys.js +8 -2
  346. package/dist/scene/particlesys.js.map +1 -1
  347. package/dist/scene/physics_settings.js +157 -0
  348. package/dist/scene/physics_settings.js.map +1 -0
  349. package/dist/scene/scene.js +190 -44
  350. package/dist/scene/scene.js.map +1 -1
  351. package/dist/scene/scene_node.js +147 -6
  352. package/dist/scene/scene_node.js.map +1 -1
  353. package/dist/scene/terrain-cm/bladegrassmaterial.js +735 -0
  354. package/dist/scene/terrain-cm/bladegrassmaterial.js.map +1 -0
  355. package/dist/scene/terrain-cm/grass.js +881 -42
  356. package/dist/scene/terrain-cm/grass.js.map +1 -1
  357. package/dist/scene/terrain-cm/grass_gpu.js +929 -0
  358. package/dist/scene/terrain-cm/grass_gpu.js.map +1 -0
  359. package/dist/scene/terrain-cm/grassmaterial.js +180 -9
  360. package/dist/scene/terrain-cm/grassmaterial.js.map +1 -1
  361. package/dist/scene/terrain-cm/terrain-cm.js +465 -36
  362. package/dist/scene/terrain-cm/terrain-cm.js.map +1 -1
  363. package/dist/scene/terrain-cm/terrain_gi_albedo.js +257 -0
  364. package/dist/scene/terrain-cm/terrain_gi_albedo.js.map +1 -0
  365. package/dist/scene/terrain-cm/terrain_rvt.js +395 -0
  366. package/dist/scene/terrain-cm/terrain_rvt.js.map +1 -0
  367. package/dist/scene/update_queue.js +54 -0
  368. package/dist/scene/update_queue.js.map +1 -0
  369. package/dist/scene/water.js +633 -268
  370. package/dist/scene/water.js.map +1 -1
  371. package/dist/scene/water_surface_sampler.js +324 -0
  372. package/dist/scene/water_surface_sampler.js.map +1 -0
  373. package/dist/scene/wind.js +317 -0
  374. package/dist/scene/wind.js.map +1 -0
  375. package/dist/shaders/atmosphere.js +388 -166
  376. package/dist/shaders/atmosphere.js.map +1 -1
  377. package/dist/shaders/fog.js +100 -32
  378. package/dist/shaders/fog.js.map +1 -1
  379. package/dist/shaders/gi.js +365 -0
  380. package/dist/shaders/gi.js.map +1 -0
  381. package/dist/shaders/ltc_rect.js +202 -0
  382. package/dist/shaders/ltc_rect.js.map +1 -0
  383. package/dist/shaders/misc.js +1 -10
  384. package/dist/shaders/misc.js.map +1 -1
  385. package/dist/shaders/noise.js +5 -1
  386. package/dist/shaders/noise.js.map +1 -1
  387. package/dist/shaders/probes.js +309 -0
  388. package/dist/shaders/probes.js.map +1 -0
  389. package/dist/shaders/sdf.js +537 -0
  390. package/dist/shaders/sdf.js.map +1 -0
  391. package/dist/shaders/shadow.js +104 -70
  392. package/dist/shaders/shadow.js.map +1 -1
  393. package/dist/shaders/skin_brdf.js +243 -0
  394. package/dist/shaders/skin_brdf.js.map +1 -0
  395. package/dist/shaders/ssgi.js +63 -0
  396. package/dist/shaders/ssgi.js.map +1 -0
  397. package/dist/shaders/ssgi_reprojection.js +118 -0
  398. package/dist/shaders/ssgi_reprojection.js.map +1 -0
  399. package/dist/shaders/ssr.js +6 -159
  400. package/dist/shaders/ssr.js.map +1 -1
  401. package/dist/shaders/temporal.js +38 -27
  402. package/dist/shaders/temporal.js.map +1 -1
  403. package/dist/shaders/water.js +32 -82
  404. package/dist/shaders/water.js.map +1 -1
  405. package/dist/shaders/water_medium.js +7 -21
  406. package/dist/shaders/water_medium.js.map +1 -1
  407. package/dist/shadow/dom.js +110 -19
  408. package/dist/shadow/dom.js.map +1 -1
  409. package/dist/shadow/esm.js +3 -3
  410. package/dist/shadow/esm.js.map +1 -1
  411. package/dist/shadow/pcf_opt.js +15 -48
  412. package/dist/shadow/pcf_opt.js.map +1 -1
  413. package/dist/shadow/pcf_pd.js +15 -53
  414. package/dist/shadow/pcf_pd.js.map +1 -1
  415. package/dist/shadow/pcss.js +34 -9
  416. package/dist/shadow/pcss.js.map +1 -1
  417. package/dist/shadow/shadow_impl.js +3 -8
  418. package/dist/shadow/shadow_impl.js.map +1 -1
  419. package/dist/shadow/shadow_region.js +40 -2
  420. package/dist/shadow/shadow_region.js.map +1 -1
  421. package/dist/shadow/shadowmapper.js +93 -53
  422. package/dist/shadow/shadowmapper.js.map +1 -1
  423. package/dist/shadow/ssm.js +18 -72
  424. package/dist/shadow/ssm.js.map +1 -1
  425. package/dist/shadow/vsm.js +3 -3
  426. package/dist/shadow/vsm.js.map +1 -1
  427. package/dist/shapes/shape.js +44 -1
  428. package/dist/shapes/shape.js.map +1 -1
  429. package/dist/utility/buoyancy.js +287 -0
  430. package/dist/utility/buoyancy.js.map +1 -0
  431. package/dist/utility/physical.js +10 -1
  432. package/dist/utility/physical.js.map +1 -1
  433. package/dist/utility/pmrem.js +17 -43
  434. package/dist/utility/pmrem.js.map +1 -1
  435. package/dist/utility/sdf/bake.js +435 -0
  436. package/dist/utility/sdf/bake.js.map +1 -0
  437. package/dist/utility/sdf/bvh.js +576 -0
  438. package/dist/utility/sdf/bvh.js.map +1 -0
  439. package/dist/utility/serialization/manager.js +34 -6
  440. package/dist/utility/serialization/manager.js.map +1 -1
  441. package/dist/utility/serialization/scene/animation.js +46 -5
  442. package/dist/utility/serialization/scene/animation.js.map +1 -1
  443. package/dist/utility/serialization/scene/batch.js +11 -4
  444. package/dist/utility/serialization/scene/batch.js.map +1 -1
  445. package/dist/utility/serialization/scene/camera.js +87 -254
  446. package/dist/utility/serialization/scene/camera.js.map +1 -1
  447. package/dist/utility/serialization/scene/cloth.js +25 -0
  448. package/dist/utility/serialization/scene/cloth.js.map +1 -0
  449. package/dist/utility/serialization/scene/common.js +14 -6
  450. package/dist/utility/serialization/scene/common.js.map +1 -1
  451. package/dist/utility/serialization/scene/foliage.js +821 -0
  452. package/dist/utility/serialization/scene/foliage.js.map +1 -0
  453. package/dist/utility/serialization/scene/hair.js +11 -4
  454. package/dist/utility/serialization/scene/hair.js.map +1 -1
  455. package/dist/utility/serialization/scene/light.js +115 -7
  456. package/dist/utility/serialization/scene/light.js.map +1 -1
  457. package/dist/utility/serialization/scene/material.js +388 -220
  458. package/dist/utility/serialization/scene/material.js.map +1 -1
  459. package/dist/utility/serialization/scene/mesh.js +69 -4
  460. package/dist/utility/serialization/scene/mesh.js.map +1 -1
  461. package/dist/utility/serialization/scene/node.js +140 -5
  462. package/dist/utility/serialization/scene/node.js.map +1 -1
  463. package/dist/utility/serialization/scene/node_physics.js +1318 -0
  464. package/dist/utility/serialization/scene/node_physics.js.map +1 -0
  465. package/dist/utility/serialization/scene/particle.js +11 -4
  466. package/dist/utility/serialization/scene/particle.js.map +1 -1
  467. package/dist/utility/serialization/scene/physics.js +120 -0
  468. package/dist/utility/serialization/scene/physics.js.map +1 -0
  469. package/dist/utility/serialization/scene/primitive.js +124 -10
  470. package/dist/utility/serialization/scene/primitive.js.map +1 -1
  471. package/dist/utility/serialization/scene/scene.js +1687 -51
  472. package/dist/utility/serialization/scene/scene.js.map +1 -1
  473. package/dist/utility/serialization/scene/sprite.js +11 -4
  474. package/dist/utility/serialization/scene/sprite.js.map +1 -1
  475. package/dist/utility/serialization/scene/terrain.js +163 -33
  476. package/dist/utility/serialization/scene/terrain.js.map +1 -1
  477. package/dist/utility/serialization/scene/text.js +11 -4
  478. package/dist/utility/serialization/scene/text.js.map +1 -1
  479. package/dist/utility/serialization/scene/water.js +1543 -264
  480. package/dist/utility/serialization/scene/water.js.map +1 -1
  481. package/dist/utility/serialization/types.js.map +1 -1
  482. package/dist/utility/shprojector.js +10 -22
  483. package/dist/utility/shprojector.js.map +1 -1
  484. package/dist/utility/textures/foam.js +170 -0
  485. package/dist/utility/textures/foam.js.map +1 -0
  486. package/dist/utility/textures/ggxlut.js +17 -43
  487. package/dist/utility/textures/ggxlut.js.map +1 -1
  488. package/dist/utility/textures/ltcdata.js +4 -4
  489. package/dist/utility/textures/ltcdata.js.map +1 -1
  490. package/dist/utility/textures/ltclut.js +16 -6
  491. package/dist/utility/textures/ltclut.js.map +1 -1
  492. package/dist/utility/unsupported.js +24 -0
  493. package/dist/utility/unsupported.js.map +1 -0
  494. package/dist/values.js +13 -1
  495. package/dist/values.js.map +1 -1
  496. package/package.json +25 -12
  497. package/src/asset/loaders/ktx2/basis/LICENSE +201 -0
  498. package/src/asset/loaders/ktx2/basis/basis_transcoder.js +2 -0
  499. package/src/asset/loaders/ktx2/basis/basis_transcoder.wasm +0 -0
  500. package/src/asset/meshoptimizer/LICENSE +21 -0
  501. package/dist/animation/joint_dynamics/convex_collider.js +0 -320
  502. package/dist/animation/joint_dynamics/convex_collider.js.map +0 -1
@@ -1,11 +1,12 @@
1
- import { Vector4, Vector3, Vector2, DEPTH_FARTHEST } from '@zephyr3d/base';
1
+ import { Vector2, Vector4, DEPTH_FARTHEST } from '@zephyr3d/base';
2
2
  import { ShaderHelper } from '../material/shader/helper.js';
3
3
  import '../material/lambert.js';
4
4
  import '../material/blinn.js';
5
5
  import '../material/unlit.js';
6
6
  import '../material/particle.js';
7
- import { SubsurfaceProfile } from '../material/subsurfaceprofile.js';
8
- import { SKIN_SSS_LDR_ENCODE_RANGE } from '../material/skin.js';
7
+ import '../material/subsurfaceprofile.js';
8
+ import { SSSProfile } from '../material/sssprofile.js';
9
+ import '../material/skin.js';
9
10
  import '../material/eye.js';
10
11
  import '@zephyr3d/device';
11
12
  import '../values.js';
@@ -21,289 +22,247 @@ import '../material/pbrblueprint.js';
21
22
  import '../material/sprite.js';
22
23
  import '../material/sprite_std.js';
23
24
  import '../utility/blueprint/material/texture.js';
24
- import { burleyDiffusionWeight } from '../shaders/diffusion.js';
25
+ import { hash21 } from '../shaders/noise.js';
25
26
  import { linearToGamma } from '../shaders/misc.js';
26
27
  import { fetchSampler } from '../utility/misc.js';
27
28
  import { AbstractPostEffect, PostEffectLayer } from './posteffect.js';
28
29
 
29
- /** Taps per side of the separable kernel. The full kernel is `2 * TAPS + 1` wide. */ const SKIN_SSS_TAPS = 8;
30
- /**
31
- * Stylized screen-space skin scattering pass.
32
- *
33
- * @remarks
34
- * {@link SkinMaterial} writes the diffusible part of its lit color - the stylized diffuse ramp
35
- * plus back-lit transmission, without specular - into a side buffer. This pass diffuses that
36
- * buffer with a channel-dependent kernel and swaps it back in:
37
- *
38
- * ```
39
- * result = base + (diffused - original) * scatterTint * strength + diffused * glow
40
- * ```
41
- *
42
- * Because the term added back replaces exactly the term subtracted, the redistribution is energy
43
- * conserving: light that appears on the dark side of the terminator is light that left the lit
44
- * side, rather than light invented on top of a finished image. With `scatterTint` white and
45
- * `glow` zero the total radiance is preserved to within the kernel's normalization error.
46
- *
47
- * Two things follow from that formulation and are worth knowing:
48
- *
49
- * - Nothing happens where the diffusion changes nothing, so a disabled or zero-radius pass is a
50
- * true no-op, and quantization from an 8-bit side buffer largely cancels between the subtracted
51
- * and added terms instead of banding across the whole face.
52
- * - `scatterTint` colors only the light that actually moved. Tinting the whole diffuse - what the
53
- * material used to do with `scatterColor` - is what made the old look read as red haze.
54
- *
55
- * Stylization survives in two places. The material owns the direct lighting ramp (wrap, softness,
56
- * shadow tint, brightening) and this pass diffuses whatever ramp it produced; and {@link profile}
57
- * selects the channel radii, so the `wax` and `jade` presets are as reachable as `skin`.
58
- *
59
- * This is intentionally separate from the profile-based {@link SSS} pass, which supports per-pixel
60
- * profiles via a profile-slot MRT. Here a single global profile lives in uniforms.
61
- *
62
- * @public
30
+ const SKIN_SSS_DEBUG_OUTPUTS = [
31
+ 'none',
32
+ 'diffusible',
33
+ 'diffuseAmount',
34
+ 'profileId',
35
+ 'normal',
36
+ 'diffusionDistance',
37
+ 'sampleRadius',
38
+ 'weight',
39
+ 'acceptance',
40
+ 'centerWeight',
41
+ 'diffused',
42
+ 'thickness'
43
+ ];
44
+ /**
45
+ * Radial-disc sample count, matching UE5's `BURLEY_NUM_SAMPLES`.
46
+ *
47
+ * @remarks
48
+ * UE5 drives the count from a variance history and resolves the remainder
49
+ * temporally. Without that history every frame stands on its own, so the full
50
+ * count is used throughout.
51
+ */ const DEFAULT_SAMPLE_COUNT = 64;
52
+ /**
53
+ * Screen-space skin scattering — 3-pass pipeline aligned with UE5.
54
+ *
55
+ * @remarks
56
+ * Pass 1 (Burley): Radial-disc Burley diffusion with Halton + inverse-CDF
57
+ * importance sampling, 3D bilateral distance, per-channel kernel.
58
+ * Pass 2 (BVar): Luminance variance + depth gradient → transmission estimate.
59
+ * Pass 3 (Recombine): Specular/diffuse separation via SceneColor.a, then the
60
+ * diffused half summed back with the specular remainder.
61
+ *
62
+ * The pass exposes no scattering parameters of its own — every one of them
63
+ * belongs to the {@link SSSProfile} the material points at, as in UE5.
64
+ *
65
+ * WebGPU only.
66
+ *
67
+ * @public
63
68
  */ class SkinSSS extends AbstractPostEffect {
64
- static _blurProgram = null;
65
- static _combineProgram = null;
66
- _blurBindGroupH;
67
- _blurBindGroupV;
68
- _combineBindGroup;
69
+ static _burleyProgram = null;
70
+ static _bvarProgram = null;
71
+ static _recombineProgram = null;
72
+ _burleyBindGroup;
73
+ _bvarBindGroup;
74
+ _recombineBindGroup;
69
75
  _profile;
70
- _strength;
71
- _glow;
72
- _opacity;
73
- _sampleStep;
74
- _depthScale;
75
- _colorBoost;
76
- _scatterRadius;
77
- _smoothness;
78
- _scatterTint;
79
- _channelRadius;
80
- _channelFalloff;
81
- _blurDirection;
82
- _radiusParams;
76
+ _debugOutput;
77
+ _debugExposure;
78
+ _sampleCount;
79
+ _projScale;
80
+ _profileParams;
83
81
  _targetSize;
84
82
  _cameraNearFar;
85
83
  constructor(){
86
84
  super();
87
85
  this._layer = PostEffectLayer.opaque;
88
- this._blurBindGroupH = null;
89
- this._blurBindGroupV = null;
90
- this._combineBindGroup = null;
86
+ this._burleyBindGroup = null;
87
+ this._bvarBindGroup = null;
88
+ this._recombineBindGroup = null;
91
89
  this._profile = null;
92
- this._strength = 1;
93
- this._glow = 0;
94
- this._opacity = 0.18;
95
- this._sampleStep = 3;
96
- this._depthScale = 80;
97
- this._colorBoost = 1;
98
- this._scatterRadius = 0.02;
99
- this._smoothness = 0;
100
- this._scatterTint = new Vector4(1, 1, 1, 1);
101
- this._channelRadius = new Vector3(1, 1, 1);
102
- this._channelFalloff = new Vector3(1, 1, 1);
103
- this._blurDirection = new Vector2();
104
- this._radiusParams = new Vector4();
90
+ this._debugOutput = 'none';
91
+ this._debugExposure = 1;
92
+ this._sampleCount = DEFAULT_SAMPLE_COUNT;
93
+ this._projScale = new Vector2();
94
+ this._profileParams = new Vector4();
105
95
  this._targetSize = new Vector4();
106
96
  this._cameraNearFar = new Vector2();
107
97
  }
108
- /**
109
- * Subsurface profile supplying the per-channel scatter radii and falloff.
110
- *
111
- * @remarks
112
- * Defaults to {@link SubsurfaceProfile.getDefaultSkinProfile} when unset. Only the channel
113
- * ratios of `scatterRadius` and the `falloffColor` are read; the absolute distance comes from
114
- * {@link SkinSSS.scatterRadius}, so switching profiles changes the color of the bleed without
115
- * changing how far it reaches.
116
- *
117
- * The channel ratios are what make skin read as skin - red travels several times further than
118
- * blue, which is the red-to-orange-to-yellow gradient at the terminator. They are also the main
119
- * stylization lever: the `wax` and `jade` presets are the same code path with different ratios.
98
+ /**
99
+ * Fallback profile for pixels whose id is not in the table.
100
+ *
101
+ * @remarks
102
+ * Scattering parameters normally come from the per-pixel profile id the skin
103
+ * material writes, so this only applies when that lookup misses.
104
+ *
105
+ * @public
120
106
  */ get profile() {
121
107
  return this._profile;
122
108
  }
123
109
  set profile(val) {
124
110
  this._profile = val ?? null;
125
111
  }
126
- /** Final blend strength of the conserving redistribution. 1 fully diffuses the skin diffuse. */ get strength() {
127
- return this._strength;
112
+ get sampleCount() {
113
+ return this._sampleCount;
128
114
  }
129
- set strength(val) {
130
- this._strength = Math.max(0, val ?? 0);
115
+ set sampleCount(val) {
116
+ this._sampleCount = Math.max(8, Math.min(64, Math.round(val ?? DEFAULT_SAMPLE_COUNT)));
131
117
  }
132
- /**
133
- * Additive, deliberately non-conserving bleed layered on top of the conserving result.
134
- *
135
- * @remarks
136
- * 0 (the default) keeps the pass energy conserving. Raising it adds the diffused term a second
137
- * time without subtracting anything, which is the glow-heavy look the pass produced before it
138
- * conserved energy; around 1 approximates it. Use it when the art direction wants skin to read
139
- * as lit from within rather than as a physical surface.
140
- */ get glow() {
141
- return this._glow;
118
+ /**
119
+ * Multiplier applied to whatever {@link SkinSSS.debugOutput} renders.
120
+ *
121
+ * @remarks
122
+ * Defaults to 1. Several intermediates sit in a narrow band that reads as a
123
+ * flat tone at unit exposure, so raising this is how "no variation" is told
124
+ * apart from "variation too small to see". No effect unless a debug output is
125
+ * selected.
126
+ *
127
+ * @public
128
+ */ get debugExposure() {
129
+ return this._debugExposure;
142
130
  }
143
- set glow(val) {
144
- this._glow = Math.max(0, val ?? 0);
131
+ set debugExposure(val) {
132
+ this._debugExposure = Math.max(0, val ?? 1);
145
133
  }
146
- /**
147
- * Tint applied to the redistributed light.
148
- *
149
- * @remarks
150
- * White (the default) leaves the pass energy conserving. A warm tint pushes the classic reddish
151
- * terminator further than the profile's channel radii do on their own. Because it multiplies
152
- * only the difference between the diffused and original diffuse, it cannot wash the whole
153
- * surface - only the light that moved.
154
- */ get scatterTint() {
155
- return this._scatterTint;
134
+ /**
135
+ * Intermediate quantity to render instead of the shaded result.
136
+ *
137
+ * @remarks
138
+ * Defaults to `'none'`. See {@link SkinSSSDebugOutput}.
139
+ *
140
+ * @public
141
+ */ get debugOutput() {
142
+ return this._debugOutput;
156
143
  }
157
- set scatterTint(val) {
158
- this._scatterTint.set(val);
159
- }
160
- /** Skin mask coverage threshold. Scattering fades in as the blurred mask coverage exceeds this. */ get opacity() {
161
- return this._opacity;
162
- }
163
- set opacity(val) {
164
- this._opacity = Math.max(0, Math.min(1, val ?? 0));
165
- }
166
- /**
167
- * Maximum pixel spacing between blur taps. Caps the projected scatter radius for close-ups.
168
- *
169
- * @remarks
170
- * With `SKIN_SSS_TAPS` taps per side the kernel reaches `sampleStep * SKIN_SSS_TAPS` pixels, so
171
- * this is the real limit on scatter width. The separable form makes taps cheap enough that the
172
- * default no longer has to be tight: the old single-pass 9x9 kernel capped out at 8 pixels, and
173
- * a face in close-up projects a 2 cm scatter radius far past that - the shot that needs the most
174
- * scattering used to get the least.
175
- */ get sampleStep() {
176
- return this._sampleStep;
177
- }
178
- set sampleStep(val) {
179
- this._sampleStep = Math.max(0.25, Math.min(6, val ?? 0.25));
180
- }
181
- /** World-space scatter radius. The blur width shrinks with distance to keep this constant. */ get scatterRadius() {
182
- return this._scatterRadius;
183
- }
184
- set scatterRadius(val) {
185
- this._scatterRadius = Math.max(0, val ?? 0);
186
- }
187
- /**
188
- * Skin smoothing amount ("beauty filter"). Blends the lit color toward a mask- and depth-weighted
189
- * blur of itself on skin pixels, removing pore and albedo detail while facial features stay sharp
190
- * wherever the skin mask excludes them (eyes, brows, lips). 0 disables smoothing.
191
- *
192
- * @remarks
193
- * This is separate from the diffusion and stacks on top of it. The diffusion blurs *lighting*
194
- * only; albedo detail such as pores and blemishes passes through it untouched, which is correct
195
- * but is not what a beauty filter is for.
196
- */ get smoothness() {
197
- return this._smoothness;
198
- }
199
- set smoothness(val) {
200
- this._smoothness = Math.max(0, Math.min(1, val ?? 0));
201
- }
202
- /** Depth rejection scale. The reference shader uses 80. */ get depthScale() {
203
- return this._depthScale;
204
- }
205
- set depthScale(val) {
206
- this._depthScale = Math.max(0, val ?? 0);
207
- }
208
- /** Multiplier applied to the diffused irradiance before compositing. */ get colorBoost() {
209
- return this._colorBoost;
210
- }
211
- set colorBoost(val) {
212
- this._colorBoost = Math.max(0, val ?? 0);
144
+ set debugOutput(val) {
145
+ this._debugOutput = SKIN_SSS_DEBUG_OUTPUTS.includes(val) ? val : 'none';
213
146
  }
214
147
  requireLinearDepthTexture() {
215
148
  return true;
216
149
  }
217
150
  apply(ctx, inputColorTexture, sceneDepthTexture, srgbOutput) {
218
- if (!ctx.SkinSSSTexture || this._strength <= 0) {
151
+ if (!ctx.SSSMaskTexture || !ctx.SSSProfileIdTexture || ctx.device.type !== 'webgpu') {
219
152
  this.passThrough(ctx, inputColorTexture, srgbOutput);
220
153
  return;
221
154
  }
222
155
  const device = ctx.device;
223
156
  const outputFramebuffer = device.getFramebuffer();
224
- const skinTex = ctx.SkinSSSTexture;
225
- if (!SkinSSS._blurProgram) {
226
- SkinSSS._blurProgram = this.createBlurProgram(ctx);
157
+ const maskTex = ctx.SSSMaskTexture;
158
+ const profileIdTex = ctx.SSSProfileIdTexture;
159
+ const width = inputColorTexture.width;
160
+ const height = inputColorTexture.height;
161
+ const scatterFormat = ctx.colorFormat;
162
+ const profileTable = SSSProfile.getTable(device);
163
+ if (!profileTable) {
164
+ this.passThrough(ctx, inputColorTexture, srgbOutput);
165
+ return;
227
166
  }
228
- if (!SkinSSS._combineProgram) {
229
- SkinSSS._combineProgram = this.createCombineProgram(ctx);
167
+ const fallback = this._profile ?? SSSProfile.getDefault();
168
+ // Projection of a world-space length at unit depth into UV, per axis. Two
169
+ // factors, not one: `m00` and `m11` differ by the aspect ratio, so a single
170
+ // one turns the sampling disc into an ellipse. UE5 reaches the same pair by
171
+ // building x from m00 and correcting y by `Extent.x / Extent.y`.
172
+ const projMatrix = ctx.camera.getProjectionMatrix();
173
+ this._projScale.setXY(// x: world length at unit depth, in horizontal UV
174
+ 0.5 * projMatrix.m00, // y: world length at unit depth, in vertical UV
175
+ 0.5 * projMatrix.m11);
176
+ // Row of the fallback profile, used when a pixel's id is missing.
177
+ this._profileParams.setXYZW(fallback.encodedId, 1 / SSSProfile.tableColumns, 1 / SSSProfile.tableRows, SSSProfile.tableRows);
178
+ this._targetSize.setXYZW(width, height, 1 / width, 1 / height);
179
+ this._cameraNearFar.setXY(ctx.camera.getNearPlane(), ctx.camera.getFarPlane());
180
+ if (!SkinSSS._burleyProgram) {
181
+ SkinSSS._burleyProgram = this.createBurleyProgram(ctx);
230
182
  }
231
- const blurProgram = SkinSSS._blurProgram;
232
- const combineProgram = SkinSSS._combineProgram;
233
- if (!this._blurBindGroupH) {
234
- // Two bind groups rather than one program per direction: the only thing
235
- // that differs is the step vector, and rewriting it between draws would
236
- // race the first draw's read of it.
237
- this._blurBindGroupH = device.createBindGroup(blurProgram.bindGroupLayouts[0]);
238
- this._blurBindGroupV = device.createBindGroup(blurProgram.bindGroupLayouts[0]);
183
+ if (!SkinSSS._bvarProgram) {
184
+ SkinSSS._bvarProgram = this.createBVarProgram(ctx);
239
185
  }
240
- if (!this._combineBindGroup) {
241
- this._combineBindGroup = device.createBindGroup(combineProgram.bindGroupLayouts[0]);
186
+ if (!SkinSSS._recombineProgram) {
187
+ SkinSSS._recombineProgram = this.createRecombineProgram(ctx);
242
188
  }
243
- const profile = this._profile ?? SubsurfaceProfile.getDefaultSkinProfile();
244
- const radius = profile.scatterRadius;
245
- const maxChannel = Math.max(radius.x, radius.y, radius.z, 1e-5);
246
- this._channelRadius.setXYZ(radius.x / maxChannel, radius.y / maxChannel, radius.z / maxChannel);
247
- const falloff = profile.falloffColor;
248
- this._channelFalloff.setXYZ(falloff.x, falloff.y, falloff.z);
249
- // Pixels per world unit at unit view depth. The shader divides by view depth
250
- // so the footprint stays a constant world-space radius.
251
- const projScale = 0.5 * inputColorTexture.height * ctx.camera.getProjectionMatrix().m11;
252
- this._radiusParams.setXYZW(this._scatterRadius * projScale, ctx.camera.isPerspective() ? 1 : 0, this._sampleStep, this._depthScale);
253
- this._targetSize.setXYZW(inputColorTexture.width, inputColorTexture.height, 1 / inputColorTexture.width, 1 / inputColorTexture.height);
254
- this._cameraNearFar.setXY(ctx.camera.getNearPlane(), ctx.camera.getFarPlane());
255
- const encodeScale = skinTex.format === 'rgba8unorm' ? SKIN_SSS_LDR_ENCODE_RANGE : 1;
256
- const blurH = device.pool.fetchTemporalFramebuffer(false, skinTex.width, skinTex.height, skinTex.format, null, false);
257
- const blurV = device.pool.fetchTemporalFramebuffer(false, skinTex.width, skinTex.height, skinTex.format, null, false);
258
- device.setFramebuffer(blurH);
259
- this.blur(ctx, blurProgram, this._blurBindGroupH, skinTex, sceneDepthTexture, true);
260
- device.setFramebuffer(blurV);
261
- this.blur(ctx, blurProgram, this._blurBindGroupV, blurH.getColorAttachments()[0], sceneDepthTexture, false);
189
+ const diffusedFB = device.pool.fetchTemporalFramebuffer(false, width, height, scatterFormat, null, false);
190
+ const bvarFB = device.pool.fetchTemporalFramebuffer(false, width, height, scatterFormat, null, false);
191
+ // --- Pass 1: Burley diffusion ---
192
+ device.pushDeviceStates();
193
+ try {
194
+ if (!this._burleyBindGroup) {
195
+ this._burleyBindGroup = device.createBindGroup(SkinSSS._burleyProgram.bindGroupLayouts[0]);
196
+ }
197
+ const bg = this._burleyBindGroup;
198
+ bg.setTexture('sceneTex', inputColorTexture, fetchSampler('clamp_linear'));
199
+ bg.setTexture('maskTex', maskTex, fetchSampler('clamp_linear'));
200
+ // Nearest only: the id is a table row, and an interpolated one addresses a
201
+ // profile neither neighbour has.
202
+ bg.setTexture('profileIdTex', profileIdTex, fetchSampler('clamp_nearest_nomip'));
203
+ bg.setTexture('depthTex', sceneDepthTexture, fetchSampler('clamp_nearest_nomip'));
204
+ bg.setValue('cameraNearFar', this._cameraNearFar);
205
+ bg.setValue('targetSize', this._targetSize);
206
+ bg.setValue('projScale', this._projScale);
207
+ bg.setValue('perspective', ctx.camera.isPerspective() ? 1 : 0);
208
+ bg.setValue('profileParams', this._profileParams);
209
+ bg.setTexture('profileTex', profileTable, fetchSampler('clamp_nearest_nomip'));
210
+ bg.setTexture('thicknessTex', ctx.transmissionThicknessTexture ?? ShaderHelper.getDummyTransmissionThickness(device), fetchSampler('clamp_nearest'));
211
+ bg.setValue('sampleCount', this._sampleCount);
212
+ bg.setValue('debugMode', SKIN_SSS_DEBUG_OUTPUTS.indexOf(this._debugOutput));
213
+ bg.setValue('debugExposure', this._debugExposure);
214
+ // Intermediate passes always render into a texture, so on WebGPU they flip
215
+ // unconditionally. `needFlip` cannot be used: it reports whatever target is
216
+ // bound at the time, and these values are set before the pass binds its own.
217
+ bg.setValue('flip', device.type === 'webgpu' ? 1 : 0);
218
+ device.setProgram(SkinSSS._burleyProgram);
219
+ device.setBindGroup(0, bg);
220
+ device.setFramebuffer(diffusedFB);
221
+ this.drawFullscreenQuad();
222
+ } finally{
223
+ device.popDeviceStates();
224
+ }
225
+ // --- Pass 2: BVar transmission ---
226
+ device.pushDeviceStates();
227
+ try {
228
+ if (!this._bvarBindGroup) {
229
+ this._bvarBindGroup = device.createBindGroup(SkinSSS._bvarProgram.bindGroupLayouts[0]);
230
+ }
231
+ const bg = this._bvarBindGroup;
232
+ bg.setTexture('diffusedTex', diffusedFB.getColorAttachments()[0], fetchSampler('clamp_linear'));
233
+ bg.setTexture('depthTex', sceneDepthTexture, fetchSampler('clamp_nearest_nomip'));
234
+ bg.setValue('targetSize', this._targetSize);
235
+ bg.setValue('cameraNearFar', this._cameraNearFar);
236
+ bg.setValue('flip', device.type === 'webgpu' ? 1 : 0);
237
+ device.setProgram(SkinSSS._bvarProgram);
238
+ device.setBindGroup(0, bg);
239
+ device.setFramebuffer(bvarFB);
240
+ this.drawFullscreenQuad();
241
+ } finally{
242
+ device.popDeviceStates();
243
+ }
244
+ // --- Pass 3: Recombine ---
262
245
  device.setFramebuffer(outputFramebuffer);
263
- this.combine(ctx, combineProgram, inputColorTexture, skinTex, blurV.getColorAttachments()[0], sceneDepthTexture, encodeScale, srgbOutput);
264
- device.pool.releaseFrameBuffer(blurH);
265
- device.pool.releaseFrameBuffer(blurV);
266
- }
267
- blur(ctx, program, bindGroup, source, sceneDepthTexture, horizontal) {
268
- const device = ctx.device;
269
- this._blurDirection.setXY(horizontal ? 1 : 0, horizontal ? 0 : 1);
270
- bindGroup.setTexture('skinTex', source, fetchSampler('clamp_linear'));
271
- bindGroup.setTexture('depthTex', sceneDepthTexture, fetchSampler('clamp_nearest_nomip'));
272
- bindGroup.setValue('cameraNearFar', this._cameraNearFar);
273
- bindGroup.setValue('targetSize', this._targetSize);
274
- bindGroup.setValue('radiusParams', this._radiusParams);
275
- bindGroup.setValue('channelRadius', this._channelRadius);
276
- bindGroup.setValue('channelFalloff', this._channelFalloff);
277
- bindGroup.setValue('blurDirection', this._blurDirection);
278
- bindGroup.setValue('flip', 0);
279
- device.setProgram(program);
280
- device.setBindGroup(0, bindGroup);
281
- this.drawFullscreenQuad();
282
- }
283
- combine(ctx, program, colorTexture, skinTexture, blurredTexture, sceneDepthTexture, encodeScale, srgbOutput) {
284
- const device = ctx.device;
285
- const bindGroup = this._combineBindGroup;
286
- bindGroup.setTexture('colorTex', colorTexture, fetchSampler('clamp_linear'));
287
- bindGroup.setTexture('skinTex', skinTexture, fetchSampler('clamp_linear'));
288
- bindGroup.setTexture('blurredTex', blurredTexture, fetchSampler('clamp_linear'));
289
- bindGroup.setTexture('depthTex', sceneDepthTexture, fetchSampler('clamp_nearest_nomip'));
290
- bindGroup.setValue('cameraNearFar', this._cameraNearFar);
291
- bindGroup.setValue('targetSize', this._targetSize);
292
- bindGroup.setValue('radiusParams', this._radiusParams);
293
- bindGroup.setValue('scatterTint', this._scatterTint);
294
- bindGroup.setValue('strength', this._strength);
295
- bindGroup.setValue('glow', this._glow);
296
- bindGroup.setValue('opacity', this._opacity);
297
- bindGroup.setValue('colorBoost', this._colorBoost);
298
- bindGroup.setValue('encodeScale', encodeScale);
299
- bindGroup.setValue('smoothness', this._smoothness);
300
- bindGroup.setValue('flip', this.needFlip(device) ? 1 : 0);
301
- bindGroup.setValue('srgbOut', srgbOutput ? 1 : 0);
302
- device.setProgram(program);
303
- device.setBindGroup(0, bindGroup);
246
+ if (!this._recombineBindGroup) {
247
+ this._recombineBindGroup = device.createBindGroup(SkinSSS._recombineProgram.bindGroupLayouts[0]);
248
+ }
249
+ const rbg = this._recombineBindGroup;
250
+ rbg.setTexture('colorTex', inputColorTexture, fetchSampler('clamp_linear'));
251
+ rbg.setTexture('maskTex', maskTex, fetchSampler('clamp_linear'));
252
+ rbg.setTexture('bvarTex', bvarFB.getColorAttachments()[0], fetchSampler('clamp_linear'));
253
+ rbg.setTexture('depthTex', sceneDepthTexture, fetchSampler('clamp_nearest_nomip'));
254
+ rbg.setValue('cameraNearFar', this._cameraNearFar);
255
+ rbg.setValue('targetSize', this._targetSize);
256
+ rbg.setValue('debugMode', SKIN_SSS_DEBUG_OUTPUTS.indexOf(this._debugOutput));
257
+ rbg.setValue('flip', this.needFlip(device) ? 1 : 0);
258
+ rbg.setValue('srgbOut', srgbOutput ? 1 : 0);
259
+ device.setProgram(SkinSSS._recombineProgram);
260
+ device.setBindGroup(0, rbg);
304
261
  this.drawFullscreenQuad();
262
+ device.pool.releaseFrameBuffer(diffusedFB);
263
+ device.pool.releaseFrameBuffer(bvarFB);
305
264
  }
306
- /** Shared fullscreen vertex stage for both passes. */ static fullscreenVertex(pb) {
265
+ static fullscreenVertex(pb) {
307
266
  pb.getGlobalScope().flip = pb.int().uniform(0);
308
267
  pb.getGlobalScope().$inputs.pos = pb.vec2().attrib('position');
309
268
  pb.getGlobalScope().$outputs.uv = pb.vec2();
@@ -315,109 +274,415 @@ import { AbstractPostEffect, PostEffectLayer } from './posteffect.js';
315
274
  });
316
275
  });
317
276
  }
318
- createBlurProgram(ctx) {
277
+ createBurleyProgram(ctx) {
278
+ // WebGL1 has no texture array sampling. This effect is WebGPU-only at
279
+ // runtime, but its programs are still built for WebGL by the shader
280
+ // generation tests, so the thickness debug view has to compile out there.
281
+ const hasTextureArrays = ctx.device.type !== 'webgl';
319
282
  const program = ctx.device.buildRenderProgram({
320
283
  vertex (pb) {
321
284
  SkinSSS.fullscreenVertex(pb);
322
285
  },
323
286
  fragment (pb) {
324
- this.skinTex = pb.tex2D().uniform(0);
325
287
  this.depthTex = pb.tex2D().sampleType('unfilterable-float').uniform(0);
288
+ this.sceneTex = pb.tex2D().uniform(0);
289
+ this.maskTex = pb.tex2D().uniform(0);
290
+ this.profileIdTex = pb.tex2D().uniform(0);
326
291
  this.cameraNearFar = pb.vec2().uniform(0);
327
292
  this.targetSize = pb.vec4().uniform(0);
328
- // x: world scatter radius in pixels at unit depth, y: 1 for perspective,
329
- // z: max tap spacing, w: depth rejection scale.
330
- this.radiusParams = pb.vec4().uniform(0);
331
- this.channelRadius = pb.vec3().uniform(0);
332
- this.channelFalloff = pb.vec3().uniform(0);
333
- this.blurDirection = pb.vec2().uniform(0);
293
+ this.projScale = pb.vec2().uniform(0);
294
+ this.perspective = pb.int().uniform(0);
295
+ this.profileParams = pb.vec4().uniform(0);
296
+ this.profileTex = pb.tex2D().uniform(0);
297
+ // Always declared so the cached program has one layout; a 1x1 dummy is
298
+ // bound on frames without a transmission pass. Only the debug view reads
299
+ // it — shading takes its thickness through the material, per light.
300
+ if (hasTextureArrays) {
301
+ this.thicknessTex = pb.tex2DArray().uniform(0);
302
+ }
303
+ this.sampleCount = pb.int().uniform(0);
304
+ this.debugMode = pb.int().uniform(0);
305
+ this.debugExposure = pb.float().uniform(0);
334
306
  this.$outputs.outColor = pb.vec4();
335
307
  pb.func('readDepth01', [
336
308
  pb.vec2('uv')
337
309
  ], function() {
338
310
  this.$return(ShaderHelper.sampleLinearDepth(this, this.depthTex, this.uv, 0));
339
311
  });
312
+ // UE5's spec/diff separation (SSS::Setup): SceneColor.a holds the diffuse
313
+ // luminance, so the diffusible fraction is its ratio to the total.
314
+ //
315
+ // The skin mask comes from its own channel, never SceneColor.a, which
316
+ // every opaque material writes 1 to. The alpha returned here is UE5's
317
+ // subsurface Opacity, a continuous 0..1 scattering weight.
318
+ pb.func('readDiffusible', [
319
+ pb.vec2('uv')
320
+ ], function() {
321
+ this.$l.scene = pb.textureSampleLevel(this.sceneTex, this.uv, 0);
322
+ this.$l.opacity = pb.clamp(pb.textureSampleLevel(this.maskTex, this.uv, 0).a, 0, 1);
323
+ this.$l.lum = pb.dot(this.scene.rgb, pb.vec3(0.2126, 0.7152, 0.0722));
324
+ // Below the floor the ratio tracks brightness rather than the split, so
325
+ // treat those pixels as fully diffusible - one that dark has no specular
326
+ // worth preserving.
327
+ //
328
+ // `pb.select(x, y, cond)` is `cond ? y : x`, following WGSL's builtin
329
+ // where the *false* value comes first. It reads like a ternary and is
330
+ // not one.
331
+ this.$l.diffAmt = pb.select(pb.float(1), pb.clamp(pb.div(this.scene.a, pb.max(this.lum, 1e-4)), 0, 1), pb.greaterThan(this.lum, 1e-4));
332
+ this.$return(pb.vec4(pb.mul(this.scene.rgb, this.diffAmt), this.opacity));
333
+ });
334
+ // Per-pixel profile id, from the depth prepass rather than the mask
335
+ // buffer. Keeping them apart is what lets the opacity above be
336
+ // continuous: one channel cannot carry a table row and a weight at once.
337
+ pb.func('readProfileId', [
338
+ pb.vec2('uv')
339
+ ], function() {
340
+ this.$return(pb.textureSampleLevel(this.profileIdTex, this.uv, 0).r);
341
+ });
342
+ // --- Burley diffusion, transcribed from UE5's BurleyNormalizedSSSCommon.ush ---
343
+ //
344
+ // Radii are in profile space and unbounded: the inverse CDF produces them
345
+ // directly and `burleyScale` converts them to a UV offset. There is
346
+ // deliberately no sampling disc to normalize against - one would couple
347
+ // the kernel shape to the mean free path.
348
+ // GetSearchLightDiffuseScalingFactor: s = 3.5 + 100 (A - 0.33)^4.
349
+ // This is the default of UE5's three scaling-factor variants.
350
+ pb.func('scalingFactor', [
351
+ pb.float('a')
352
+ ], function() {
353
+ this.$l.v = pb.sub(this.a, 0.33);
354
+ this.$l.v2 = pb.mul(this.v, this.v);
355
+ this.$return(pb.add(3.5, pb.mul(100, this.v2, this.v2)));
356
+ });
357
+ pb.func('scalingFactor3D', [
358
+ pb.vec3('a')
359
+ ], function() {
360
+ this.$l.v = pb.sub(this.a, pb.vec3(0.33));
361
+ this.$l.v2 = pb.mul(this.v, this.v);
362
+ this.$return(pb.add(pb.vec3(3.5), pb.mul(pb.vec3(100), this.v2, this.v2)));
363
+ });
364
+ // RadiusRootFindByApproximation: d ((2 - 2.6) xi - 2) ln(1 - xi).
365
+ pb.func('radiusRootApprox', [
366
+ pb.float('d'),
367
+ pb.float('xi')
368
+ ], function() {
369
+ this.$l.x = pb.min(this.xi, 0.9999);
370
+ this.$return(pb.mul(this.d, pb.sub(pb.mul(pb.sub(2, 2.6), this.x), 2), pb.log(pb.max(pb.sub(1, this.x), 1e-6))));
371
+ });
372
+ // GetCDFDeriv1: 0.25/d (e^{-r/d} + e^{-r/3d}), clamped as CLAMP_PDF does.
373
+ pb.func('burleyPdf', [
374
+ pb.float('r'),
375
+ pb.float('l'),
376
+ pb.float('s')
377
+ ], function() {
378
+ this.$l.d = pb.max(pb.div(this.l, this.s), 1e-6);
379
+ this.$l.e1 = pb.exp(pb.neg(pb.min(pb.div(this.r, this.d), 40)));
380
+ this.$l.e2 = pb.exp(pb.neg(pb.min(pb.div(this.r, pb.mul(this.d, 3)), 40)));
381
+ this.$return(pb.max(pb.mul(pb.div(0.25, this.d), pb.add(this.e1, this.e2)), 1e-5));
382
+ });
383
+ // GetDiffuseReflectProfileWithDiffuseMeanFreePath: r R(r), per channel.
384
+ // D = 1/s3d, R = radius/L, (e^{-R/D} + e^{-R/3D}) / (D L) / 8pi
385
+ pb.func('diffusionProfile', [
386
+ pb.vec3('l'),
387
+ pb.vec3('s3d'),
388
+ pb.float('radius')
389
+ ], function() {
390
+ this.$l.D = pb.div(pb.vec3(1), pb.max(this.s3d, pb.vec3(1e-6)));
391
+ this.$l.L = pb.max(this.l, pb.vec3(1e-6));
392
+ this.$l.R = pb.div(pb.vec3(this.radius), this.L);
393
+ this.$l.negRD = pb.div(pb.neg(this.R), this.D);
394
+ this.$l.e1 = pb.exp(pb.max(this.negRD, pb.vec3(-40)));
395
+ this.$l.e2 = pb.exp(pb.max(pb.div(this.negRD, 3), pb.vec3(-40)));
396
+ this.$return(pb.max(pb.div(pb.add(this.e1, this.e2), pb.mul(this.D, this.L, 8 * Math.PI)), pb.vec3(1e-12)));
397
+ });
398
+ // GetCDF with XI = 0, used to weight the centre sample.
399
+ pb.func('burleyCdf', [
400
+ pb.float('d'),
401
+ pb.float('x')
402
+ ], function() {
403
+ this.$l.sd = pb.max(this.d, 1e-6);
404
+ this.$return(pb.clamp(pb.sub(pb.sub(1, pb.mul(0.25, pb.exp(pb.neg(pb.min(pb.div(this.x, this.sd), 40))))), pb.mul(0.75, pb.exp(pb.neg(pb.min(pb.div(this.x, pb.mul(this.sd, 3)), 40))))), 0, 1));
405
+ });
406
+ // R2Sequence: the low-discrepancy pair UE5 samples radius and angle with.
407
+ // The index must stay an integer - offsetting it by a per-pixel fraction
408
+ // turns the sequence into a phase sweep correlated with pixel position,
409
+ // which shows up as banding.
410
+ pb.func('r2Sequence', [
411
+ pb.int('i')
412
+ ], function() {
413
+ this.$return(pb.fract(pb.mul(pb.vec2(0.7548776662466927, 0.5698402909980532), pb.float(this.i))));
414
+ });
415
+ // Fetch one parameter column of a profile row. `id` is the normalized
416
+ // per-pixel profile id; rows are addressed by it directly, as UE5 does
417
+ // with its SSProfiles table.
418
+ pb.func('readProfile', [
419
+ pb.float('id'),
420
+ pb.float('column')
421
+ ], function() {
422
+ this.$l.u = pb.mul(pb.add(this.column, 0.5), this.profileParams.y);
423
+ this.$l.v = pb.mul(pb.add(pb.mul(pb.clamp(this.id, 0, 1), 255), 0.5), this.profileParams.z);
424
+ this.$return(pb.textureSampleLevel(this.profileTex, pb.vec2(this.u, this.v), 0));
425
+ });
426
+ pb.func('readNormal', [
427
+ pb.vec2('uv')
428
+ ], function() {
429
+ this.$return(pb.normalize(pb.sub(pb.mul(pb.textureSampleLevel(this.maskTex, this.uv, 0).rgb, 2), pb.vec3(1))));
430
+ });
340
431
  pb.main(function() {
341
432
  this.$l.uv = this.$inputs.uv;
342
- this.$l.center = pb.textureSampleLevel(this.skinTex, this.uv, 0);
433
+ this.$l.center = this.readDiffusible(this.uv);
434
+ this.$l.centerId = this.readProfileId(this.uv);
343
435
  this.$l.centerDepth01 = this.readDepth01(this.uv);
344
436
  this.$outputs.outColor = this.center;
345
- // Background, or a pixel no skin material wrote: nothing to diffuse.
346
- this.$if(pb.and(pb.lessThan(this.centerDepth01, 1), pb.greaterThan(this.center.a, 1e-4)), function() {
437
+ // Two distinct tests: a pixel can carry a profile with zero opacity, or
438
+ // an opacity with no profile where the prepass and the light pass
439
+ // disagree about coverage. Neither scatters.
440
+ this.$if(pb.and(pb.lessThan(this.centerDepth01, 1), pb.and(pb.greaterThan(this.center.a, 1e-4), pb.greaterThan(this.centerId, 0.5 / 255))), function() {
347
441
  this.$l.centerDepth = pb.max(pb.mul(this.centerDepth01, this.cameraNearFar.y), 1e-4);
348
- // Per-channel radius in pixels. Red reaches several times further
349
- // than blue, which is the whole point - a shared radius is what
350
- // makes Gaussian SSS read as a flat haze.
351
- this.$l.viewScale = pb.max(pb.mix(pb.float(1), this.centerDepth, this.radiusParams.y), 1e-4);
352
- this.$l.radiusPx = pb.div(pb.mul(this.channelRadius, this.radiusParams.x), this.viewScale);
353
- // Burley shaping distance per channel; falloff tightens or widens
354
- // the profile without changing its reach.
355
- this.$l.shape = pb.vec3(pb.mix(0.72, 1.46, pb.clamp(this.channelFalloff.x, 0.05, 1)), pb.mix(0.72, 1.46, pb.clamp(this.channelFalloff.y, 0.05, 1)), pb.mix(0.72, 1.46, pb.clamp(this.channelFalloff.z, 0.05, 1)));
356
- this.$l.shapeDist = pb.max(pb.mul(this.radiusPx, this.shape), pb.vec3(1e-3));
357
- // Tap spacing follows the widest channel so the longest tail is
358
- // resolved; the narrower channels simply weight down sooner.
359
- this.$l.maxRadiusPx = pb.max(pb.max(this.radiusPx.x, this.radiusPx.y), this.radiusPx.z);
360
- this.$l.stepPx = pb.clamp(pb.div(this.maxRadiusPx, SKIN_SSS_TAPS), 0, this.radiusParams.z);
361
- this.$l.stepUV = pb.mul(this.blurDirection, this.stepPx, this.targetSize.zw);
362
- this.$l.sum = pb.vec3(0);
363
- this.$l.weightSum = pb.vec3(0);
364
- this.$l.coverage = pb.float(0);
365
- this.$l.coverageWeight = pb.float(0);
366
- this.$for(pb.int('i'), -8, SKIN_SSS_TAPS + 1, function() {
367
- this.$l.fi = pb.float(this.i);
368
- this.$l.sampleUV = pb.clamp(pb.add(this.uv, pb.mul(this.stepUV, this.fi)), pb.vec2(0), pb.vec2(1));
369
- this.$l.dist = pb.mul(pb.abs(this.fi), this.stepPx);
370
- this.$l.w = pb.vec3(burleyDiffusionWeight(this, this.dist, this.shapeDist.x), burleyDiffusionWeight(this, this.dist, this.shapeDist.y), burleyDiffusionWeight(this, this.dist, this.shapeDist.z));
442
+ this.$l.centerNormal = this.readNormal(this.uv);
443
+ this.$l.scaling = this.readProfile(this.centerId, 0);
444
+ this.$l.albedo = this.readProfile(this.centerId, 1);
445
+ this.$l.mfp = this.readProfile(this.centerId, 2);
446
+ this.$l.boundaryBleed = this.readProfile(this.centerId, 3).rgb;
447
+ this.$l.worldUnitScale = pb.max(this.scaling.x, 1e-3);
448
+ // UE5 draws the radius from one representative channel
449
+ // (SurfaceAlbedo.a / DiffuseMeanFreePath.a) and evaluates all three
450
+ // channel kernels at those radii.
451
+ this.$l.aForSampling = this.albedo.w;
452
+ this.$l.lForSampling = pb.max(this.mfp.w, 1e-6);
453
+ this.$l.S = this.scalingFactor(this.aForSampling);
454
+ this.$l.S3D = this.scalingFactor3D(this.albedo.rgb);
455
+ this.$l.dForSampling = pb.div(this.lForSampling, pb.max(this.S, 1e-6));
456
+ // CalculateBurleyScale: profile space to UV. Purely unit conversion,
457
+ // including the 1/depth perspective term - nothing here shapes the
458
+ // kernel.
459
+ // Perspective divides the world radius by the view depth so the disc
460
+ // keeps a constant world size as the subject recedes; orthographic
461
+ // has no such term and takes 1.
462
+ this.$l.viewScale = pb.max(pb.select(this.centerDepth, pb.float(1), pb.equal(this.perspective, 0)), 1e-4);
463
+ // The world unit scale is applied here and only here; the packed
464
+ // mean free path leaves it out, as UE5 does. Applying it in both
465
+ // places would make the diffusion scale with its square. UE5's
466
+ // extra cm-to-mm cast has no counterpart in this metric engine.
467
+ this.$l.burleyScale = pb.div(pb.mul(this.projScale, this.worldUnitScale), this.viewScale);
468
+ // Centre-sample reweighting: the radius within one texel and the CDF
469
+ // mass it accounts for. Sampling then covers only [cdf, 1] and the
470
+ // centre is lerped back in at the end. Averaged over the two axes,
471
+ // as UE5's CalculateCenterSampleRadiusInMM does, since they carry
472
+ // different scales.
473
+ this.$l.centerRadiusMM = pb.mul(0.5, pb.add(pb.div(this.targetSize.z, pb.max(this.burleyScale.x, 1e-9)), pb.div(this.targetSize.w, pb.max(this.burleyScale.y, 1e-9))));
474
+ this.$l.centerCdf = this.burleyCdf(this.dForSampling, this.centerRadiusMM);
475
+ // Per-channel diffusion distance, the same `d` the kernel below is
476
+ // evaluated with. UE5's scalar DiffuseMeanFreePath works there
477
+ // because its `.a` and `.rgb` are related by construction; here `.a`
478
+ // is simply the widest channel, so substituting it would flatten the
479
+ // channel ratio - and invert it, since a smaller `d` concentrates
480
+ // more CDF mass inside one texel.
481
+ this.$l.dPerChannel = pb.div(this.mfp.rgb, pb.max(this.S3D, pb.vec3(1e-6)));
482
+ this.$l.centerWeight = pb.vec3(this.burleyCdf(this.dPerChannel.x, this.centerRadiusMM), this.burleyCdf(this.dPerChannel.y, this.centerRadiusMM), this.burleyCdf(this.dPerChannel.z, this.centerRadiusMM));
483
+ // Integer sequence start, rebased per pixel so neighbours do not
484
+ // draw the identical 64 taps. It must land on an integer to keep the
485
+ // low-discrepancy spacing, as UE5's 16-bit `Rand3DPCG16` rebase does.
486
+ //
487
+ // UE5 also advances the seed per frame and leans on TAA to resolve
488
+ // the remainder. Without that history a per-frame seed would read as
489
+ // flicker, so this is held fixed and trades it for a stable error.
490
+ this.$l.seedStart = pb.int(pb.mul(hash21(this, pb.mul(this.uv, this.targetSize.xy)), 65536));
491
+ this.$l.radianceAccum = pb.vec3(0);
492
+ this.$l.weightAccum = pb.vec3(0);
493
+ this.$l.bleedAccum = pb.vec3(0);
494
+ this.$l.acceptedCount = pb.float(0);
495
+ this.$l.radiusSum = pb.float(0);
496
+ this.$for(pb.int('i'), 0, DEFAULT_SAMPLE_COUNT, function() {
497
+ this.$l.rand = this.r2Sequence(pb.add(this.seedStart, this.i));
498
+ // Restrict the radius draw to the range the centre sample does not
499
+ // already account for.
500
+ this.$l.xi = pb.add(this.centerCdf, pb.mul(this.rand.x, pb.sub(1, this.centerCdf)));
501
+ this.$l.radiusMM = pb.max(this.radiusRootApprox(this.dForSampling, this.xi), 1e-5);
502
+ this.$l.pdf = this.burleyPdf(this.radiusMM, this.lForSampling, this.S);
503
+ this.$l.theta = pb.mul(this.rand.y, 2 * Math.PI);
504
+ this.$l.uvOffset = pb.mul(pb.vec2(pb.cos(this.theta), pb.sin(this.theta)), pb.mul(this.radiusMM, this.burleyScale));
505
+ this.$l.sampleUV = pb.clamp(pb.add(this.uv, this.uvOffset), pb.vec2(0), pb.vec2(1));
506
+ this.$l.tapSample = this.readDiffusible(this.sampleUV);
371
507
  this.$l.sampleDepth01 = this.readDepth01(this.sampleUV);
372
508
  this.$l.sampleDepth = pb.max(pb.mul(this.sampleDepth01, this.cameraNearFar.y), 1e-4);
373
- this.$l.depthWeight = pb.clamp(pb.sub(1, pb.div(pb.mul(pb.abs(pb.sub(this.centerDepth, this.sampleDepth)), this.radiusParams.w), this.centerDepth)), 0, 1);
374
- this.depthWeight = pb.mul(this.depthWeight, this.depthWeight, pb.sub(3, pb.mul(this.depthWeight, 2)));
375
- this.$l.tapWeight = pb.mul(this.w, this.depthWeight);
376
- this.$l.tapSample = pb.textureSampleLevel(this.skinTex, this.sampleUV, 0);
377
- // Non-skin taps are excluded from the numerator *and* the
378
- // denominator. Leaving them in the denominator - as this pass
379
- // used to - drags the diffuse toward zero near every silhouette,
380
- // which was a mild darkening under the old additive composite
381
- // but would eat real light now that the term is subtracted back
382
- // out of the base color.
383
- this.$l.isSkin = pb.float(pb.greaterThan(this.tapSample.a, 1e-4));
384
- this.sum = pb.add(this.sum, pb.mul(this.tapSample.rgb, this.tapWeight, this.isSkin));
385
- this.weightSum = pb.add(this.weightSum, pb.mul(this.tapWeight, this.isSkin));
386
- // Coverage keeps its own accumulator: it is the skin fraction of
387
- // the neighbourhood, so it must count every tap in the
388
- // denominator, skin or not.
389
- this.coverage = pb.add(this.coverage, pb.mul(this.tapWeight.x, this.isSkin, this.tapSample.a));
390
- this.coverageWeight = pb.add(this.coverageWeight, this.tapWeight.x);
509
+ this.$l.tapNormal = this.readNormal(this.sampleUV);
510
+ this.$l.normalWeight = pb.sqrt(pb.clamp(pb.add(pb.mul(pb.dot(this.tapNormal, this.centerNormal), 0.5), 0.5), 0, 1));
511
+ // Bilateral term: the depth difference in the same millimetre
512
+ // space as the radius, so a large world unit scale does not
513
+ // over-penalize the sample. UE5 compares the two one-to-one.
514
+ this.$l.deltaDepth = pb.div(pb.sub(this.sampleDepth, this.centerDepth), this.worldUnitScale);
515
+ this.$l.radiusSampledMM = pb.sqrt(pb.add(pb.mul(this.radiusMM, this.radiusMM), pb.mul(this.deltaDepth, this.deltaDepth)));
516
+ this.$l.profile = this.diffusionProfile(this.mfp.rgb, this.S3D, this.radiusSampledMM);
517
+ this.$l.tapId = this.readProfileId(this.sampleUV);
518
+ // The tap's own opacity, UE5's Opacity semantics: 0 contributes
519
+ // nothing, 1 contributes fully, and the values between give the
520
+ // smooth transition a painted mask is for.
521
+ this.$l.tapOpacity = pb.select(pb.float(0), this.tapSample.a, pb.greaterThan(this.tapId, 0.5 / 255));
522
+ // A tap that is not skin falls back to the centre pixel rather
523
+ // than dropping out of the estimator, for two reasons.
524
+ //
525
+ // Variance: this is self-normalized, so a dropped tap leaves both
526
+ // sums. Beside a large non-skin region most of the disc lands in
527
+ // it, and since the per-pixel seed is fixed, neighbouring pixels
528
+ // survive on different taps - spatial white noise.
529
+ //
530
+ // Numerics: importance sampling is exact only for the channel the
531
+ // radii were drawn from, so `profile/pdf` is constant for red
532
+ // while blue decays to ~7e-9 in the tail. Dropping the near taps
533
+ // leaves blue's `weightAccum` on the order of the 1e-9 floor
534
+ // below, which clamps it to zero.
535
+ //
536
+ // The substitution assumes the hole scatters like the pixel under
537
+ // consideration - the same assumption the centre-weighted blend
538
+ // already makes - and costs a slightly firmer edge at the hole.
539
+ this.$l.tapColor = pb.mix(this.center.rgb, this.tapSample.rgb, this.tapOpacity);
540
+ this.$l.sampleWeight = pb.mul(pb.div(this.profile, this.pdf), this.normalWeight);
541
+ // Taps from another profile are tinted rather than dropped, so a
542
+ // face/lip boundary softens instead of seaming. The comparison is
543
+ // against the prepass id now; 0.002 is still half a step of the
544
+ // 8-bit channel it rides in.
545
+ this.$l.sameProfile = pb.float(pb.or(pb.lessThan(pb.abs(pb.sub(this.tapId, this.centerId)), 0.002), pb.lessThan(this.tapOpacity, 1e-4)));
546
+ // Only taps that landed on skin contribute a bleed factor, and
547
+ // both sides of the ratio carry the same opacity, so the mean
548
+ // stays a tint in [bleed, 1]. Taps that fell back to the centre
549
+ // are excluded: they carry the centre's own profile, so counting
550
+ // them would dilute a real boundary tint towards white.
551
+ this.bleedAccum = pb.add(this.bleedAccum, pb.mul(pb.mix(this.boundaryBleed, pb.vec3(1), this.sameProfile), this.tapOpacity));
552
+ this.acceptedCount = pb.add(this.acceptedCount, this.tapOpacity);
553
+ this.radianceAccum = pb.add(this.radianceAccum, pb.mul(this.sampleWeight, this.tapColor));
554
+ this.weightAccum = pb.add(this.weightAccum, this.sampleWeight);
555
+ this.radiusSum = pb.add(this.radiusSum, this.radiusMM);
556
+ });
557
+ // Energy normalization, matching UE5's 1/0.99995 compensation.
558
+ this.$l.diffused = pb.mul(pb.div(this.radianceAccum, pb.max(this.weightAccum, pb.vec3(1e-9))), 1 / 0.99995);
559
+ // Mean bleed over the taps that landed on skin: a tint in [bleed, 1],
560
+ // never a gain. A branch rather than `max(acceptedCount, 1)`, which
561
+ // would only be correct for an integer count - this is a sum of
562
+ // opacities, so a half-masked neighbourhood would be darkened by
563
+ // exactly its own mask on top of the fade applied below.
564
+ this.$l.bleedTint = pb.vec3(1);
565
+ this.$if(pb.greaterThan(this.acceptedCount, 1e-4), function() {
566
+ this.bleedTint = pb.div(this.bleedAccum, this.acceptedCount);
567
+ });
568
+ this.diffused = pb.mul(this.diffused, this.bleedTint);
569
+ // Blend the centre pixel back in with the CDF mass it represents.
570
+ this.diffused = pb.mix(this.diffused, this.center.rgb, this.centerWeight);
571
+ // Per channel: importance sampling is exact only for the one the
572
+ // radii were drawn from, so the three weights differ by orders of
573
+ // magnitude and a single scalar test cannot speak for all of them.
574
+ // Built as a mask rather than a vector-conditioned select, which
575
+ // has no legal GLSL form - the ternary there takes a scalar only.
576
+ this.$l.weightValid = pb.vec3(pb.float(pb.greaterThan(this.weightAccum.x, 0)), pb.float(pb.greaterThan(this.weightAccum.y, 0)), pb.float(pb.greaterThan(this.weightAccum.z, 0)));
577
+ this.diffused = pb.mix(this.center.rgb, this.diffused, this.weightValid);
578
+ // The centre's own opacity fades the whole result back towards the
579
+ // unscattered pixel. Taps were already weighted by *their* opacity,
580
+ // which controls how much the neighbourhood contributes; this
581
+ // controls how much this pixel accepts. UE5 applies the same value
582
+ // at both ends, which is what makes a painted mask read as a
583
+ // gradient in scattering strength rather than as an edge.
584
+ this.diffused = pb.mix(this.center.rgb, this.diffused, this.center.a);
585
+ // Alpha is unused downstream; Recombine reads its own mask.
586
+ this.$outputs.outColor = pb.vec4(pb.max(this.diffused, pb.vec3(0)), this.center.a);
587
+ this.$if(pb.notEqual(this.debugMode, 0), function() {
588
+ this.$l.dbg = pb.vec3(0);
589
+ this.$if(pb.equal(this.debugMode, 1), function() {
590
+ this.dbg = this.center.rgb;
591
+ });
592
+ this.$if(pb.equal(this.debugMode, 2), function() {
593
+ this.$l.lum = pb.dot(pb.textureSampleLevel(this.sceneTex, this.uv, 0).rgb, pb.vec3(0.2126, 0.7152, 0.0722));
594
+ this.dbg = pb.vec3(pb.clamp(pb.div(pb.textureSampleLevel(this.sceneTex, this.uv, 0).a, pb.max(this.lum, 1e-4)), 0, 1));
595
+ });
596
+ this.$if(pb.equal(this.debugMode, 3), function() {
597
+ this.dbg = pb.vec3(pb.mul(this.centerId, 255 / 8));
598
+ });
599
+ this.$if(pb.equal(this.debugMode, 4), function() {
600
+ this.dbg = pb.add(pb.mul(this.centerNormal, 0.5), pb.vec3(0.5));
601
+ });
602
+ this.$if(pb.equal(this.debugMode, 5), function() {
603
+ // d in millimetres of world space, so skin reads around 1.
604
+ this.dbg = pb.mul(this.dPerChannel, 1000);
605
+ });
606
+ this.$if(pb.equal(this.debugMode, 6), function() {
607
+ // Mean vertical sample radius in pixels, 32 px mapped to white.
608
+ this.dbg = pb.vec3(pb.div(pb.mul(pb.div(this.radiusSum, DEFAULT_SAMPLE_COUNT), this.burleyScale.y, this.targetSize.y), 32));
609
+ });
610
+ this.$if(pb.equal(this.debugMode, 7), function() {
611
+ this.dbg = this.weightAccum;
612
+ });
613
+ this.$if(pb.equal(this.debugMode, 8), function() {
614
+ this.dbg = pb.vec3(pb.div(this.acceptedCount, DEFAULT_SAMPLE_COUNT));
615
+ });
616
+ this.$if(pb.equal(this.debugMode, 9), function() {
617
+ this.dbg = this.centerWeight;
618
+ });
619
+ this.$if(pb.equal(this.debugMode, 10), function() {
620
+ this.dbg = this.diffused;
621
+ });
622
+ if (hasTextureArrays) {
623
+ this.$if(pb.equal(this.debugMode, 11), function() {
624
+ // Shown as optical depth (bright = thick), not the stored
625
+ // encoding. An unobstructed surface stores 0.92, close enough
626
+ // to the 1.0 of "no light wrote this" that blue calls the
627
+ // latter out separately. The minimum over layer 0's four
628
+ // channels shows whichever light sees the most material.
629
+ this.$l.th = pb.textureArraySampleLevel(this.thicknessTex, this.uv, 0, 0);
630
+ this.$l.enc = pb.min(pb.min(this.th.r, this.th.g), pb.min(this.th.b, this.th.a));
631
+ this.$if(pb.greaterThan(this.enc, 0.999), function() {
632
+ this.dbg = pb.vec3(0, 0, 1);
633
+ }).$else(function() {
634
+ this.dbg = pb.vec3(pb.sub(1, this.enc));
635
+ });
636
+ });
637
+ }
638
+ this.$outputs.outColor = pb.vec4(pb.mul(this.dbg, this.debugExposure), this.center.a);
391
639
  });
392
- this.$l.diffused = pb.div(this.sum, pb.max(this.weightSum, pb.vec3(1e-6)));
393
- this.$outputs.outColor = pb.vec4(this.diffused, pb.div(this.coverage, pb.max(this.coverageWeight, 1e-6)));
394
640
  });
395
641
  });
396
642
  }
397
643
  });
398
- program.name = '@SkinSSSBlur';
644
+ program.name = '@SkinSSSBurley';
399
645
  return program;
400
646
  }
401
- createCombineProgram(ctx) {
647
+ createBVarProgram(ctx) {
648
+ // Placeholder for UE5's `UpdateQualityVariance`, a luminance-residual history
649
+ // that drives the next frame's adaptive sample count. Without a history buffer
650
+ // there is nothing to accumulate, so this is a straight copy and the diffusion
651
+ // always runs at full sample count; the pass is the slot history would occupy.
652
+ const program = ctx.device.buildRenderProgram({
653
+ vertex (pb) {
654
+ SkinSSS.fullscreenVertex(pb);
655
+ },
656
+ fragment (pb) {
657
+ this.diffusedTex = pb.tex2D().uniform(0);
658
+ this.depthTex = pb.tex2D().sampleType('unfilterable-float').uniform(0);
659
+ this.targetSize = pb.vec4().uniform(0);
660
+ this.cameraNearFar = pb.vec2().uniform(0);
661
+ this.$outputs.outColor = pb.vec4();
662
+ pb.main(function() {
663
+ this.$l.diffused = pb.textureSampleLevel(this.diffusedTex, this.$inputs.uv, 0);
664
+ // Alpha is the subsurface opacity the diffusion wrote; it is passed
665
+ // through unchanged rather than repurposed.
666
+ this.$outputs.outColor = pb.vec4(this.diffused.rgb, this.diffused.a);
667
+ });
668
+ }
669
+ });
670
+ program.name = '@SkinSSSBVar';
671
+ return program;
672
+ }
673
+ createRecombineProgram(ctx) {
402
674
  const program = ctx.device.buildRenderProgram({
403
675
  vertex (pb) {
404
676
  SkinSSS.fullscreenVertex(pb);
405
677
  },
406
678
  fragment (pb) {
407
679
  this.colorTex = pb.tex2D().uniform(0);
408
- this.skinTex = pb.tex2D().uniform(0);
409
- this.blurredTex = pb.tex2D().uniform(0);
680
+ this.maskTex = pb.tex2D().uniform(0);
681
+ this.bvarTex = pb.tex2D().uniform(0);
410
682
  this.depthTex = pb.tex2D().sampleType('unfilterable-float').uniform(0);
411
683
  this.cameraNearFar = pb.vec2().uniform(0);
412
684
  this.targetSize = pb.vec4().uniform(0);
413
- this.radiusParams = pb.vec4().uniform(0);
414
- this.scatterTint = pb.vec4().uniform(0);
415
- this.strength = pb.float().uniform(0);
416
- this.glow = pb.float().uniform(0);
417
- this.opacity = pb.float().uniform(0);
418
- this.colorBoost = pb.float().uniform(0);
419
- this.encodeScale = pb.float().uniform(0);
420
- this.smoothness = pb.float().uniform(0);
685
+ this.debugMode = pb.int().uniform(0);
421
686
  this.srgbOut = pb.int().uniform(0);
422
687
  this.$outputs.outColor = pb.vec4();
423
688
  pb.func('readDepth01', [
@@ -429,52 +694,37 @@ import { AbstractPostEffect, PostEffectLayer } from './posteffect.js';
429
694
  this.$l.uv = this.$inputs.uv;
430
695
  this.$l.baseColor = pb.textureSampleLevel(this.colorTex, this.uv, 0);
431
696
  this.$l.result = this.baseColor.rgb;
432
- this.$l.centerSkin = pb.textureSampleLevel(this.skinTex, this.uv, 0);
433
697
  this.$l.centerDepth01 = this.readDepth01(this.uv);
434
- this.$if(pb.and(pb.lessThan(this.centerDepth01, 1), pb.greaterThan(this.centerSkin.a, 1e-4)), function() {
435
- this.$l.blurred = pb.textureSampleLevel(this.blurredTex, this.uv, 0);
436
- // Both buffers carry the same LDR encoding, so decode once here.
437
- this.$l.original = pb.mul(this.centerSkin.rgb, this.encodeScale);
438
- this.$l.diffused = pb.mul(this.blurred.rgb, this.encodeScale, this.colorBoost);
439
- this.$l.coverage = pb.smoothStep(this.opacity, pb.add(this.opacity, 0.35), this.blurred.a);
440
- // Energy conserving core: swap the original diffuse for the
441
- // diffused one. The added term is exactly the subtracted term, so
442
- // brightening the dark side of the terminator darkens the lit side
443
- // by the same amount rather than adding light to the frame.
444
- this.$l.redistributed = pb.mul(pb.sub(this.diffused, this.original), this.scatterTint.rgb, this.strength, this.coverage);
445
- this.result = pb.add(this.baseColor.rgb, this.redistributed);
446
- // Explicit, opt-in departure from conservation.
447
- this.$if(pb.greaterThan(this.glow, 0), function() {
448
- this.result = pb.add(this.result, pb.mul(this.diffused, this.scatterTint.rgb, this.glow, this.coverage));
449
- });
450
- // Beauty filter. Unlike the diffusion above this blurs the shaded
451
- // color itself, so it removes albedo detail (pores, blemishes)
452
- // that scattering physically leaves alone. Uniform branch: free
453
- // when disabled.
454
- this.$if(pb.greaterThan(this.smoothness, 0), function() {
455
- this.$l.centerDepth = pb.max(pb.mul(this.centerDepth01, this.cameraNearFar.y), 1e-4);
456
- this.$l.viewScale = pb.max(pb.mix(pb.float(1), this.centerDepth, this.radiusParams.y), 1e-4);
457
- this.$l.smoothStepPx = pb.clamp(pb.div(pb.mul(this.radiusParams.x, 0.5), this.viewScale), 0, this.radiusParams.z);
458
- this.$l.colorSum = pb.vec3(0);
459
- this.$l.colorWeight = pb.float(0);
460
- this.$for(pb.int('y'), -2, 3, function() {
461
- this.$for(pb.int('x'), -2, 3, function() {
462
- this.$l.offset = pb.mul(pb.vec2(pb.float(this.x), pb.float(this.y)), this.smoothStepPx, this.targetSize.zw);
463
- this.$l.sampleUV = pb.clamp(pb.add(this.uv, this.offset), pb.vec2(0), pb.vec2(1));
464
- // The skin mask is the edge stop, so eyes, brows and lips
465
- // (mask 0) never bleed onto the surrounding skin.
466
- this.$l.maskSample = pb.textureSampleLevel(this.skinTex, this.sampleUV, 0).a;
467
- this.$l.w = pb.float(pb.greaterThan(this.maskSample, 1e-4));
468
- this.colorSum = pb.add(this.colorSum, pb.mul(pb.textureSampleLevel(this.colorTex, this.sampleUV, 0).rgb, this.w));
469
- this.colorWeight = pb.add(this.colorWeight, this.w);
470
- });
471
- });
472
- this.$if(pb.greaterThan(this.colorWeight, 1e-4), function() {
473
- this.result = pb.mix(this.result, pb.div(this.colorSum, this.colorWeight), pb.mul(this.smoothness, this.coverage, this.centerSkin.a));
474
- });
475
- });
476
- // Precision can push the subtraction a hair below zero.
477
- this.result = pb.max(this.result, pb.vec3(0));
698
+ // Subsurface opacity. A plain gate is right here: how strongly the
699
+ // pixel scattered is already baked into the diffusion buffer, and
700
+ // weighting by the opacity a second time would count it twice.
701
+ this.$l.centerMask = pb.textureSampleLevel(this.maskTex, this.uv, 0).a;
702
+ this.$if(pb.and(pb.equal(this.debugMode, 0), pb.and(pb.lessThan(this.centerDepth01, 1), pb.greaterThan(this.centerMask, 1e-4))), function() {
703
+ // UE5 SSS::Recombine (lines 32-40): split the lit pixel into the
704
+ // diffuse part that was allowed to scatter and the specular part
705
+ // that must survive untouched, then swap the diffuse for the
706
+ // diffused version.
707
+ this.$l.lum = pb.dot(this.baseColor.rgb, pb.vec3(0.2126, 0.7152, 0.0722));
708
+ // Must match Burley's split exactly: that pass diffused
709
+ // `baseColor.rgb * diffAmt` and this one keeps the remainder, so a
710
+ // disagreement leaves the halves not adding up to the original.
711
+ this.$l.diffAmt = pb.select(pb.float(1), pb.clamp(pb.div(this.baseColor.a, pb.max(this.lum, 1e-4)), 0, 1), pb.greaterThan(this.lum, 1e-4));
712
+ this.$l.specKeep = pb.mul(this.baseColor.rgb, pb.sub(1, this.diffAmt));
713
+ // The diffusion buffer's alpha is the subsurface opacity, not a
714
+ // transmission term - nothing in this pipeline produces one here.
715
+ // UE5's transmission likewise comes from SubsurfaceProfileBxDF per
716
+ // light, and the back-lit term on SSSMaterial that stands in for
717
+ // it is already part of SceneColor.
718
+ this.$l.diffused = pb.textureSampleLevel(this.bvarTex, this.uv, 0).rgb;
719
+ // Straight sum, as UE5 does it. No blend weight or tint: how far and
720
+ // in what colour the light travels is the profile's business, and a
721
+ // second knob on top could only disagree with it.
722
+ this.result = pb.max(pb.add(this.diffused, this.specKeep), pb.vec3(0));
723
+ });
724
+ // A debug channel must reach the screen unmodified, so recombination is
725
+ // skipped and the diffusion buffer is shown as it was written.
726
+ this.$if(pb.notEqual(this.debugMode, 0), function() {
727
+ this.result = pb.textureSampleLevel(this.bvarTex, this.uv, 0).rgb;
478
728
  });
479
729
  this.$if(pb.equal(this.srgbOut, 0), function() {
480
730
  this.$outputs.outColor = pb.vec4(this.result, this.baseColor.a);
@@ -484,7 +734,7 @@ import { AbstractPostEffect, PostEffectLayer } from './posteffect.js';
484
734
  });
485
735
  }
486
736
  });
487
- program.name = '@SkinSSSCombine';
737
+ program.name = '@SkinSSSRecombine';
488
738
  return program;
489
739
  }
490
740
  }