@zephyr3d/scene 0.9.25 → 0.9.27

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 (299) hide show
  1. package/dist/animation/animationset.js +92 -30
  2. package/dist/animation/animationset.js.map +1 -1
  3. package/dist/animation/hair/gpu_hair_simulation.js +1090 -0
  4. package/dist/animation/hair/gpu_hair_simulation.js.map +1 -0
  5. package/dist/animation/joint_dynamics/controller.js +7 -5
  6. package/dist/animation/joint_dynamics/controller.js.map +1 -1
  7. package/dist/animation/joint_dynamics/convex_collider.js +320 -0
  8. package/dist/animation/joint_dynamics/convex_collider.js.map +1 -0
  9. package/dist/animation/joint_dynamics/joint_dynamics_system.js +81 -5
  10. package/dist/animation/joint_dynamics/joint_dynamics_system.js.map +1 -1
  11. package/dist/animation/morphtrack.js +3 -4
  12. package/dist/animation/morphtrack.js.map +1 -1
  13. package/dist/animation/skeleton.js +79 -25
  14. package/dist/animation/skeleton.js.map +1 -1
  15. package/dist/animation/spring/multi_chain_spring_system.js +577 -179
  16. package/dist/animation/spring/multi_chain_spring_system.js.map +1 -1
  17. package/dist/animation/spring/spring_collider.js +4 -3
  18. package/dist/animation/spring/spring_collider.js.map +1 -1
  19. package/dist/animation/spring/spring_node_pose_tracker.js +40 -0
  20. package/dist/animation/spring/spring_node_pose_tracker.js.map +1 -0
  21. package/dist/animation/spring/spring_particle.js +2 -0
  22. package/dist/animation/spring/spring_particle.js.map +1 -1
  23. package/dist/animation/spring/spring_system.js +11 -3
  24. package/dist/animation/spring/spring_system.js.map +1 -1
  25. package/dist/animation/spring_modifier.js +10 -0
  26. package/dist/animation/spring_modifier.js.map +1 -1
  27. package/dist/app/engine.js +53 -43
  28. package/dist/app/engine.js.map +1 -1
  29. package/dist/app/scriptingsystem.js +119 -6
  30. package/dist/app/scriptingsystem.js.map +1 -1
  31. package/dist/app/scriptregistry.js +3 -2
  32. package/dist/app/scriptregistry.js.map +1 -1
  33. package/dist/asset/assetmanager.js +284 -67
  34. package/dist/asset/assetmanager.js.map +1 -1
  35. package/dist/asset/loaders/zhair/zhair_format.js +288 -0
  36. package/dist/asset/loaders/zhair/zhair_format.js.map +1 -0
  37. package/dist/asset/loaders/zhair/zhair_loader.js +221 -0
  38. package/dist/asset/loaders/zhair/zhair_loader.js.map +1 -0
  39. package/dist/asset/model.js +184 -69
  40. package/dist/asset/model.js.map +1 -1
  41. package/dist/camera/camera.js +268 -44
  42. package/dist/camera/camera.js.map +1 -1
  43. package/dist/camera/perspectivecamera.js +3 -1
  44. package/dist/camera/perspectivecamera.js.map +1 -1
  45. package/dist/index.d.ts +9044 -5139
  46. package/dist/index.js +17 -6
  47. package/dist/index.js.map +1 -1
  48. package/dist/material/eye.js +932 -0
  49. package/dist/material/eye.js.map +1 -0
  50. package/dist/material/hair.js +917 -0
  51. package/dist/material/hair.js.map +1 -0
  52. package/dist/material/hairstrand.js +842 -0
  53. package/dist/material/hairstrand.js.map +1 -0
  54. package/dist/material/hairstrand_data.js +162 -0
  55. package/dist/material/hairstrand_data.js.map +1 -0
  56. package/dist/material/lambert.js +5 -2
  57. package/dist/material/lambert.js.map +1 -1
  58. package/dist/material/material.js +48 -10
  59. package/dist/material/material.js.map +1 -1
  60. package/dist/material/meshmaterial.js +120 -19
  61. package/dist/material/meshmaterial.js.map +1 -1
  62. package/dist/material/mixins/albedocolor.js +1 -1
  63. package/dist/material/mixins/albedocolor.js.map +1 -1
  64. package/dist/material/mixins/lightmodel/blinnphong.js +5 -3
  65. package/dist/material/mixins/lightmodel/blinnphong.js.map +1 -1
  66. package/dist/material/mixins/lightmodel/lambert.js +5 -4
  67. package/dist/material/mixins/lightmodel/lambert.js.map +1 -1
  68. package/dist/material/mixins/lightmodel/pbrblueprintmixin.js +3 -3
  69. package/dist/material/mixins/lightmodel/pbrblueprintmixin.js.map +1 -1
  70. package/dist/material/mixins/lightmodel/pbrmetallicroughness.js +5 -5
  71. package/dist/material/mixins/lightmodel/pbrmetallicroughness.js.map +1 -1
  72. package/dist/material/mixins/lightmodel/pbrspecularglossness.js +3 -3
  73. package/dist/material/mixins/lightmodel/pbrspecularglossness.js.map +1 -1
  74. package/dist/material/mixins/lit.js +106 -19
  75. package/dist/material/mixins/lit.js.map +1 -1
  76. package/dist/material/mixins/pbr/common.js +4 -4
  77. package/dist/material/mixins/pbr/common.js.map +1 -1
  78. package/dist/material/mixins/texture.js +48 -11
  79. package/dist/material/mixins/texture.js.map +1 -1
  80. package/dist/material/mtoon.js +6 -5
  81. package/dist/material/mtoon.js.map +1 -1
  82. package/dist/material/pbrblueprint.js +146 -28
  83. package/dist/material/pbrblueprint.js.map +1 -1
  84. package/dist/material/pbrblueprint_instance.js +501 -0
  85. package/dist/material/pbrblueprint_instance.js.map +1 -0
  86. package/dist/material/pbrmr.js +15 -16
  87. package/dist/material/pbrmr.js.map +1 -1
  88. package/dist/material/shader/helper.js +734 -23
  89. package/dist/material/shader/helper.js.map +1 -1
  90. package/dist/material/shader/terrain_blend.js +55 -0
  91. package/dist/material/shader/terrain_blend.js.map +1 -0
  92. package/dist/material/skin.js +192 -32
  93. package/dist/material/skin.js.map +1 -1
  94. package/dist/material/spriteblueprint.js +16 -1
  95. package/dist/material/spriteblueprint.js.map +1 -1
  96. package/dist/material/subsurfaceprofile.js +86 -23
  97. package/dist/material/subsurfaceprofile.js.map +1 -1
  98. package/dist/material/water.js +2059 -72
  99. package/dist/material/water.js.map +1 -1
  100. package/dist/posteffect/bloom.js +167 -7
  101. package/dist/posteffect/bloom.js.map +1 -1
  102. package/dist/posteffect/coloradjust.js +2 -1
  103. package/dist/posteffect/coloradjust.js.map +1 -1
  104. package/dist/posteffect/compositor.js.map +1 -1
  105. package/dist/posteffect/fxaa.js +2 -1
  106. package/dist/posteffect/fxaa.js.map +1 -1
  107. package/dist/posteffect/grayscale.js +3 -1
  108. package/dist/posteffect/grayscale.js.map +1 -1
  109. package/dist/posteffect/motionblur.js +664 -56
  110. package/dist/posteffect/motionblur.js.map +1 -1
  111. package/dist/posteffect/posteffect.js +12 -1
  112. package/dist/posteffect/posteffect.js.map +1 -1
  113. package/dist/posteffect/sao.js +46 -44
  114. package/dist/posteffect/sao.js.map +1 -1
  115. package/dist/posteffect/skinsss.js +368 -69
  116. package/dist/posteffect/skinsss.js.map +1 -1
  117. package/dist/posteffect/ssgi.js +16 -9
  118. package/dist/posteffect/ssgi.js.map +1 -1
  119. package/dist/posteffect/ssr.js +3 -0
  120. package/dist/posteffect/ssr.js.map +1 -1
  121. package/dist/posteffect/sss.js +121 -36
  122. package/dist/posteffect/sss.js.map +1 -1
  123. package/dist/posteffect/taa.js.map +1 -1
  124. package/dist/posteffect/tonemap.js +44 -1
  125. package/dist/posteffect/tonemap.js.map +1 -1
  126. package/dist/render/abuffer_oit.js +8 -2
  127. package/dist/render/abuffer_oit.js.map +1 -1
  128. package/dist/render/clipmap.js +156 -5
  129. package/dist/render/clipmap.js.map +1 -1
  130. package/dist/render/cull_visitor.js +20 -0
  131. package/dist/render/cull_visitor.js.map +1 -1
  132. package/dist/render/depthpass.js +36 -2
  133. package/dist/render/depthpass.js.map +1 -1
  134. package/dist/render/drawable_mixin.js +26 -1
  135. package/dist/render/drawable_mixin.js.map +1 -1
  136. package/dist/render/dualdepthpeeling_oit.js +3 -2
  137. package/dist/render/dualdepthpeeling_oit.js.map +1 -1
  138. package/dist/render/envlight.js +1 -1
  139. package/dist/render/envlight.js.map +1 -1
  140. package/dist/render/fbm_wavegenerator.js +6 -3
  141. package/dist/render/fbm_wavegenerator.js.map +1 -1
  142. package/dist/render/fft_wavegenerator.js +57 -15
  143. package/dist/render/fft_wavegenerator.js.map +1 -1
  144. package/dist/render/gerstner_wavegenerator.js +130 -20
  145. package/dist/render/gerstner_wavegenerator.js.map +1 -1
  146. package/dist/render/globalbindgroup_allocator.js +3 -0
  147. package/dist/render/globalbindgroup_allocator.js.map +1 -1
  148. package/dist/render/hzb.js +80 -22
  149. package/dist/render/hzb.js.map +1 -1
  150. package/dist/render/lightpass.js +6 -3
  151. package/dist/render/lightpass.js.map +1 -1
  152. package/dist/render/oit.js +29 -0
  153. package/dist/render/oit.js.map +1 -0
  154. package/dist/render/render_queue.js +39 -0
  155. package/dist/render/render_queue.js.map +1 -1
  156. package/dist/render/renderer.js +4 -0
  157. package/dist/render/renderer.js.map +1 -1
  158. package/dist/render/rendergraph/blackboard.js +1 -0
  159. package/dist/render/rendergraph/blackboard.js.map +1 -1
  160. package/dist/render/rendergraph/device_pool_allocator.js.map +1 -1
  161. package/dist/render/rendergraph/executor.js +2 -0
  162. package/dist/render/rendergraph/executor.js.map +1 -1
  163. package/dist/render/rendergraph/forward_plus_builder.js +379 -10
  164. package/dist/render/rendergraph/forward_plus_builder.js.map +1 -1
  165. package/dist/render/rendergraph/frame_resource_requirements.js +1 -0
  166. package/dist/render/rendergraph/frame_resource_requirements.js.map +1 -1
  167. package/dist/render/rendergraph/history_resources.js +8 -1
  168. package/dist/render/rendergraph/history_resources.js.map +1 -1
  169. package/dist/render/rendergraph/rendergraph.js.map +1 -1
  170. package/dist/render/rendergraph/texture_affinity_cache.js +1 -1
  171. package/dist/render/rendergraph/texture_affinity_cache.js.map +1 -1
  172. package/dist/render/rendergraph/types.js.map +1 -1
  173. package/dist/render/shadow_mask_pass.js +73 -8
  174. package/dist/render/shadow_mask_pass.js.map +1 -1
  175. package/dist/render/sky.js +166 -126
  176. package/dist/render/sky.js.map +1 -1
  177. package/dist/render/underwater.js +624 -0
  178. package/dist/render/underwater.js.map +1 -0
  179. package/dist/render/water_caustics.js +1380 -0
  180. package/dist/render/water_caustics.js.map +1 -0
  181. package/dist/render/weightedblended_oit.js +3 -2
  182. package/dist/render/weightedblended_oit.js.map +1 -1
  183. package/dist/scene/basesprite.js +8 -0
  184. package/dist/scene/basesprite.js.map +1 -1
  185. package/dist/scene/batchgroup.js +3 -0
  186. package/dist/scene/batchgroup.js.map +1 -1
  187. package/dist/scene/environment.js +4 -1
  188. package/dist/scene/environment.js.map +1 -1
  189. package/dist/scene/hair.js +963 -0
  190. package/dist/scene/hair.js.map +1 -0
  191. package/dist/scene/light.js +5 -1
  192. package/dist/scene/light.js.map +1 -1
  193. package/dist/scene/mesh.js +481 -47
  194. package/dist/scene/mesh.js.map +1 -1
  195. package/dist/scene/meshdrawable.js +6 -0
  196. package/dist/scene/meshdrawable.js.map +1 -1
  197. package/dist/scene/msdftext.js +3 -0
  198. package/dist/scene/msdftext.js.map +1 -1
  199. package/dist/scene/msdftextsprite.js +3 -0
  200. package/dist/scene/msdftextsprite.js.map +1 -1
  201. package/dist/scene/particlesys.js +6 -0
  202. package/dist/scene/particlesys.js.map +1 -1
  203. package/dist/scene/scene.js +3 -0
  204. package/dist/scene/scene.js.map +1 -1
  205. package/dist/scene/scene_node.js +6 -1
  206. package/dist/scene/scene_node.js.map +1 -1
  207. package/dist/scene/terrain-cm/grass.js +3 -0
  208. package/dist/scene/terrain-cm/grass.js.map +1 -1
  209. package/dist/scene/terrain-cm/grassmaterial.js +3 -0
  210. package/dist/scene/terrain-cm/grassmaterial.js.map +1 -1
  211. package/dist/scene/terrain-cm/terrain-cm.js +6 -0
  212. package/dist/scene/terrain-cm/terrain-cm.js.map +1 -1
  213. package/dist/scene/terrain-cm/texture_cache.js +309 -0
  214. package/dist/scene/terrain-cm/texture_cache.js.map +1 -0
  215. package/dist/scene/water.js +546 -4
  216. package/dist/scene/water.js.map +1 -1
  217. package/dist/shaders/atmosphere.js.map +1 -1
  218. package/dist/shaders/diffusion.js +54 -0
  219. package/dist/shaders/diffusion.js.map +1 -0
  220. package/dist/shaders/shadow.js +33 -21
  221. package/dist/shaders/shadow.js.map +1 -1
  222. package/dist/shaders/temporal.js +71 -33
  223. package/dist/shaders/temporal.js.map +1 -1
  224. package/dist/shaders/water.js +22 -19
  225. package/dist/shaders/water.js.map +1 -1
  226. package/dist/shaders/water_medium.js +56 -0
  227. package/dist/shaders/water_medium.js.map +1 -0
  228. package/dist/shadow/dom.js +546 -0
  229. package/dist/shadow/dom.js.map +1 -0
  230. package/dist/shadow/pcf_opt.js +3 -5
  231. package/dist/shadow/pcf_opt.js.map +1 -1
  232. package/dist/shadow/pcf_pd.js +1 -1
  233. package/dist/shadow/pcf_pd.js.map +1 -1
  234. package/dist/shadow/receiver_bias.js +41 -0
  235. package/dist/shadow/receiver_bias.js.map +1 -0
  236. package/dist/shadow/shader.js +20 -60
  237. package/dist/shadow/shader.js.map +1 -1
  238. package/dist/shadow/shadow_impl.js +89 -0
  239. package/dist/shadow/shadow_impl.js.map +1 -1
  240. package/dist/shadow/shadow_region.js +1 -1
  241. package/dist/shadow/shadow_region.js.map +1 -1
  242. package/dist/shadow/shadowmapper.js +150 -37
  243. package/dist/shadow/shadowmapper.js.map +1 -1
  244. package/dist/shapes/shape.js +11 -2
  245. package/dist/shapes/shape.js.map +1 -1
  246. package/dist/shapes/sphere.js +50 -9
  247. package/dist/shapes/sphere.js.map +1 -1
  248. package/dist/utility/blueprint/common/constants.js +112 -2
  249. package/dist/utility/blueprint/common/constants.js.map +1 -1
  250. package/dist/utility/blueprint/material/func.js +142 -49
  251. package/dist/utility/blueprint/material/func.js.map +1 -1
  252. package/dist/utility/blueprint/material/ir.js +490 -25
  253. package/dist/utility/blueprint/material/ir.js.map +1 -1
  254. package/dist/utility/blueprint/material/texture.js +898 -4
  255. package/dist/utility/blueprint/material/texture.js.map +1 -1
  256. package/dist/utility/serialization/manager.js +434 -101
  257. package/dist/utility/serialization/manager.js.map +1 -1
  258. package/dist/utility/serialization/scene/animation.js +401 -5
  259. package/dist/utility/serialization/scene/animation.js.map +1 -1
  260. package/dist/utility/serialization/scene/batch.js +4 -0
  261. package/dist/utility/serialization/scene/batch.js.map +1 -1
  262. package/dist/utility/serialization/scene/camera.js +186 -26
  263. package/dist/utility/serialization/scene/camera.js.map +1 -1
  264. package/dist/utility/serialization/scene/common.js +26 -5
  265. package/dist/utility/serialization/scene/common.js.map +1 -1
  266. package/dist/utility/serialization/scene/hair.js +1141 -0
  267. package/dist/utility/serialization/scene/hair.js.map +1 -0
  268. package/dist/utility/serialization/scene/light.js +84 -4
  269. package/dist/utility/serialization/scene/light.js.map +1 -1
  270. package/dist/utility/serialization/scene/material.js +1867 -166
  271. package/dist/utility/serialization/scene/material.js.map +1 -1
  272. package/dist/utility/serialization/scene/mesh.js +231 -14
  273. package/dist/utility/serialization/scene/mesh.js.map +1 -1
  274. package/dist/utility/serialization/scene/node.js +141 -7
  275. package/dist/utility/serialization/scene/node.js.map +1 -1
  276. package/dist/utility/serialization/scene/particle.js +4 -0
  277. package/dist/utility/serialization/scene/particle.js.map +1 -1
  278. package/dist/utility/serialization/scene/primitive.js +18 -0
  279. package/dist/utility/serialization/scene/primitive.js.map +1 -1
  280. package/dist/utility/serialization/scene/sprite.js +4 -0
  281. package/dist/utility/serialization/scene/sprite.js.map +1 -1
  282. package/dist/utility/serialization/scene/terrain.js +4 -0
  283. package/dist/utility/serialization/scene/terrain.js.map +1 -1
  284. package/dist/utility/serialization/scene/text.js +4 -0
  285. package/dist/utility/serialization/scene/text.js.map +1 -1
  286. package/dist/utility/serialization/scene/water.js +1105 -21
  287. package/dist/utility/serialization/scene/water.js.map +1 -1
  288. package/dist/utility/serialization/types.js.map +1 -1
  289. package/dist/utility/shprojector.js +9 -8
  290. package/dist/utility/shprojector.js.map +1 -1
  291. package/dist/values.js +49 -2
  292. package/dist/values.js.map +1 -1
  293. package/package.json +3 -3
  294. package/dist/render/rendergraph/frame_graph_context.js +0 -41
  295. package/dist/render/rendergraph/frame_graph_context.js.map +0 -1
  296. package/dist/shaders/ssgi.js +0 -63
  297. package/dist/shaders/ssgi.js.map +0 -1
  298. package/dist/shaders/ssgi_reprojection.js +0 -118
  299. package/dist/shaders/ssgi_reprojection.js.map +0 -1
@@ -0,0 +1,842 @@
1
+ import { HairMaterial } from './hair.js';
2
+ import { ShaderHelper } from './shader/helper.js';
3
+ import { Vector4 } from '@zephyr3d/base';
4
+
5
+ /**
6
+ * Hair material that expands strand control points into ribbons on the GPU.
7
+ *
8
+ * @remarks
9
+ * Where {@link HairMaterial} shades ribbon geometry that something else built,
10
+ * this material builds the ribbon itself. The vertex shader derives which strand,
11
+ * which segment and which corner it is drawing from the vertex index alone, reads
12
+ * the control points from a storage buffer, and emits a camera-facing quad. No
13
+ * position, normal, tangent or texture coordinate attribute is read.
14
+ *
15
+ * Two things fall out of that, and both are the reason to do it:
16
+ *
17
+ * - **Memory.** A CPU-built ribbon mesh for a full 69k-strand hair set costs
18
+ * around 266 MB of vertex data. The control points it is built from are about
19
+ * 20 MB, and that is all this material needs resident.
20
+ * - **Orientation.** A stored ribbon has a fixed plane, so there is a viewing
21
+ * angle at which it degenerates to an edge, and a whole coplanar group of
22
+ * strands can vanish at once. A quad expanded toward the camera never does.
23
+ *
24
+ * Lighting is inherited unchanged from {@link HairMaterial}, so the two paths
25
+ * shade identically and can be compared against each other.
26
+ *
27
+ * Control points are read in the drawing node's local space and put through its
28
+ * world matrix, so a groom follows the node it hangs off - a head bone, say -
29
+ * and several nodes can share one set of strands at different transforms.
30
+ *
31
+ * Requires storage buffers in the vertex stage, which means WebGPU.
32
+ * @public
33
+ */ class HairStrandMaterial extends HairMaterial {
34
+ /** @internal Enables per-strand distance decimation in the vertex shader. */ static FEATURE_STRAND_LOD = this.defineFeature();
35
+ /** @internal Samples a second point buffer to build motion vectors. */ static FEATURE_STRAND_MOTION = this.defineFeature();
36
+ /** @internal */ _strands;
37
+ /** @internal Control points as of the previously rendered frame. */ _prevPoints;
38
+ /** @internal Ribbon segments generated per strand. */ _segmentsPerStrand;
39
+ /** @internal Multiplier on the width stored in the strand data. */ _strandWidthScale;
40
+ /** @internal Lower bound on ribbon width, in world units. */ _minStrandWidth;
41
+ /** @internal Lower bound on ribbon width, in pixels. */ _minPixelWidth;
42
+ /** @internal Floor on the fraction of strands distance decimation keeps. */ _minStrandLODRatio;
43
+ /** @internal How far the shading normal bends across the ribbon, in [0, 1]. */ _strandRoundness;
44
+ /** @internal Strength of the root-depth ambient occlusion, in [0, 1]. */ _rootOcclusion;
45
+ /** @internal How far along a strand the root occlusion reaches, in (0, 1]. */ _rootOcclusionRange;
46
+ /** @internal Scratch vector, so applying uniforms allocates nothing. */ _strandScratch;
47
+ /** @internal Scratch vector for the LOD uniform. */ _lodScratch;
48
+ /** @internal Scratch vector for the shape uniform. */ _shapeScratch;
49
+ /**
50
+ * Creates a GPU strand material.
51
+ */ constructor(){
52
+ super();
53
+ this._strands = null;
54
+ this._segmentsPerStrand = 8;
55
+ this._strandWidthScale = 1;
56
+ this._minStrandWidth = 0;
57
+ this._minPixelWidth = 1.4;
58
+ this._minStrandLODRatio = 0.05;
59
+ this._strandRoundness = 1;
60
+ this._rootOcclusion = 0.5;
61
+ this._rootOcclusionRange = 0.6;
62
+ this._strandScratch = new Vector4();
63
+ this._lodScratch = new Vector4();
64
+ this._shapeScratch = new Vector4();
65
+ this._prevPoints = null;
66
+ this.useFeature(HairStrandMaterial.FEATURE_STRAND_LOD, false);
67
+ this.useFeature(HairStrandMaterial.FEATURE_STRAND_MOTION, false);
68
+ // A groom is dense enough that one velocity per pixel is a fair summary of
69
+ // it, so a blended groom writes motion vectors rather than inheriting the
70
+ // background's and ghosting as it swings.
71
+ this.transparentMotionVector = true;
72
+ this.transparentShadowCaster = true;
73
+ // The quad is built facing the camera, so there is no back face to cull.
74
+ this.cullMode = 'none';
75
+ // Strand geometry carries no vertex attributes at all: the frame is derived
76
+ // from the curve, so the inherited attribute paths must stay off.
77
+ this.vertexNormal = false;
78
+ this.vertexTangent = false;
79
+ // The inherited default is 'binormal', which is right for a hair card: its
80
+ // atlas runs strands along V, and V is the binormal. This material builds its
81
+ // own frame and puts the curve direction in the tangent, so the anisotropic
82
+ // lobes have to be told to look there. Left at the default they measure the
83
+ // highlight against the ribbon's side vector instead - and the side vector is
84
+ // perpendicular to the view by construction, so sin(T,H) sits near one over
85
+ // the whole groom and the lobes degenerate into a flat sheen with no band in
86
+ // it. That is the single largest difference between this reading as hair and
87
+ // reading as wet plastic, so it is set here rather than left to the caller.
88
+ this.strandDirection = 'tangent';
89
+ }
90
+ clone() {
91
+ const other = new HairStrandMaterial();
92
+ other.copyFrom(this);
93
+ return other;
94
+ }
95
+ copyFrom(other) {
96
+ super.copyFrom(other);
97
+ this.strands = other.strands;
98
+ this.segmentsPerStrand = other.segmentsPerStrand;
99
+ this.strandWidthScale = other.strandWidthScale;
100
+ this.minStrandWidth = other.minStrandWidth;
101
+ this.minPixelWidth = other.minPixelWidth;
102
+ this.strandLOD = other.strandLOD;
103
+ this.minStrandLODRatio = other.minStrandLODRatio;
104
+ this.strandRoundness = other.strandRoundness;
105
+ this.rootOcclusion = other.rootOcclusion;
106
+ this.rootOcclusionRange = other.rootOcclusionRange;
107
+ }
108
+ /** GPU-resident strand geometry. */ get strands() {
109
+ return this._strands;
110
+ }
111
+ set strands(val) {
112
+ if (val !== this._strands) {
113
+ this._strands = val;
114
+ this.uniformChanged();
115
+ }
116
+ }
117
+ /**
118
+ * Ribbon segments generated per strand.
119
+ *
120
+ * @remarks
121
+ * The primary quality/cost dial, and independent of the control point count:
122
+ * the shader resamples the curve, so the same 30-point strand can be drawn with
123
+ * 12 segments up close and 3 far away.
124
+ */ get segmentsPerStrand() {
125
+ return this._segmentsPerStrand;
126
+ }
127
+ set segmentsPerStrand(val) {
128
+ const v = val < 1 ? 1 : val | 0;
129
+ if (v !== this._segmentsPerStrand) {
130
+ this._segmentsPerStrand = v;
131
+ this.uniformChanged();
132
+ }
133
+ }
134
+ /** Multiplier applied to the width stored in the strand data. */ get strandWidthScale() {
135
+ return this._strandWidthScale;
136
+ }
137
+ set strandWidthScale(val) {
138
+ if (val !== this._strandWidthScale) {
139
+ this._strandWidthScale = val;
140
+ this.uniformChanged();
141
+ }
142
+ }
143
+ /** Lower bound on ribbon width in world units. */ get minStrandWidth() {
144
+ return this._minStrandWidth;
145
+ }
146
+ set minStrandWidth(val) {
147
+ const v = val < 0 ? 0 : val;
148
+ if (v !== this._minStrandWidth) {
149
+ this._minStrandWidth = v;
150
+ this.uniformChanged();
151
+ }
152
+ }
153
+ /**
154
+ * Lower bound on ribbon width in pixels.
155
+ *
156
+ * @remarks
157
+ * A strand narrower than a pixel rasterises to a flickering dotted line,
158
+ * because whether a given segment covers a sample point changes with subpixel
159
+ * position. Clamping the quad to about a pixel and scaling alpha by how much it
160
+ * was widened keeps total energy roughly constant, so the strand reads as
161
+ * fainter rather than as broken. Set to 0 to disable.
162
+ */ get minPixelWidth() {
163
+ return this._minPixelWidth;
164
+ }
165
+ set minPixelWidth(val) {
166
+ const v = val < 0 ? 0 : val;
167
+ if (v !== this._minPixelWidth) {
168
+ this._minPixelWidth = v;
169
+ this.uniformChanged();
170
+ }
171
+ }
172
+ /**
173
+ * Trades strand count for strand opacity with distance.
174
+ *
175
+ * @remarks
176
+ * Past the point where a strand is thinner than a pixel, {@link minPixelWidth}
177
+ * widens it and pays the widening back as alpha, so a distant groom is drawn as
178
+ * tens of thousands of ribbons each contributing a few percent of a pixel. That
179
+ * is correct on average and unstable in practice: any per-fragment stochastic
180
+ * alpha - `alphaDither`, alpha-to-coverage - is sampling a five percent event
181
+ * once per pixel per frame, and the variance reads as pixel-level crawl that a
182
+ * temporal filter's short history cannot average away.
183
+ *
184
+ * This moves the randomness from the fragment to the strand. Each strand is
185
+ * kept with a probability equal to the coverage it would have been drawn at and
186
+ * the survivors are drawn at full coverage, which puts the same quantity of hair
187
+ * on screen: `N` strands at alpha `a` and `N*a` strands at alpha 1 integrate to
188
+ * the same image. The difference is that the draw is decided once per strand
189
+ * from a fixed seed rather than once per fragment per frame, so the result is
190
+ * stable across frames, and the culled strands cost no fragments at all.
191
+ *
192
+ * Off by default: it changes which strands are drawn, so it is opted into
193
+ * rather than applied to existing grooms silently.
194
+ */ get strandLOD() {
195
+ return this.featureUsed(HairStrandMaterial.FEATURE_STRAND_LOD);
196
+ }
197
+ set strandLOD(val) {
198
+ this.useFeature(HairStrandMaterial.FEATURE_STRAND_LOD, !!val);
199
+ }
200
+ /**
201
+ * Control points as of the previously rendered frame, or null.
202
+ *
203
+ * @remarks
204
+ * Set by {@link HairNode} while a simulation is running. Without it the
205
+ * previous position is the *current* control point put through the previous
206
+ * world matrix, which carries the node's motion but none of the strand's own -
207
+ * so a groom swinging under a still head reports no motion at all, and a
208
+ * temporal filter blends a history that has nothing to do with the pixel.
209
+ * Costs four extra point fetches per vertex, hence a feature rather than an
210
+ * always-on path: a static groom does not need it.
211
+ */ get prevPoints() {
212
+ return this._prevPoints;
213
+ }
214
+ set prevPoints(buffer) {
215
+ this._prevPoints = buffer ?? null;
216
+ this.useFeature(HairStrandMaterial.FEATURE_STRAND_MOTION, !!this._prevPoints);
217
+ }
218
+ /** Whether motion vectors follow the control points. @internal */ get strandMotion() {
219
+ return this.featureUsed(HairStrandMaterial.FEATURE_STRAND_MOTION);
220
+ }
221
+ /**
222
+ * Floor on the fraction of strands {@link strandLOD} keeps.
223
+ *
224
+ * @remarks
225
+ * Decimation follows coverage, and coverage falls without bound as the groom
226
+ * recedes, so an unclamped ratio eventually culls every strand and the hair
227
+ * disappears. This is the point past which thinning stops and the remaining
228
+ * strands simply fade instead. Defaults to 0.05.
229
+ */ get minStrandLODRatio() {
230
+ return this._minStrandLODRatio;
231
+ }
232
+ set minStrandLODRatio(val) {
233
+ const v = val < 0 ? 0 : val > 1 ? 1 : val;
234
+ if (v !== this._minStrandLODRatio) {
235
+ this._minStrandLODRatio = v;
236
+ this.uniformChanged();
237
+ }
238
+ }
239
+ /**
240
+ * How far the shading normal bends across the ribbon, in [0, 1].
241
+ *
242
+ * @remarks
243
+ * The quad is a stand-in for a cylinder, and this is how much it is shaded like
244
+ * one. At 1 the normal sweeps a full half turn about the curve tangent between
245
+ * the two edges, as the visible surface of a fibre does; at 0 it stays the
246
+ * quad's own normal and every fragment across the width shades identically,
247
+ * which is the flat ribbon this material drew before.
248
+ *
249
+ * It matters more than its size suggests. A flat ribbon has one normal, so the
250
+ * specular lobes either fire across the strand's whole width or not at all, and
251
+ * a groom becomes a field of uniformly bright or uniformly dull tape. Bending
252
+ * the normal puts a narrow highlight down the length of each strand and lets the
253
+ * edges fall off, which is most of what separates strands from one another when
254
+ * they are all the same colour.
255
+ *
256
+ * Values below 1 keep the highlight but widen it, which reads as a softer,
257
+ * thicker fibre; artists after a stylised look sometimes want that.
258
+ */ get strandRoundness() {
259
+ return this._strandRoundness;
260
+ }
261
+ set strandRoundness(val) {
262
+ const v = val < 0 ? 0 : val > 1 ? 1 : val;
263
+ if (v !== this._strandRoundness) {
264
+ this._strandRoundness = v;
265
+ this.uniformChanged();
266
+ }
267
+ }
268
+ /**
269
+ * Strength of the root-depth ambient occlusion, in [0, 1].
270
+ *
271
+ * @remarks
272
+ * Environment irradiance is not attenuated by anything - a shadow map answers
273
+ * for one light, not for the sky - so without this every strand of a groom
274
+ * receives the full sky, and a dense hairstyle lights up as brightly in its
275
+ * interior as on its surface. That reads as a flat silhouette with no volume,
276
+ * and it is most obvious on pale hair, where there is little pigment to darken
277
+ * the interior instead.
278
+ *
279
+ * Real occlusion would need to know how much hair sits between a fibre and the
280
+ * sky. This approximates that by where along the strand the fragment is: roots
281
+ * grow from the scalp, buried under everything above them, while tips are on
282
+ * the outside. It is only an approximation - a tip tucked under a fringe is
283
+ * treated as exposed - but it is free, it needs no texture coordinates, which
284
+ * this material has none of, and it captures the dominant effect. Set to 0 to
285
+ * disable.
286
+ *
287
+ * Applied to ambient light and the scatter term only. Direct light keeps its
288
+ * own answer - the shadow map, or DOM's graded transmittance - and multiplying
289
+ * this on top would count the same blockage twice.
290
+ */ get rootOcclusion() {
291
+ return this._rootOcclusion;
292
+ }
293
+ set rootOcclusion(val) {
294
+ const v = val < 0 ? 0 : val > 1 ? 1 : val;
295
+ if (v !== this._rootOcclusion) {
296
+ this._rootOcclusion = v;
297
+ this.uniformChanged();
298
+ }
299
+ }
300
+ /**
301
+ * How far along a strand the root occlusion reaches, in (0, 1].
302
+ *
303
+ * @remarks
304
+ * The fraction of the strand's length over which occlusion fades from full at
305
+ * the root to none. Short for a groom whose strands leave the scalp quickly,
306
+ * longer for one where they lie against each other most of their length.
307
+ */ get rootOcclusionRange() {
308
+ return this._rootOcclusionRange;
309
+ }
310
+ set rootOcclusionRange(val) {
311
+ const v = val < 0.001 ? 0.001 : val > 1 ? 1 : val;
312
+ if (v !== this._rootOcclusionRange) {
313
+ this._rootOcclusionRange = v;
314
+ this.uniformChanged();
315
+ }
316
+ }
317
+ /**
318
+ * Vertex count needed to draw the current strand set.
319
+ *
320
+ * @remarks
321
+ * Assign this to the primitive's `indexCount`. Drawing is non-indexed, with six
322
+ * vertices per ribbon segment forming two triangles.
323
+ */ get vertexCount() {
324
+ return this._strands ? this._strands.strandCount * this._segmentsPerStrand * 6 : 0;
325
+ }
326
+ /**
327
+ * Albedo, with alpha scaled by how much the ribbon was widened to meet the
328
+ * pixel floor.
329
+ *
330
+ * @remarks
331
+ * Widening a sub-pixel strand to a pixel adds coverage that is not physically
332
+ * there. Scaling alpha by the inverse keeps the strand's contribution roughly
333
+ * constant, so distant hair thins out in opacity instead of breaking into a
334
+ * flickering dotted line.
335
+ */ calculateAlbedoColor(scope, uv) {
336
+ const pb = scope.$builder;
337
+ const albedo = super.calculateAlbedoColor(scope, uv);
338
+ const coverage = scope.$inputs.zStrandCoverage;
339
+ if (!coverage) {
340
+ return albedo;
341
+ }
342
+ return pb.vec4(albedo.rgb, pb.mul(albedo.a, coverage));
343
+ }
344
+ vertexShaderImpl(scope) {
345
+ const pb = scope.$builder;
346
+ this.declareStrandBindings(scope);
347
+ scope.$l.strand = this.expandStrandVertex(scope);
348
+ scope.$outputs.worldPos = scope.strand.worldPos;
349
+ scope.$outputs.wNorm = scope.strand.normal;
350
+ scope.$outputs.wTangent = scope.strand.tangent;
351
+ // Completes the frame the inherited lighting reads. The ribbon's own side
352
+ // vector is already perpendicular to the strand and to the shading normal.
353
+ scope.$outputs.wBinormal = scope.strand.side;
354
+ scope.$outputs.zStrandCoverage = scope.strand.coverage;
355
+ // Where across the ribbon this vertex sits, as the sine of the angle round the
356
+ // fibre it stands for: -1 and +1 at the edges, 0 down the middle. The fragment
357
+ // stage rebuilds the cylinder normal from it. Carrying the sine rather than the
358
+ // angle is what makes it safe to interpolate - it is linear in the offset from
359
+ // the centre, which is exactly what the rasteriser interpolates - and the
360
+ // roundness dial is already folded in, so no fragment-stage uniform is needed.
361
+ scope.$outputs.zStrandRound = scope.strand.across;
362
+ scope.$outputs.zStrandAO = scope.strand.occlusion;
363
+ // Without this the inherited path reports zero motion for every pixel - it
364
+ // emits motion vectors from resolveVertexPosition, which reads the vertex
365
+ // attribute this material does not have. A temporal filter then reprojects
366
+ // rotating hair onto the pixel it used to occupy, rejects the mismatched
367
+ // history and falls back to the raw frame, which on dithered strands is
368
+ // visible as noise that only settles once the camera stops.
369
+ //
370
+ // The previous position is the previous frame's control point put through
371
+ // the previous frame's world matrix. The two halves are independent: the
372
+ // matrix carries the node - a head bone, say - and the control point carries
373
+ // whatever the solver did to the strand. With a static groom the previous
374
+ // control point is the current one and only the matrix contributes; see
375
+ // {@link HairStrandMaterial.prevPoints} for the other case.
376
+ //
377
+ // The camera-facing offset is reused rather than rebuilt against the previous
378
+ // camera: its per-frame change is the ribbon half-width times the per-frame
379
+ // camera rotation, hundredths of a pixel even while orbiting quickly, and
380
+ // recomputing it would double the control point fetches for a sub-pixel
381
+ // correction.
382
+ if (ShaderHelper.getPrevUnjitteredViewProjectionMatrix(scope)) {
383
+ scope.$l.zPrevWorldPos = pb.add(pb.mul(ShaderHelper.getPrevWorldMatrix(scope), pb.vec4(scope.strand.prevLocalCentre, 1)).xyz, scope.strand.offset);
384
+ ShaderHelper.resolveMotionVector(scope, scope.strand.worldPos, scope.zPrevWorldPos);
385
+ }
386
+ ShaderHelper.setClipSpacePosition(scope, pb.mul(ShaderHelper.getViewProjectionMatrix(scope), pb.vec4(scope.strand.worldPos, 1)));
387
+ }
388
+ /**
389
+ * Rebuilds the cylinder normal the flat quad stands for.
390
+ *
391
+ * @remarks
392
+ * The quad's own normal points at the camera everywhere on it, so every fragment
393
+ * across a strand's width shades the same and the strand reads as tape. A real
394
+ * fibre presents a curved surface: at the centre of the visible side its normal
395
+ * faces the viewer, and by the silhouette edges it has turned a quarter turn to
396
+ * lie perpendicular to the view. With `a` the interpolated sine of that angle,
397
+ * the normal is `side * a + faceNormal * cos`, and `cos = sqrt(1 - a * a)`.
398
+ *
399
+ * Built from the varyings rather than from the interpolated TBN on purpose. The
400
+ * TBN may be flipped for back faces, and the sine carried alongside it would not
401
+ * be, which would turn the fibre inside out; the varyings need no flip, because
402
+ * a camera-facing quad's normal already points at the camera by construction.
403
+ * @internal
404
+ */ /**
405
+ * Ambient occlusion from how deep in the groom the fragment sits.
406
+ *
407
+ * @remarks
408
+ * Stands in for the occlusion map the inherited path uses, which this material
409
+ * cannot sample: it emits no texture coordinates. See
410
+ * {@link HairStrandMaterial.rootOcclusion} for why an approximation is worth
411
+ * having at all.
412
+ *
413
+ * Only the ambient hook is overridden. The inherited direct hook resolves to
414
+ * one here - there is no occlusion map - which is correct: the geometric term
415
+ * describes being buried in the groom, and for direct light that is exactly
416
+ * what the shadow map already measures.
417
+ * @internal
418
+ */ calculateHairOcclusion(scope) {
419
+ const pb = scope.$builder;
420
+ // Evaluated at the vertex stage and interpolated, the same way the roundness
421
+ // dial is: the expansion parameters live in a vertex-stage uniform block, and
422
+ // the term is smooth along a strand, so nothing is gained by computing it per
423
+ // fragment.
424
+ const occlusion = scope.$inputs.zStrandAO;
425
+ if (!occlusion) {
426
+ return super.calculateHairOcclusion(scope);
427
+ }
428
+ return pb.mul(super.calculateHairOcclusion(scope), occlusion);
429
+ }
430
+ calculateShadingNormal(scope, normalInfo) {
431
+ const pb = scope.$builder;
432
+ const across = scope.$inputs.zStrandRound;
433
+ const faceNormal = scope.$inputs.wNorm;
434
+ const side = scope.$inputs.wBinormal;
435
+ if (!across || !faceNormal || !side) {
436
+ return normalInfo.normal;
437
+ }
438
+ const funcName = 'Z_strandShadingNormal';
439
+ pb.func(funcName, [
440
+ pb.float('across'),
441
+ pb.vec3('faceNormal'),
442
+ pb.vec3('side')
443
+ ], function() {
444
+ // Interpolating between two unit vectors shortens them, so both are
445
+ // renormalised before they are combined rather than trusting the varyings.
446
+ this.$l.n = pb.normalize(this.faceNormal);
447
+ this.$l.s = pb.normalize(this.side);
448
+ this.$l.sinA = pb.clamp(this.across, -1, 1);
449
+ this.$l.cosA = pb.sqrt(pb.max(pb.sub(1, pb.mul(this.sinA, this.sinA)), 0));
450
+ this.$return(pb.normalize(pb.add(pb.mul(this.s, this.sinA), pb.mul(this.n, this.cosA))));
451
+ });
452
+ return pb.getGlobalScope()[funcName](across, faceNormal, side);
453
+ }
454
+ applyUniformValues(bindGroup, ctx, pass) {
455
+ super.applyUniformValues(bindGroup, ctx, pass);
456
+ const strands = this._strands;
457
+ if (!strands?.headerBuffer || !strands.pointBuffer) {
458
+ return;
459
+ }
460
+ bindGroup.setBuffer('zStrandHeaders', strands.headerBuffer);
461
+ bindGroup.setBuffer('zStrandPoints', strands.pointBuffer);
462
+ if (this.strandMotion && this._prevPoints) {
463
+ bindGroup.setBuffer('zStrandPrevPoints', this._prevPoints);
464
+ }
465
+ // The pixel floor, in the form the rendering camera calls for. Perspective:
466
+ // half a pixel subtends tan(fovY/2)/renderHeight radians, so a per-distance
467
+ // factor multiplied by view distance in the shader yields the world-space
468
+ // half-width. Orthographic - which is what a directional light's shadow pass
469
+ // uses - has no notion of subtended angle: a pixel is a constant world size,
470
+ // read off the projection matrix, and the floor is a constant half-width.
471
+ // Exactly one of the two is non-zero, and the shader adds them.
472
+ let pixelFactor = 0;
473
+ let pixelFloorWorld = 0;
474
+ const camera = ctx.camera;
475
+ if (this._minPixelWidth > 0 && camera && ctx.renderHeight > 0) {
476
+ if (camera.isPerspective()) {
477
+ const tanHalfFov = camera.getTanHalfFovy();
478
+ if (tanHalfFov > 0) {
479
+ pixelFactor = this._minPixelWidth * tanHalfFov / ctx.renderHeight;
480
+ }
481
+ } else {
482
+ // m11 of an orthographic projection is 2 / frustumHeight, so a texel is
483
+ // frustumHeight / renderHeight world units tall and the half-width floor
484
+ // follows. Keeping shadow-pass strands at roughly a texel matches what
485
+ // the view pass does per pixel, so caster and receiver agree on width.
486
+ const m11 = camera.getProjectionMatrix()[5];
487
+ if (Number.isFinite(m11) && m11 > 0) {
488
+ pixelFloorWorld = this._minPixelWidth / m11 / ctx.renderHeight;
489
+ }
490
+ }
491
+ }
492
+ bindGroup.setValue('zStrandParams', this._strandScratch.setXYZW(this._segmentsPerStrand, this._strandWidthScale, this._minStrandWidth, pixelFactor));
493
+ bindGroup.setValue('zStrandShape', this._shapeScratch.setXYZW(this._strandRoundness, this._rootOcclusion, this._rootOcclusionRange, pixelFloorWorld));
494
+ if (this.strandLOD) {
495
+ bindGroup.setValue('zStrandLOD', this._lodScratch.setXYZW(this._minStrandLODRatio, 0, 0, 0));
496
+ }
497
+ }
498
+ /**
499
+ * Declares the strand storage buffers and expansion parameters.
500
+ *
501
+ * @remarks
502
+ * Group 2 is the material's own bind group, matching the inherited uniforms.
503
+ * @internal
504
+ */ declareStrandBindings(scope) {
505
+ const pb = scope.$builder;
506
+ scope.zStrandHeaders = pb.uint[0]().storageBufferReadonly(2);
507
+ scope.zStrandPoints = pb.float[0]().storageBufferReadonly(2);
508
+ if (this.strandMotion) {
509
+ scope.zStrandPrevPoints = pb.float[0]().storageBufferReadonly(2);
510
+ }
511
+ scope.zStrandParams = pb.vec4().uniform(2);
512
+ scope.zStrandShape = pb.vec4().uniform(2);
513
+ if (this.strandLOD) {
514
+ scope.zStrandLOD = pb.vec4().uniform(2);
515
+ }
516
+ }
517
+ /**
518
+ * Reads one control point as position in xyz and width in w.
519
+ * @internal
520
+ */ fetchStrandPoint(scope, index, prev = false) {
521
+ const pb = scope.$builder;
522
+ const funcName = prev ? 'Z_strandPointPrev' : 'Z_strandPoint';
523
+ pb.func(funcName, [
524
+ pb.uint('index')
525
+ ], function() {
526
+ if (prev) {
527
+ // The snapshot carries positions only - three floats per point, against
528
+ // the live buffer's four. Width does not change between frames and the
529
+ // caller only reads xyz off the previous sample, so w is left at zero
530
+ // rather than given a buffer to come from.
531
+ this.$l.base = pb.mul(this.index, 3);
532
+ this.$return(pb.vec4(this.zStrandPrevPoints.at(this.base), this.zStrandPrevPoints.at(pb.add(this.base, 1)), this.zStrandPrevPoints.at(pb.add(this.base, 2)), 0));
533
+ } else {
534
+ this.$l.base = pb.mul(this.index, 4);
535
+ this.$return(pb.vec4(this.zStrandPoints.at(this.base), this.zStrandPoints.at(pb.add(this.base, 1)), this.zStrandPoints.at(pb.add(this.base, 2)), this.zStrandPoints.at(pb.add(this.base, 3))));
536
+ }
537
+ });
538
+ return pb.getGlobalScope()[funcName](index);
539
+ }
540
+ /**
541
+ * Evaluates a strand at parameter `t`, returning position, width and tangent.
542
+ *
543
+ * @remarks
544
+ * A Catmull-Rom spline through the control points, not the control polygon
545
+ * itself, and both quantities come from the same curve: the sample is the
546
+ * spline's value, the tangent its analytic derivative.
547
+ *
548
+ * The reason is the tangent. A polyline's derivative is constant within a span
549
+ * and jumps at every control point, and the anisotropic lobes are a function of
550
+ * that direction raised to a large power, so what is a slight kink in the
551
+ * silhouette becomes a hard step in the highlight - each span of a strand shading
552
+ * as its own uniformly bright or dark facet. Interpolating the position more
553
+ * accurately is the lesser half of the change.
554
+ *
555
+ * The curve matters too once {@link segmentsPerStrand} is below the control
556
+ * point count, which is the normal case: the default draws 8 segments from
557
+ * strands carrying 30 points, so a polyline reading cuts the corner across three
558
+ * points at a time and quietly straightens out every curl in the groom. A spline
559
+ * sampled at the same rate keeps the bend.
560
+ *
561
+ * Costs four control point fetches against the three the polyline reading of
562
+ * position and tangent needed between them.
563
+ * @internal
564
+ */ evaluateStrand(scope, firstPoint, pointCount, t, prev = false) {
565
+ const pb = scope.$builder;
566
+ const that = this;
567
+ const funcName = prev ? 'Z_strandEvaluatePrev' : 'Z_strandEvaluate';
568
+ const StrandSample = pb.defineStruct([
569
+ pb.vec4('sample'),
570
+ pb.vec3('tangent')
571
+ ]);
572
+ pb.func(funcName, [
573
+ pb.uint('firstPoint'),
574
+ pb.uint('pointCount'),
575
+ pb.float('t')
576
+ ], function() {
577
+ this.$l.last = pb.sub(this.pointCount, 1);
578
+ this.$l.f = pb.mul(this.t, pb.float(this.last));
579
+ this.$l.i1 = pb.min(pb.uint(pb.floor(this.f)), pb.sub(this.last, 1));
580
+ this.$l.s = pb.clamp(pb.sub(this.f, pb.float(this.i1)), 0, 1);
581
+ // The span is p1..p2, with p0 and p3 as the neighbours that set its
582
+ // curvature. Both ends duplicate rather than wrap: max(i,1)-1 gives i-1
583
+ // without underflowing at the root, and the tip clamps to the last point,
584
+ // which is the usual way to end a Catmull-Rom without inventing points.
585
+ this.$l.p0 = that.fetchStrandPoint(this, pb.add(this.firstPoint, pb.sub(pb.max(this.i1, 1), 1)), prev);
586
+ this.$l.p1 = that.fetchStrandPoint(this, pb.add(this.firstPoint, this.i1), prev);
587
+ this.$l.p2 = that.fetchStrandPoint(this, pb.add(this.firstPoint, pb.add(this.i1, 1)), prev);
588
+ this.$l.p3 = that.fetchStrandPoint(this, pb.add(this.firstPoint, pb.min(pb.add(this.i1, 2), this.last)), prev);
589
+ // Uniform Catmull-Rom in Hermite form: the span p1..p2 with the endpoint
590
+ // tangents each spline segment inherits from its neighbours. Kept in this
591
+ // form rather than as a collected cubic so the value and the derivative are
592
+ // visibly the same polynomial, and so the endpoint conditions - value p1 and
593
+ // slope m1 at s = 0, value p2 and slope m2 at s = 1 - can be read off.
594
+ this.$l.m1 = pb.mul(pb.sub(this.p2, this.p0), 0.5);
595
+ this.$l.m2 = pb.mul(pb.sub(this.p3, this.p1), 0.5);
596
+ this.$l.d = pb.sub(this.p2, this.p1);
597
+ this.$l.a2 = pb.sub(pb.mul(this.d, 3), pb.add(pb.mul(this.m1, 2), this.m2));
598
+ this.$l.a3 = pb.add(pb.mul(this.d, -2), this.m1, this.m2);
599
+ this.$l.s2 = pb.mul(this.s, this.s);
600
+ this.$l.value = pb.add(this.p1, pb.mul(this.m1, this.s), pb.mul(this.a2, this.s2), pb.mul(this.a3, pb.mul(this.s2, this.s)));
601
+ this.$l.deriv = pb.add(this.m1.xyz, pb.mul(this.a2.xyz, pb.mul(this.s, 2)), pb.mul(this.a3.xyz, pb.mul(this.s2, 3)));
602
+ this.$l.out = StrandSample();
603
+ // A spline can undershoot between control points, and a negative width
604
+ // would flip the quad inside out, so the width is floored at zero.
605
+ this.out.sample = pb.vec4(this.value.xyz, pb.max(this.value.w, 0));
606
+ this.$l.len = pb.length(this.deriv);
607
+ this.$if(pb.greaterThan(this.len, 0.000001), function() {
608
+ this.out.tangent = pb.div(this.deriv, this.len);
609
+ }).$else(function() {
610
+ // Coincident control points leave the derivative undefined; fall back to
611
+ // the chord across the span, and to an arbitrary axis if that is degenerate
612
+ // too, so a duplicated point cannot produce a NaN frame.
613
+ this.$l.chord = pb.sub(this.p2.xyz, this.p1.xyz);
614
+ this.$l.chordLen = pb.length(this.chord);
615
+ this.$if(pb.greaterThan(this.chordLen, 0.000001), function() {
616
+ this.out.tangent = pb.div(this.chord, this.chordLen);
617
+ }).$else(function() {
618
+ this.out.tangent = pb.vec3(0, 1, 0);
619
+ });
620
+ });
621
+ this.$return(this.out);
622
+ });
623
+ return pb.getGlobalScope()[funcName](firstPoint, pointCount, t);
624
+ }
625
+ /**
626
+ * Rotates a strand direction into world space and renormalises it.
627
+ *
628
+ * @remarks
629
+ * The direction is transformed by the world matrix rather than by its inverse
630
+ * transpose on purpose: this is a tangent, not a normal. The shading normal is
631
+ * rebuilt from the tangent and the ribbon's side vector after the fact, so a
632
+ * non-uniform scale reaches the lighting through the frame it actually bends.
633
+ *
634
+ * A degenerate scale can collapse the direction entirely, which would leave a
635
+ * zero-length tangent and produce NaN once it is normalised, so the untransformed
636
+ * direction stands in for that case.
637
+ * @internal
638
+ */ transformStrandDirection(scope, worldMatrix, direction) {
639
+ const pb = scope.$builder;
640
+ const funcName = 'Z_strandDirectionToWorld';
641
+ pb.func(funcName, [
642
+ pb.mat4('worldMatrix'),
643
+ pb.vec3('direction')
644
+ ], function() {
645
+ this.$l.transformed = pb.mul(this.worldMatrix, pb.vec4(this.direction, 0)).xyz;
646
+ this.$l.len = pb.length(this.transformed);
647
+ this.$if(pb.greaterThan(this.len, 0.000001), function() {
648
+ this.$return(pb.div(this.transformed, this.len));
649
+ });
650
+ this.$return(this.direction);
651
+ });
652
+ return pb.getGlobalScope()[funcName](worldMatrix, direction);
653
+ }
654
+ /**
655
+ * Derives this vertex's ribbon corner from the vertex index.
656
+ *
657
+ * @remarks
658
+ * Six vertices per segment form two triangles over a quad whose corners are
659
+ * indexed by (ring, side). Independent triangles rather than a strip means no
660
+ * degenerate vertices are needed to cut between strands, and the duplicated
661
+ * corners cost nothing because corners are computed rather than fetched.
662
+ * @internal
663
+ */ expandStrandVertex(scope) {
664
+ const pb = scope.$builder;
665
+ const funcName = 'Z_expandStrandVertex';
666
+ const that = this;
667
+ const StrandVertex = pb.defineStruct([
668
+ pb.vec3('worldPos'),
669
+ pb.vec3('normal'),
670
+ pb.vec3('tangent'),
671
+ pb.vec3('side'),
672
+ pb.float('coverage'),
673
+ pb.float('across'),
674
+ pb.vec3('localCentre'),
675
+ pb.vec3('prevLocalCentre'),
676
+ pb.vec3('offset'),
677
+ pb.float('occlusion')
678
+ ]);
679
+ pb.func(funcName, [], function() {
680
+ this.$l.vid = pb.uint(this.$builtins.vertexIndex);
681
+ this.$l.segCount = pb.max(pb.uint(this.zStrandParams.x), 1);
682
+ this.$l.perStrand = pb.mul(this.segCount, 6);
683
+ this.$l.strandIndex = pb.div(this.vid, this.perStrand);
684
+ this.$l.local = pb.sub(this.vid, pb.mul(this.strandIndex, this.perStrand));
685
+ this.$l.segIndex = pb.div(this.local, 6);
686
+ this.$l.corner = pb.sub(this.local, pb.mul(this.segIndex, 6));
687
+ // Quad corners as (ring offset, side): triangle one is 0,2,1 and triangle
688
+ // two is 1,2,3, which keeps a consistent winding for both.
689
+ this.$l.ringStep = pb.uint(0);
690
+ this.$l.sideSel = pb.float(0);
691
+ this.$if(pb.equal(this.corner, 0), function() {
692
+ this.ringStep = pb.uint(0);
693
+ this.sideSel = pb.float(0);
694
+ }).$elseif(pb.equal(this.corner, 1), function() {
695
+ this.ringStep = pb.uint(0);
696
+ this.sideSel = pb.float(1);
697
+ }).$elseif(pb.equal(this.corner, 2), function() {
698
+ this.ringStep = pb.uint(1);
699
+ this.sideSel = pb.float(0);
700
+ }).$elseif(pb.equal(this.corner, 3), function() {
701
+ this.ringStep = pb.uint(1);
702
+ this.sideSel = pb.float(0);
703
+ }).$elseif(pb.equal(this.corner, 4), function() {
704
+ this.ringStep = pb.uint(0);
705
+ this.sideSel = pb.float(1);
706
+ }).$else(function() {
707
+ this.ringStep = pb.uint(1);
708
+ this.sideSel = pb.float(1);
709
+ });
710
+ this.$l.ring = pb.add(this.segIndex, this.ringStep);
711
+ // Header: first point, point count, packed root UV, seed.
712
+ this.$l.hBase = pb.mul(this.strandIndex, 4);
713
+ this.$l.firstPoint = this.zStrandHeaders.at(this.hBase);
714
+ this.$l.pointCount = pb.max(this.zStrandHeaders.at(pb.add(this.hBase, 1)), 2);
715
+ this.$l.t = pb.div(pb.float(this.ring), pb.float(this.segCount));
716
+ this.$l.curve = that.evaluateStrand(this, this.firstPoint, this.pointCount, this.t);
717
+ // Control points are stored in the node's local space, so the frame is
718
+ // built after the world transform rather than before it: the ribbon faces
719
+ // the camera in world space, and a non-uniform scale must not be allowed
720
+ // to shear that facing direction.
721
+ this.$l.worldMatrix = ShaderHelper.getWorldMatrix(this);
722
+ this.$l.localCentre = this.curve.sample.xyz;
723
+ // Where this vertex's control point sat when the last frame was drawn. The
724
+ // spline weights depend only on t and the span index, both of which are
725
+ // fixed by the topology, so the second evaluation costs four fetches and
726
+ // repeats arithmetic the compiler is free to share.
727
+ this.$l.prevLocalCentre = this.localCentre;
728
+ if (that.strandMotion) {
729
+ this.prevLocalCentre = that.evaluateStrand(this, this.firstPoint, this.pointCount, this.t, true).sample.xyz;
730
+ }
731
+ this.$l.centre = pb.mul(this.worldMatrix, pb.vec4(this.localCentre, 1)).xyz;
732
+ this.$l.tangent = that.transformStrandDirection(this, this.worldMatrix, this.curve.tangent);
733
+ // Width is a length in the same space as the control points, so it follows
734
+ // the same transform. A single factor stands in for the three axis scales
735
+ // because a strand is a fibre with no preferred cross-section direction -
736
+ // and the mean is what keeps a uniformly scaled groom exactly proportional.
737
+ this.$l.worldScale = pb.div(pb.add(pb.length(this.worldMatrix[0].xyz), pb.length(this.worldMatrix[1].xyz), pb.length(this.worldMatrix[2].xyz)), 3);
738
+ this.$l.halfWidth = pb.mul(this.curve.sample.w, 0.5, this.zStrandParams.y, this.worldScale);
739
+ // Camera-facing side vector: perpendicular to the strand and to the view
740
+ // ray, so the ribbon always presents its full width.
741
+ this.$l.camPos = ShaderHelper.getCameraPosition(this);
742
+ this.$l.toCam = pb.sub(this.camPos, this.centre);
743
+ this.$l.viewDist = pb.length(this.toCam);
744
+ this.$l.viewDir = pb.vec3(0, 0, 1);
745
+ this.$if(pb.greaterThan(this.viewDist, 0.000001), function() {
746
+ this.viewDir = pb.div(this.toCam, this.viewDist);
747
+ });
748
+ this.$l.side = pb.cross(this.tangent, this.viewDir);
749
+ this.$l.sideLen = pb.length(this.side);
750
+ this.$if(pb.greaterThan(this.sideLen, 0.000001), function() {
751
+ this.side = pb.div(this.side, this.sideLen);
752
+ }).$else(function() {
753
+ // Strand pointing straight at the camera: any perpendicular will do.
754
+ this.side = pb.normalize(pb.cross(this.tangent, pb.vec3(0.0, 1.0, 0.001)));
755
+ });
756
+ // Clamp to the world-space floor, then to the pixel floor, tracking how much
757
+ // the quad was widened so the fragment stage can pay it back in alpha.
758
+ this.$l.halfW = pb.max(this.halfWidth, pb.mul(this.zStrandParams.z, 0.5));
759
+ this.$l.coverage = pb.float(1);
760
+ // The pixel floor has two forms because a pixel has two meanings. Under a
761
+ // perspective camera its world size grows with distance: zStrandParams.w
762
+ // holds minPixelWidth * tan(fovY/2) / renderHeight and one multiply by view
763
+ // distance gives the floor - folding the projection terms on the CPU avoids
764
+ // needing a viewport uniform here. Under an orthographic camera - a
765
+ // directional light's shadow pass - a pixel is the same world size
766
+ // everywhere, so zStrandShape.w carries the floor as a constant. The CPU
767
+ // fills exactly one of the two, so their sum is whichever applies; without
768
+ // the ortho term, sub-texel strands leave no footprint in the shadow map at
769
+ // all and the deep opacity map under-counts the very hair it exists to
770
+ // shadow.
771
+ this.$l.floorHalf = pb.add(pb.mul(this.zStrandParams.w, this.viewDist), this.zStrandShape.w);
772
+ this.$if(pb.greaterThan(this.floorHalf, 0), function() {
773
+ this.$if(pb.lessThan(this.halfW, this.floorHalf), function() {
774
+ this.coverage = pb.div(this.halfW, this.floorHalf);
775
+ this.halfW = this.floorHalf;
776
+ });
777
+ });
778
+ if (that.strandLOD) {
779
+ // Taken from the root control point rather than from this ring: both the
780
+ // view distance and the tapered width vary along a strand, so a
781
+ // ring-local threshold would fall between two rings on strands near the
782
+ // cut-off and lop them off partway down their length.
783
+ const points = this.zStrandPoints;
784
+ this.$l.rootBase = pb.mul(this.firstPoint, 4);
785
+ this.$l.rootLocal = pb.vec3(points.at(this.rootBase), points.at(pb.add(this.rootBase, 1)), points.at(pb.add(this.rootBase, 2)));
786
+ // The threshold compares a width against a view distance, so the root has
787
+ // to reach world space like everything else - otherwise a scaled groom
788
+ // would decimate against distances measured in its own local units.
789
+ this.$l.rootPos = pb.mul(this.worldMatrix, pb.vec4(this.rootLocal, 1)).xyz;
790
+ this.$l.rootHalf = pb.mul(points.at(pb.add(this.rootBase, 3)), 0.5, this.zStrandParams.y, this.worldScale);
791
+ this.$l.rootFloor = pb.add(pb.mul(this.zStrandParams.w, pb.distance(this.camPos, this.rootPos)), this.zStrandShape.w);
792
+ this.$l.keep = pb.float(1);
793
+ this.$if(pb.greaterThan(this.rootFloor, 0), function() {
794
+ this.keep = pb.clamp(pb.div(this.rootHalf, this.rootFloor), this.zStrandLOD.x, 1);
795
+ });
796
+ // The per-strand seed the importer hashed into the header. Stored as
797
+ // float bits in a uint buffer, hence the bitcast.
798
+ this.$l.seed = pb.uintBitsToFloat(this.zStrandHeaders.at(pb.add(this.hBase, 3)));
799
+ // Survivors absorb the coverage of the strands dropped alongside them,
800
+ // which is what leaves the total amount of hair unchanged. At the ratio
801
+ // floor this stops reaching 1 and the strands fade instead.
802
+ this.coverage = pb.min(pb.div(this.coverage, this.keep), 1);
803
+ this.$if(pb.greaterThanEqual(this.seed, this.keep), function() {
804
+ // Collapse the strand onto a single point: every triangle it would
805
+ // have produced becomes zero-area and is dropped before rasterisation,
806
+ // so a culled strand costs no fragments at all.
807
+ this.centre = this.rootPos;
808
+ this.localCentre = this.rootLocal;
809
+ this.prevLocalCentre = this.rootLocal;
810
+ this.halfW = pb.float(0);
811
+ });
812
+ }
813
+ this.$l.acrossSign = pb.sub(pb.mul(this.sideSel, 2), 1);
814
+ this.$l.offset = pb.mul(this.side, this.halfW, this.acrossSign);
815
+ this.$l.result = StrandVertex();
816
+ this.result.worldPos = pb.add(this.centre, this.offset);
817
+ // On a cylinder seen from outside, the visible surface points back along the
818
+ // side vector toward the camera; crossing side with tangent gives that.
819
+ this.result.normal = pb.normalize(pb.cross(this.side, this.tangent));
820
+ this.result.tangent = this.tangent;
821
+ this.result.side = this.side;
822
+ this.result.coverage = this.coverage;
823
+ this.result.across = pb.mul(this.acrossSign, this.zStrandShape.x);
824
+ // Carried out so the caller can rebuild this vertex against the previous
825
+ // frame's world matrix without re-running the expansion.
826
+ this.result.localCentre = this.localCentre;
827
+ this.result.prevLocalCentre = this.prevLocalCentre;
828
+ this.result.offset = this.offset;
829
+ // How deep in the groom this vertex sits, stood in for by where along the
830
+ // strand it is: full occlusion at the root, fading to none once the strand
831
+ // has climbed `range` of its length clear. Smoothstep rather than a linear
832
+ // ramp so the transition leaves no visible edge across a dense groom.
833
+ this.$l.aoDepth = pb.sub(1, pb.smoothStep(0, pb.max(this.zStrandShape.z, 0.001), this.t));
834
+ this.result.occlusion = pb.clamp(pb.sub(1, pb.mul(this.aoDepth, this.zStrandShape.y)), 0, 1);
835
+ this.$return(this.result);
836
+ });
837
+ return pb.getGlobalScope()[funcName]();
838
+ }
839
+ }
840
+
841
+ export { HairStrandMaterial };
842
+ //# sourceMappingURL=hairstrand.js.map