@volter/blender-engine 0.1.147 → 0.1.149

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 (245) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +4 -11
  3. package/browser/addons/vrm/LICENSE_(OPTION1)_MIT.txt +22 -0
  4. package/browser/addons/vrm/LICENSE_(OPTION2)_GPL-3_0.txt +674 -0
  5. package/browser/addons/vrm/LICENSE_MAIN.txt +1 -0
  6. package/browser/addons/vrm/README.txt +3 -0
  7. package/browser/addons/vrm/__init__.py +41 -0
  8. package/browser/addons/vrm/blender_manifest.toml +16 -0
  9. package/browser/addons/vrm/common/__init__.py +1 -0
  10. package/browser/addons/vrm/common/animation.py +58 -0
  11. package/browser/addons/vrm/common/blender_manifest.py +82 -0
  12. package/browser/addons/vrm/common/char.py +16 -0
  13. package/browser/addons/vrm/common/convert.py +287 -0
  14. package/browser/addons/vrm/common/convert_any.py +48 -0
  15. package/browser/addons/vrm/common/debug.py +40 -0
  16. package/browser/addons/vrm/common/deep.py +162 -0
  17. package/browser/addons/vrm/common/error_dialog.py +274 -0
  18. package/browser/addons/vrm/common/fs.py +71 -0
  19. package/browser/addons/vrm/common/gl.py +20 -0
  20. package/browser/addons/vrm/common/gltf.py +1149 -0
  21. package/browser/addons/vrm/common/human_bone_mapper/__init__.py +1 -0
  22. package/browser/addons/vrm/common/human_bone_mapper/biped_mapping.py +84 -0
  23. package/browser/addons/vrm/common/human_bone_mapper/cats_blender_plugin_fix_model_mapping.py +63 -0
  24. package/browser/addons/vrm/common/human_bone_mapper/human_bone_mapper.py +203 -0
  25. package/browser/addons/vrm/common/human_bone_mapper/microsoft_rocketbox_mapping.py +69 -0
  26. package/browser/addons/vrm/common/human_bone_mapper/mixamo_mapping.py +62 -0
  27. package/browser/addons/vrm/common/human_bone_mapper/mmd_mapping.py +115 -0
  28. package/browser/addons/vrm/common/human_bone_mapper/mpfb_mapping.py +120 -0
  29. package/browser/addons/vrm/common/human_bone_mapper/ready_player_me_mapping.py +65 -0
  30. package/browser/addons/vrm/common/human_bone_mapper/reallusion_mapping.py +94 -0
  31. package/browser/addons/vrm/common/human_bone_mapper/rigify_meta_rig_mapping.py +65 -0
  32. package/browser/addons/vrm/common/human_bone_mapper/structure_based_mapping.py +891 -0
  33. package/browser/addons/vrm/common/human_bone_mapper/unreal_mapping.py +62 -0
  34. package/browser/addons/vrm/common/human_bone_mapper/vrm_addon_mapping.py +112 -0
  35. package/browser/addons/vrm/common/human_bone_mapper/vroid_mapping.py +89 -0
  36. package/browser/addons/vrm/common/legacy_gltf.py +16 -0
  37. package/browser/addons/vrm/common/logger.py +84 -0
  38. package/browser/addons/vrm/common/mtoon0.blend +0 -0
  39. package/browser/addons/vrm/common/mtoon1.blend +0 -0
  40. package/browser/addons/vrm/common/mtoon1_outline.blend +0 -0
  41. package/browser/addons/vrm/common/mtoon_auto_setup.blend +0 -0
  42. package/browser/addons/vrm/common/mtoon_unversioned.py +62 -0
  43. package/browser/addons/vrm/common/ops/__init__.py +10 -0
  44. package/browser/addons/vrm/common/ops/export_scene.py +67 -0
  45. package/browser/addons/vrm/common/ops/icyp.py +53 -0
  46. package/browser/addons/vrm/common/ops/import_scene.py +57 -0
  47. package/browser/addons/vrm/common/ops/vrm.py +2380 -0
  48. package/browser/addons/vrm/common/ops/wm.py +167 -0
  49. package/browser/addons/vrm/common/preferences.py +362 -0
  50. package/browser/addons/vrm/common/progress.py +87 -0
  51. package/browser/addons/vrm/common/rotation.py +89 -0
  52. package/browser/addons/vrm/common/safe_removal.py +46 -0
  53. package/browser/addons/vrm/common/scene_watcher.py +230 -0
  54. package/browser/addons/vrm/common/shader.py +2243 -0
  55. package/browser/addons/vrm/common/shape_key_mapper/__init__.py +1 -0
  56. package/browser/addons/vrm/common/shape_key_mapper/arkit_mapping.py +168 -0
  57. package/browser/addons/vrm/common/shape_key_mapper/mmd_mapping.py +62 -0
  58. package/browser/addons/vrm/common/shape_key_mapper/ready_player_me_mapping.py +52 -0
  59. package/browser/addons/vrm/common/shape_key_mapper/vrchat_mapping.py +17 -0
  60. package/browser/addons/vrm/common/third_party_user_extension.py +214 -0
  61. package/browser/addons/vrm/common/transparent_z_write.blend +0 -0
  62. package/browser/addons/vrm/common/version.py +161 -0
  63. package/browser/addons/vrm/common/vrm0/__init__.py +1 -0
  64. package/browser/addons/vrm/common/vrm0/human_bone.py +662 -0
  65. package/browser/addons/vrm/common/vrm0/material_property.py +346 -0
  66. package/browser/addons/vrm/common/vrm1/__init__.py +1 -0
  67. package/browser/addons/vrm/common/vrm1/expression_preset.py +207 -0
  68. package/browser/addons/vrm/common/vrm1/human_bone.py +822 -0
  69. package/browser/addons/vrm/common/workspace.py +227 -0
  70. package/browser/addons/vrm/common/writable_context.py +77 -0
  71. package/browser/addons/vrm/documentation/en-us/animation/index.md +63 -0
  72. package/browser/addons/vrm/documentation/en-us/create-humanoid-vrm-from-scratch/index.md +131 -0
  73. package/browser/addons/vrm/documentation/en-us/create-simple-vrm-from-scratch/index.md +30 -0
  74. package/browser/addons/vrm/documentation/en-us/development/index.md +136 -0
  75. package/browser/addons/vrm/documentation/en-us/index.md +41 -0
  76. package/browser/addons/vrm/documentation/en-us/installation/index.md +152 -0
  77. package/browser/addons/vrm/documentation/en-us/material-mtoon/index.md +122 -0
  78. package/browser/addons/vrm/documentation/en-us/material-pbr/index.md +94 -0
  79. package/browser/addons/vrm/documentation/en-us/scripting-api/index.md +391 -0
  80. package/browser/addons/vrm/documentation/en-us/ui/export_scene.vrm/index.md +57 -0
  81. package/browser/addons/vrm/documentation/en-us/ui/export_scene.vrma/index.md +17 -0
  82. package/browser/addons/vrm/documentation/en-us/ui/import_scene.vrm/index.md +61 -0
  83. package/browser/addons/vrm/documentation/en-us/ui/import_scene.vrma/index.md +13 -0
  84. package/browser/addons/vrm/documentation/ja-jp/animation/index.md +57 -0
  85. package/browser/addons/vrm/documentation/ja-jp/create-humanoid-vrm-from-scratch/index.md +129 -0
  86. package/browser/addons/vrm/documentation/ja-jp/create-simple-vrm-from-scratch/index.md +29 -0
  87. package/browser/addons/vrm/documentation/ja-jp/development/index.md +137 -0
  88. package/browser/addons/vrm/documentation/ja-jp/index.md +37 -0
  89. package/browser/addons/vrm/documentation/ja-jp/installation/index.md +150 -0
  90. package/browser/addons/vrm/documentation/ja-jp/material-mtoon/index.md +113 -0
  91. package/browser/addons/vrm/documentation/ja-jp/material-pbr/index.md +90 -0
  92. package/browser/addons/vrm/documentation/ja-jp/scripting-api/index.md +389 -0
  93. package/browser/addons/vrm/documentation/ja-jp/ui/export_scene.vrm/index.md +55 -0
  94. package/browser/addons/vrm/documentation/ja-jp/ui/export_scene.vrma/index.md +19 -0
  95. package/browser/addons/vrm/documentation/ja-jp/ui/import_scene.vrm/index.md +56 -0
  96. package/browser/addons/vrm/documentation/ja-jp/ui/import_scene.vrma/index.md +14 -0
  97. package/browser/addons/vrm/editor/__init__.py +1 -0
  98. package/browser/addons/vrm/editor/extension.py +425 -0
  99. package/browser/addons/vrm/editor/extension_accessor.py +75 -0
  100. package/browser/addons/vrm/editor/handler.py +84 -0
  101. package/browser/addons/vrm/editor/khr_character/__init__.py +1 -0
  102. package/browser/addons/vrm/editor/khr_character/panel.py +69 -0
  103. package/browser/addons/vrm/editor/khr_character/property_group.py +27 -0
  104. package/browser/addons/vrm/editor/khr_xmp_json_ld/__init__.py +1 -0
  105. package/browser/addons/vrm/editor/khr_xmp_json_ld/ops.py +464 -0
  106. package/browser/addons/vrm/editor/khr_xmp_json_ld/panel.py +136 -0
  107. package/browser/addons/vrm/editor/khr_xmp_json_ld/property_group.py +83 -0
  108. package/browser/addons/vrm/editor/khr_xmp_json_ld/ui_list.py +198 -0
  109. package/browser/addons/vrm/editor/make_armature.py +709 -0
  110. package/browser/addons/vrm/editor/menu.py +147 -0
  111. package/browser/addons/vrm/editor/migration.py +297 -0
  112. package/browser/addons/vrm/editor/mtoon1/__init__.py +1 -0
  113. package/browser/addons/vrm/editor/mtoon1/handler.py +20 -0
  114. package/browser/addons/vrm/editor/mtoon1/migration.py +536 -0
  115. package/browser/addons/vrm/editor/mtoon1/ops.py +304 -0
  116. package/browser/addons/vrm/editor/mtoon1/panel.py +442 -0
  117. package/browser/addons/vrm/editor/mtoon1/property_group.py +4977 -0
  118. package/browser/addons/vrm/editor/mtoon1/scene_watcher.py +381 -0
  119. package/browser/addons/vrm/editor/node_constraint1/__init__.py +1 -0
  120. package/browser/addons/vrm/editor/node_constraint1/panel.py +317 -0
  121. package/browser/addons/vrm/editor/node_constraint1/property_group.py +25 -0
  122. package/browser/addons/vrm/editor/ops.py +471 -0
  123. package/browser/addons/vrm/editor/panel.py +315 -0
  124. package/browser/addons/vrm/editor/property_group.py +888 -0
  125. package/browser/addons/vrm/editor/search.py +682 -0
  126. package/browser/addons/vrm/editor/spring_bone1/__init__.py +1 -0
  127. package/browser/addons/vrm/editor/spring_bone1/handler.py +994 -0
  128. package/browser/addons/vrm/editor/spring_bone1/menu.py +246 -0
  129. package/browser/addons/vrm/editor/spring_bone1/migration.py +86 -0
  130. package/browser/addons/vrm/editor/spring_bone1/ops.py +1679 -0
  131. package/browser/addons/vrm/editor/spring_bone1/panel.py +605 -0
  132. package/browser/addons/vrm/editor/spring_bone1/property_group.py +1457 -0
  133. package/browser/addons/vrm/editor/spring_bone1/ui_list.py +408 -0
  134. package/browser/addons/vrm/editor/subscription.py +123 -0
  135. package/browser/addons/vrm/editor/t_pose.py +713 -0
  136. package/browser/addons/vrm/editor/validation.py +1271 -0
  137. package/browser/addons/vrm/editor/vrm0/__init__.py +1 -0
  138. package/browser/addons/vrm/editor/vrm0/gizmo_group.py +63 -0
  139. package/browser/addons/vrm/editor/vrm0/handler.py +23 -0
  140. package/browser/addons/vrm/editor/vrm0/menu.py +145 -0
  141. package/browser/addons/vrm/editor/vrm0/migration.py +761 -0
  142. package/browser/addons/vrm/editor/vrm0/ops.py +2145 -0
  143. package/browser/addons/vrm/editor/vrm0/panel.py +1333 -0
  144. package/browser/addons/vrm/editor/vrm0/property_group.py +1818 -0
  145. package/browser/addons/vrm/editor/vrm0/ui_list.py +688 -0
  146. package/browser/addons/vrm/editor/vrm1/__init__.py +1 -0
  147. package/browser/addons/vrm/editor/vrm1/handler.py +28 -0
  148. package/browser/addons/vrm/editor/vrm1/menu.py +76 -0
  149. package/browser/addons/vrm/editor/vrm1/migration.py +321 -0
  150. package/browser/addons/vrm/editor/vrm1/ops.py +2679 -0
  151. package/browser/addons/vrm/editor/vrm1/panel.py +1243 -0
  152. package/browser/addons/vrm/editor/vrm1/property_group.py +2087 -0
  153. package/browser/addons/vrm/editor/vrm1/scene_watcher.py +90 -0
  154. package/browser/addons/vrm/editor/vrm1/ui_list.py +533 -0
  155. package/browser/addons/vrm/exporter/__init__.py +1 -0
  156. package/browser/addons/vrm/exporter/abstract_base_vrm_exporter.py +628 -0
  157. package/browser/addons/vrm/exporter/export_scene.py +930 -0
  158. package/browser/addons/vrm/exporter/gltf2_export_user_extension.py +45 -0
  159. package/browser/addons/vrm/exporter/khr_character_exporter.py +34 -0
  160. package/browser/addons/vrm/exporter/vrm0_exporter.py +4020 -0
  161. package/browser/addons/vrm/exporter/vrm1_exporter.py +3682 -0
  162. package/browser/addons/vrm/exporter/vrm_animation_exporter.py +1035 -0
  163. package/browser/addons/vrm/external/__init__.py +1 -0
  164. package/browser/addons/vrm/external/auto_rig_pro_support.py +3 -0
  165. package/browser/addons/vrm/external/io_scene_gltf2_support.py +418 -0
  166. package/browser/addons/vrm/external/rigify_support.py +1 -0
  167. package/browser/addons/vrm/importer/__init__.py +1 -0
  168. package/browser/addons/vrm/importer/abstract_base_vrm_importer.py +1548 -0
  169. package/browser/addons/vrm/importer/file_handler.py +34 -0
  170. package/browser/addons/vrm/importer/gltf2_import_user_extension.py +268 -0
  171. package/browser/addons/vrm/importer/import_scene.py +646 -0
  172. package/browser/addons/vrm/importer/license_validation.py +198 -0
  173. package/browser/addons/vrm/importer/vrm0_importer.py +1723 -0
  174. package/browser/addons/vrm/importer/vrm1_importer.py +2014 -0
  175. package/browser/addons/vrm/importer/vrm_animation_importer.py +763 -0
  176. package/browser/addons/vrm/importer/vrm_diff.py +316 -0
  177. package/browser/addons/vrm/locale/__init__.py +1 -0
  178. package/browser/addons/vrm/locale/ja_jp.py +826 -0
  179. package/browser/addons/vrm/locale/ruff.toml +8 -0
  180. package/browser/addons/vrm/locale/translation_dictionary.py +10 -0
  181. package/browser/addons/vrm/locale/zh_hans.py +928 -0
  182. package/browser/addons/vrm/registration.py +670 -0
  183. package/browser/addons/vrm.json +18 -0
  184. package/browser/blender-emscripten-engine.mts +60 -128
  185. package/browser/blender-engine.mts +20 -186
  186. package/browser/blender-wali-engine.mts +35 -81
  187. package/browser/describe-thrown.mts +3 -6
  188. package/browser/document-chunks.mts +4 -16
  189. package/browser/gltf_export.py +3161 -0
  190. package/browser/heap-growth.mts +23 -0
  191. package/browser/index.ts +2 -0
  192. package/browser/protocol.ts +119 -35
  193. package/browser/pull-job.mts +2 -10
  194. package/browser/rna.ts +190 -73
  195. package/browser/runtime.ts +197 -82
  196. package/browser/session-addons.mts +33 -0
  197. package/browser/session-frame.mts +5 -24
  198. package/browser/session.py +1338 -1427
  199. package/browser/three/attach-presenter.ts +11 -39
  200. package/browser/three/blender-base64.ts +1 -36
  201. package/browser/three/blender-corner-normals.ts +1 -23
  202. package/browser/three/blender-display-shaders.json +1 -1
  203. package/browser/three/blender-display-shaders.lut +0 -0
  204. package/browser/three/blender-display-transform.ts +2 -1
  205. package/browser/three/blender-frame-columns.ts +5 -13
  206. package/browser/three/blender-graph-material.ts +18 -3
  207. package/browser/three/blender-node-graph.ts +10 -34
  208. package/browser/three/blender-physical-material.ts +2 -31
  209. package/browser/three/blender-runtime-armature.ts +7 -60
  210. package/browser/three/blender-runtime-frame.ts +9 -23
  211. package/browser/three/blender-runtime-geometry.ts +22 -26
  212. package/browser/three/blender-runtime-lighting.ts +48 -178
  213. package/browser/three/blender-runtime-shadows.ts +187 -1
  214. package/browser/three/blender-runtime-skeleton.ts +53 -1
  215. package/browser/three/blender-runtime-view.ts +505 -283
  216. package/browser/three/blender-runtime-weights.ts +9 -72
  217. package/browser/three/blender-sky.ts +13 -33
  218. package/browser/three/blender-triangulate.ts +7 -35
  219. package/browser/three/blender-viewport-sampling.ts +235 -0
  220. package/browser/three/blender-workbench-material.ts +25 -29
  221. package/browser/three/blender-workbench-outline.ts +199 -0
  222. package/browser/three/blender-world-volume.ts +62 -10
  223. package/browser/three/presenter.ts +10 -26
  224. package/browser/three/release.ts +5 -21
  225. package/browser/three/sky-precompute-worker.ts +1 -18
  226. package/browser/three/sky-worker.ts +5 -23
  227. package/browser/three/world-field-sampler.ts +12 -26
  228. package/browser/volter_three.py +9 -105
  229. package/browser/worker.ts +156 -151
  230. package/package.json +5 -2
  231. package/wasm/BUNDLE.json +17 -17
  232. package/wasm/DEPENDENCY-LICENSES.txt +14 -0
  233. package/wasm/blender_browser.data.br +0 -0
  234. package/wasm/blender_browser.js +1 -1
  235. package/wasm/blender_browser.wasm.br +0 -0
  236. package/browser/frame-transfer.mts +0 -19
  237. package/browser/three/agx-base-srgb.lut +0 -0
  238. package/browser/three/agx-look-medium-high-contrast.lut +0 -0
  239. package/browser/three/agx-look-punchy.lut +0 -0
  240. package/browser/three/blender-agx.ts +0 -235
  241. package/browser/three/blender-display-lut.ts +0 -145
  242. package/browser/three/blender-filmic.ts +0 -49
  243. package/browser/three/blender-standard.ts +0 -68
  244. package/browser/three/blender-world-visibility.ts +0 -278
  245. package/browser/three/filmic-srgb.lut +0 -0
@@ -1,38 +1,9 @@
1
1
  /**
2
- * The scene's own lights, and the Model document's SOLID-MODE STUDIO.
3
- *
4
- * TWO LIGHTING STATES, the same split Blender makes between its solid viewport
5
- * and a render. Modeling is lit by Blender's four view-locked studio lights
6
- * ({@link ViewportLighting}), so a model reads the same however it is orbited
7
- * while it is being built; a RENDER is lit by the scene's own lights, because
8
- * that is what the script placed them for. `setRendered` switches between
9
- * them, and a render capture is the only caller that asks for the second.
10
- *
11
- * The studio lives here rather than in the standard viewport dressing because
12
- * it is BLENDER's, not the editor's: the dressing's key is one warm light in
13
- * the world, and Solid mode's is four in view space with no world light at
14
- * all. The Model document turns the dressing's key AND its IBL off and hands
15
- * this group over as its `dressing.viewLocked` instead.
16
- *
17
- * UNITS. The frame states Blender's own values — watts, radians, metres — and
18
- * the conversion to three.js's photometric intensities happens here, derived
19
- * rather than tuned:
20
- *
21
- * - POINT and SPOT. Blender spreads `energy` watts over the whole sphere, so
22
- * irradiance at distance d is P/(4πd²); three.js gives I/d². Hence I = P/4π.
23
- * - SUN. `energy` is already an irradiance (W/m²) and a directional light's
24
- * intensity is the same quantity, so it passes through.
25
- * - AREA. P watts leave an area A into a hemisphere, so the radiance is
26
- * P/(Aπ), which is what `RectAreaLight.intensity` means. A DISK or ELLIPSE
27
- * has no rectangular equivalent, so it becomes the rectangle of EQUAL AREA:
28
- * the total emission is right and the shape of a soft shadow's edge is not.
29
- *
30
- * SHADOWS follow `use_shadow`, the same flag Cycles reads. A shadow MAP is not
31
- * a traced shadow: its softness comes from filtering a depth buffer rather than
32
- * from the light's physical size, so `shadow_soft_size` and a sun's `angle`
33
- * still reach the frame and still change nothing. A RectAreaLight casts none at
34
- * all — three.js has no shadow for it — and that is left as it is rather than
35
- * faked with a substitute light the scene never declared.
2
+ * Manage Blender scene lights and the document’s view-locked Solid studio. setRendered selects
3
+ * authored lighting; Solid mode uses the studio provided as dressing.viewLocked. Convert Blender’s
4
+ * energy units to three.js intensity here: point and spot P/(4π), sun irradiance unchanged, and
5
+ * area radiance P/(Aπ). Area shapes use equal-area rectangles; shadow support follows the
6
+ * presentation’s light-specific paths.
36
7
  */
37
8
  import * as THREE from 'three';
38
9
  import {presenterChanged} from './blender-presenter-change';
@@ -111,8 +82,10 @@ const areaExtent = (data: LightData): [number, number] => {
111
82
  return [size, size];
112
83
  };
113
84
 
114
- /** The three.js light for one Blender light datablock, or `null` when the
115
- * existing light is already the right kind and was updated in place. */
85
+ /** A sun's shadow map, on a side: the World's suns and the scene's own. */
86
+ export const SUN_SHADOW_MAP_SIZE = 2048;
87
+
88
+ /** Build or update the three.js light for one Blender light datablock. */
116
89
  export function buildLight(data: LightData, existing: THREE.Light | null): THREE.Light {
117
90
  const reuse = <T extends THREE.Light>(
118
91
  ctor: new () => T,
@@ -126,6 +99,14 @@ export function buildLight(data: LightData, existing: THREE.Light | null): THREE
126
99
  light.intensity = data.energy;
127
100
  light.color.setRGB(data.color[0], data.color[1], data.color[2], THREE.LinearSRGBColorSpace);
128
101
  light.castShadow = data.use_shadow;
102
+ // The World's sun's map size (`WorldBackground.compose`), not three's 512: a sun's map is
103
+ // fitted over everything the camera sees (`blender-runtime-shadows.ts`). Three allocates the
104
+ // map once, so a size it does not have yet replaces the map.
105
+ if (light.shadow.mapSize.x !== SUN_SHADOW_MAP_SIZE || light.shadow.mapSize.y !== SUN_SHADOW_MAP_SIZE) {
106
+ light.shadow.mapSize.set(SUN_SHADOW_MAP_SIZE, SUN_SHADOW_MAP_SIZE);
107
+ light.shadow.map?.dispose();
108
+ light.shadow.map = null;
109
+ }
129
110
  return light;
130
111
  }
131
112
  if (data.type === 'POINT') {
@@ -187,8 +168,8 @@ export function fitShadow(light: THREE.Light, centre: THREE.Vector3, radius: num
187
168
  camera.updateProjectionMatrix();
188
169
  }
189
170
 
190
- /** A Blender light aims down its own -Z; a three.js directional/spot/area
191
- * light aims at a target object. The target rides one unit down that axis. */
171
+ /** Aim directional and spot targets one unit down the authored -Z axis.
172
+ * Area lights emit along their own -Z without a target. */
192
173
  export function aimLight(light: THREE.Light, node: THREE.Object3D): void {
193
174
  // The parent owns the authored transform; directional/spot defaults are offset.
194
175
  light.position.set(0, 0, 0);
@@ -251,54 +232,8 @@ export const SOLID_LIGHTS: readonly {
251
232
  },
252
233
  ];
253
234
 
254
- /**
255
- * THE ONE CALIBRATED NUMBER in the studio, and it is one gain over all four
256
- * lights — their RELATIVE strengths, directions and wraps are Blender's own
257
- * and nothing here tunes them apart.
258
- *
259
- * It absorbs two things this side cannot state exactly. First, the workbench's
260
- * own normalization: Blender's shader is `Σ diffuseᵢ · wrap(N·Lᵢ)` times the
261
- * viewport colour PLUS a specular term over `brdf_approx`, and the measured
262
- * frame comes out at ~0.88 of the diffuse sum alone. Second, the stage's tone
263
- * curve: the editor renders through ACES (`StageHost`), Blender's viewport
264
- * through AgX, and near middle grey the two differ by a near-constant factor
265
- * (checked both ways: with one gain ACES lands the three faces within 4 levels
266
- * and AgX within 3, so the curve is NOT what row 1's 13-vs-30 spread was made
267
- * of and the stage keeps its own operator).
268
- *
269
- * THE ROUNDS, on a `cyclotron create` scaffold driven through `cyclotron
270
- * screenshot editor`, 30x30 means (std 0 — every patch inside one flat face)
271
- * of the factory cube's three visible faces against Blender 5.2's own frame,
272
- * (141,143,145) top / (129,131,131) left / (111,112,113) front:
273
- *
274
- * 0.74, ACES -> (148,150,151) (138,140,140) (108,110,110) worst 9
275
- * 0.70, ACES -> (144,146,147) (134,136,136) (105,106,106) worst 7, spread 39
276
- * 0.69, AgX -> (138,139,139) (131,132,133) (112,113,113) worst 6, spread 26
277
- *
278
- * Blender's own spread is 30. The ACES rounds are what says the tone curve
279
- * was carrying the rest of row 1's defect: at ANY gain the operator holds the
280
- * shading range 30% wider than Blender's, which is why the document names its
281
- * own (`ToolViewportDressing.toneMapping`).
282
- *
283
- * WHAT IS LEFT, measured rather than guessed: the top face reads 3 to 6
284
- * levels dark and slightly warm. Blender's workbench couples its specular to
285
- * its diffuse — the same cube with `show_specular_highlight` off renders
286
- * BRIGHTER, (146,148,149)/(136,138,138)/(114,115,116) against
287
- * (142,144,145)/(130,132,132)/(112,113,113) with it on — and three's BRDF has
288
- * no such coupling, nor a second colour per light to carry
289
- * `specular_color`. Closing that is a shader of our own, not a light rig.
290
- */
291
- /*
292
- * SURFACES NO LONGER TAKE THIS RIG IN SOLID: they are drawn by Blender's own function
293
- * (`blender-workbench-material.ts`), which reproduces Blender on the faces a single gain could
294
- * not (a face square to the view: 158-160 against Blender's 161, where this rig gave 143). The rig
295
- * still lights what that function does not draw (volume boxes). WHAT THE MEASUREMENT SHOWED about
296
- * the rounds above: the three-face reference frames match Blender's function under the STANDARD
297
- * view transform (function: 147/136/116 against 144/132/113; under AgX, 151/142/126), so they were
298
- * made under Standard, and the gain was absorbing a view-transform difference, not a shading one.
299
- * Blender's factory transform is AgX (`--factory-startup`: `view_transform` AgX), which the stage
300
- * uses.
301
- */
235
+ /** One gain across the studio lights; relative strengths, directions and wraps remain Blender’s. */
236
+ /* Solid surfaces use blender-workbench-material.ts. This rig lights content outside that shader, including volume boxes, under the stage’s view transform. */
302
237
  const SOLID_STUDIO_GAIN = 0.69;
303
238
 
304
239
  /**
@@ -437,10 +372,10 @@ export type WorldExpression =
437
372
  }
438
373
  | {
439
374
  kind: 'sky';
440
- sky_model?: 'SINGLE_SCATTERING' | 'MULTIPLE_SCATTERING' | undefined;
441
- sun_disc?: boolean | undefined;
442
- sun_size?: number | undefined;
443
- sun_intensity?: number | undefined;
375
+ sky_model: 'SINGLE_SCATTERING' | 'MULTIPLE_SCATTERING';
376
+ sun_disc: boolean;
377
+ sun_size: number;
378
+ sun_intensity: number;
444
379
  sun_elevation: number;
445
380
  sun_rotation: number;
446
381
  altitude: number;
@@ -537,10 +472,10 @@ const worldExpression: z.ZodType<WorldExpression> = z.lazy(() =>
537
472
  z
538
473
  .object({
539
474
  kind: z.literal('sky'),
540
- sky_model: z.enum(['SINGLE_SCATTERING', 'MULTIPLE_SCATTERING']).optional(),
541
- sun_disc: z.boolean().optional(),
542
- sun_size: scalar.optional(),
543
- sun_intensity: scalar.optional(),
475
+ sky_model: z.enum(['SINGLE_SCATTERING', 'MULTIPLE_SCATTERING']),
476
+ sun_disc: z.boolean(),
477
+ sun_size: scalar,
478
+ sun_intensity: scalar,
544
479
  sun_elevation: scalar,
545
480
  sun_rotation: scalar,
546
481
  altitude: scalar,
@@ -574,36 +509,10 @@ export const worldSchema = worldRadiance
574
509
  })
575
510
  .strict();
576
511
 
577
- /** Compile once; the texture loop only evaluates directions in Blender's Z-up basis. */
578
-
579
- /** Sampled world radiance, by the description that produced it.
580
- *
581
- * Sampling is 32,768 texels and, for a SKY, each texel runs the
582
- * multiple-scattering model -- synchronously, on the page's main thread.
583
- * MEASURED: a capture of a scene with a sky world cost 15.4s against 0.03s for
584
- * the same scene with a plain background, and three identical captures in a row
585
- * each paid it in full. That is why the page froze solid during a replay and
586
- * why the photograph was SLOWER than the Cycles path trace it is compared
587
- * against (15.4s vs 7.6s) -- for a screenshot.
588
- *
589
- * The DATA is cached rather than the texture, because `clear()` disposes
590
- * textures and a disposed texture must never be handed out again; a Uint16Array
591
- * survives disposal and uploading it again is free. Keyed by the description
592
- * itself, so any change to the sky (or to strength) rebuilds and an unchanged
593
- * one never does. A handful of entries covers the background/lighting pair a
594
- * split world needs without letting the map grow with the session. */
512
+ /** Cache sampled world-radiance arrays by description. Arrays survive texture disposal and can be uploaded into a new texture; bounded entries cover split background and lighting worlds. */
595
513
  const WORLD_FIELD_CACHE_LIMIT = 4;
596
514
  const worldFieldCache = new Map<string, Float32Array>();
597
515
 
598
- /** Sky derivations still running. `worldReady()` is what a RENDER awaits, the
599
- * same way it already awaits `lightingReady()` and `texturesReady()` -- a
600
- * photograph gets one chance and must not catch a half-built sky. */
601
- const pendingWorld = new Set<Promise<void>>();
602
-
603
- export async function worldReady(): Promise<void> {
604
- while (pendingWorld.size > 0) await Promise.all([...pendingWorld]);
605
- }
606
-
607
516
  function worldTexture(
608
517
  expression: WorldExpression,
609
518
  strength: number,
@@ -611,11 +520,9 @@ function worldTexture(
611
520
  ): THREE.DataTexture {
612
521
  const width = 256,
613
522
  height = 128;
614
- // KEYED ON THE EXPRESSION ALONE. Strength is a multiplier applied per texel
615
- // below, so it cannot change what the field SAMPLES -- only what they are
616
- // scaled by. Keying on it too made a strength-only change re-derive the whole
617
- // sky, and made a world split by `Is Camera Ray` (one sky, two strengths)
618
- // build that sky TWICE where once would do.
523
+ // Cache unscaled field samples by expression and any camera coordinates.
524
+ // Strength is applied per texture, so background and lighting branches at
525
+ // different strengths share the same sampled sky.
619
526
  const windowCoordinates = usesWindowCoordinates(expression);
620
527
  if (windowCoordinates && !camera)
621
528
  throw new Error('World Window coordinates need a render camera');
@@ -626,19 +533,7 @@ function worldTexture(
626
533
  ]);
627
534
  const cached = worldFieldCache.get(key);
628
535
  if (cached !== undefined) return worldDataTexture(cached, strength, width, height);
629
- // SYNCHRONOUS, AND THAT IS THE POINT. The expensive half -- each Sky Texture's
630
- // 512x256 multiple-scattering precompute -- has already been derived in the
631
- // worker and primed into `blender-sky`'s cache by the time anything calls
632
- // this; see `WorldBackground.apply`. What is left here is 32,768 bilinear
633
- // lookups, which is milliseconds.
634
- //
635
- // It stays synchronous because the alternative is what shipped once and was
636
- // taken back out (#6609): the texture handed out EMPTY and filled when the
637
- // worker answered, with the capture racing the fill. Measured on pixels, a
638
- // fresh sky rendered to 716 bytes and moving the sun produced the
639
- // byte-identical empty image. A render gets one chance. Nothing here ever
640
- // hands out a texture it has not already filled; the waiting happens one level
641
- // up, where `pendingWorld` can hold the capture.
536
+ // Sky precomputation is primed by the worker before this synchronous sampling pass. Return only filled textures; WorldBackground.ready holds captures until the preparation finishes.
642
537
  const windowPoint = new THREE.Vector3();
643
538
  const projected = new THREE.Vector4();
644
539
  const projection = camera
@@ -671,9 +566,6 @@ function worldTexture(
671
566
  return worldDataTexture(samples, strength, width, height);
672
567
  }
673
568
 
674
- /** One sampled field, scaled, as a texture. The samples are UNSCALED and shared
675
- * between textures; the multiply and the half-float conversion happen here, so
676
- * two strengths over one sky cost one derivation and two cheap passes. */
677
569
  /** Scale and convert, in one pass. The multiply happens BEFORE the half-float
678
570
  * conversion, or a dim fill branch would quantise against a bright backdrop's
679
571
  * range. */
@@ -692,6 +584,8 @@ function writeScaled(
692
584
  }
693
585
  }
694
586
 
587
+ /** Scale shared radiance samples into a texture, using float32 when the scaled
588
+ * field exceeds half-float range. */
695
589
  function worldDataTexture(
696
590
  samples: Float32Array,
697
591
  strength: number,
@@ -724,7 +618,6 @@ export type WorldData = z.infer<typeof worldSchema>;
724
618
  * Constants and linked colors use the same radiometric path. The environment
725
619
  * samples the field at 256×128; features below a texel are filtered. */
726
620
  export class WorldBackground {
727
- lightingTexture():THREE.Texture|null {return this.environmentTexture??this.texture;}
728
621
  readonly solarLights: THREE.DirectionalLight[] = [];
729
622
  private readonly solarDirections = new Map<THREE.DirectionalLight, THREE.Vector3>();
730
623
 
@@ -763,23 +656,7 @@ export class WorldBackground {
763
656
  * after the world moved on knows it is stale and composes nothing. */
764
657
  private generation = 0;
765
658
 
766
- /**
767
- * THE ASYNC SEAM, and the only one.
768
- *
769
- * Every Sky Texture in this world is derived in a worker BEFORE anything is
770
- * composed. When all of them are already in `blender-sky`'s cache the whole
771
- * apply runs synchronously, exactly as it did inline. When any is missing,
772
- * composition is DEFERRED behind the worker and the promise is registered in
773
- * `pendingWorld` SYNCHRONOUSLY, before this method returns -- which is what
774
- * makes it impossible for a caller to reach `worldReady()` in between.
775
- * `BlenderRuntimeView.setRendered` awaits `worldReady()` AFTER the applies for
776
- * this reason, and that ordering must not move.
777
- *
778
- * The failure this is shaped against (#6609) was a texture handed out empty
779
- * and filled later. Here nothing is handed out early at all: until the sky
780
- * exists, `scene.background` is simply not assigned, and the capture that
781
- * awaits `worldReady()` cannot run until it is.
782
- */
659
+ /** Prepare missing sky textures in the worker and publish pending synchronously before returning. Assign the composed background only after preparation; captures await ready after apply. */
783
660
  apply(root: THREE.Object3D, world: WorldData | null, camera?: THREE.Camera): void {
784
661
  this.clear();
785
662
  this.pending = null;
@@ -812,22 +689,18 @@ export class WorldBackground {
812
689
  presenterChanged();
813
690
  })();
814
691
  this.pending = pending;
815
- pendingWorld.add(pending);
816
- // Both arms, or a rejection here is an unhandled one; `worldReady()` still
817
- // awaits `pending` itself, so the failure reaches the capture LOUDLY rather
818
- // than leaving it to photograph a scene with no sky.
819
- void pending.then(
820
- () => pendingWorld.delete(pending),
821
- () => pendingWorld.delete(pending),
822
- );
692
+ // Handled here, or a rejection nobody has awaited yet is an unhandled one;
693
+ // `ready()` still awaits `pending` itself, so the failure reaches the
694
+ // capture LOUDLY rather than leaving it to photograph a scene with no sky.
695
+ pending.catch(() => {});
823
696
  }
824
697
 
825
698
  private compose(
826
699
  scene: THREE.Scene,
827
700
  world: WorldData,
828
701
  expression: WorldExpression,
829
- camera?: THREE.Camera,
830
- root?: THREE.Object3D,
702
+ camera: THREE.Camera | undefined,
703
+ root: THREE.Object3D,
831
704
  ): void {
832
705
  this.texture = worldTexture(expression, world.strength, camera);
833
706
  if (usesWindowCoordinates(expression)) {
@@ -853,11 +726,8 @@ export class WorldBackground {
853
726
  } else {
854
727
  scene.background = this.texture;
855
728
  }
856
- // Replace the document environment so the model is not lit twice. When
857
- // `Is Camera Ray` split the world, what LIGHTS the scene is a different
858
- // radiance from what the camera photographs -- `19-tram-stop` lights at
859
- // 0.30 behind a 0.95 backdrop -- and three keeps the two apart, so using
860
- // the backdrop here would light that scene three times too brightly.
729
+ // Replace the document environment. A world split by `Is Camera Ray`
730
+ // supplies separate lighting and backdrop radiance.
861
731
  const { lighting } = world;
862
732
  const lightingExpression = lighting?.shader ?? lighting?.color ?? expression;
863
733
  const solar = worldSolarIrradiance(lightingExpression);
@@ -866,15 +736,15 @@ export class WorldBackground {
866
736
  lighting?.strength ?? world.strength, camera)
867
737
  : null;
868
738
  scene.environment = this.environmentTexture ?? this.texture;
869
- root?.updateMatrixWorld(true);
739
+ root.updateMatrixWorld(true);
870
740
  for (const {direction, irradiance} of solar) {
871
741
  const light = new THREE.DirectionalLight(0xffffff, lighting?.strength ?? world.strength);
872
742
  light.name = 'BlenderWorldSun';
873
743
  light.color.setRGB(irradiance.x, irradiance.y, irradiance.z, THREE.LinearSRGBColorSpace);
874
744
  light.position.copy(direction);
875
- if (root) light.position.transformDirection(root.matrixWorld);
745
+ light.position.transformDirection(root.matrixWorld);
876
746
  light.castShadow = true;
877
- light.shadow.mapSize.set(2048, 2048);
747
+ light.shadow.mapSize.set(SUN_SHADOW_MAP_SIZE, SUN_SHADOW_MAP_SIZE);
878
748
  scene.add(light, light.target);
879
749
  this.solarLights.push(light);
880
750
  this.solarDirections.set(light, direction.clone());
@@ -71,7 +71,8 @@ export function fitModelDirectionalShadow(
71
71
  );
72
72
  const size = bounds.getSize(new THREE.Vector3());
73
73
  const epsilon = Math.max(size.length() * 1e-6, Number.EPSILON);
74
- // PCFSoft reaches two texels per axis; the normal offset can move the
74
+ // PCF (r186: five taps on a disk of `shadow.radius`, 1 texel, each a
75
+ // hardware 2x2 compare) reaches two texels per axis; the normal offset can move the
75
76
  // lookup another 2*sqrt(2) texels. Reserve that same footprint for both
76
77
  // the map and the casters that can contribute to its edge samples.
77
78
  const support = 2 + 2 * Math.SQRT2;
@@ -110,3 +111,188 @@ export function fitModelDirectionalShadow(
110
111
  camera.updateProjectionMatrix();
111
112
  light.shadow.normalBias = normalBias;
112
113
  }
114
+
115
+ /**
116
+ * HOW FAR A POINT OR SPOT LIGHT REACHES, as EEVEE cuts it off (`Light::attenuation_radius_get`): where its power,
117
+ * falling with the square of the distance, drops under the scene's light threshold, sqrt(max colour · watts / 100 /
118
+ * threshold) metres, at EEVEE's default threshold (0.01, which the frame does not carry), so sqrt(max colour · watts).
119
+ * In three.js's terms the watts are 4π times the intensity (`blender-runtime-lighting.ts`). A three.js cut-off
120
+ * (`distance`), when one is set, is a reach too.
121
+ */
122
+ export function perspectiveLightReach(light: THREE.PointLight | THREE.SpotLight): number {
123
+ const watts = 4 * Math.PI * Math.max(0, light.intensity);
124
+ const reach = Math.sqrt(Math.max(light.color.r, light.color.g, light.color.b, 0) * watts / 100 / EEVEE_LIGHT_THRESHOLD);
125
+ return light.distance > 0 ? Math.min(light.distance, reach) : reach;
126
+ }
127
+
128
+ /** EEVEE's default light threshold (`scene.eevee.light_threshold`): below it a light lights nothing. */
129
+ const EEVEE_LIGHT_THRESHOLD = 0.01;
130
+
131
+ /** Find the farthest visible shadow receiver, skipping boxes whose farthest corner cannot improve the current result. Return a one-point array, or an empty array when no receiver is visible. */
132
+ export function farthestVisibleReceiver(boxes: readonly THREE.Box3[], frustum: THREE.Frustum,
133
+ from: THREE.Vector3): THREE.Vector3[] {
134
+ // A box's farthest corner from `from`: on each axis, the face farther from it.
135
+ const farthest = (box: THREE.Box3): number => box.isEmpty() ? -1 : Math.hypot(
136
+ Math.max(Math.abs(box.min.x - from.x), Math.abs(box.max.x - from.x)),
137
+ Math.max(Math.abs(box.min.y - from.y), Math.abs(box.max.y - from.y)),
138
+ Math.max(Math.abs(box.min.z - from.z), Math.abs(box.max.z - from.z)));
139
+ const reach = boxes.map((box) => ({ box, far: farthest(box) })).sort((a, b) => b.far - a.far);
140
+ let best = -1;
141
+ let point: THREE.Vector3 | null = null;
142
+ for (const { box, far } of reach) {
143
+ if (far <= best) break;
144
+ for (const candidate of visibleShadowReceivers(box, frustum)) {
145
+ const distance = candidate.distanceTo(from);
146
+ if (distance > best) { best = distance; point = candidate; }
147
+ }
148
+ }
149
+ return point ? [point] : [];
150
+ }
151
+
152
+ /** Size a point or spot light’s normal offset from a shadow-map texel at its farthest visible lit receiver. With no receiver, use the nearer of the light’s reach and far plane. Point maps have 90-degree faces; spot maps span 2 × angle × focus. */
153
+ export function fitPerspectiveShadowBias(
154
+ light: THREE.PointLight | THREE.SpotLight,
155
+ receivers: readonly THREE.Vector3[],
156
+ ): void {
157
+ if (!light.castShadow) return;
158
+ const position = light.getWorldPosition(new THREE.Vector3());
159
+ const far = (light.shadow.camera as THREE.PerspectiveCamera).far;
160
+ const reach = Math.min(perspectiveLightReach(light), far);
161
+ let distance = 0;
162
+ for (const point of receivers) distance = Math.max(distance, point.distanceTo(position));
163
+ distance = distance > 0 ? Math.min(distance, reach) : reach;
164
+ const half = (light as THREE.SpotLight).isSpotLight
165
+ ? Math.min((light as THREE.SpotLight).angle * (light.shadow as THREE.SpotLightShadow).focus, Math.PI / 2 - 1e-3)
166
+ : Math.PI / 4;
167
+ const size = Math.max(1, Math.min(light.shadow.mapSize.x, light.shadow.mapSize.y));
168
+ const texel = (2 * distance * Math.tan(half)) / size;
169
+ light.shadow.normalBias = 2 * Math.SQRT2 * texel;
170
+ }
171
+
172
+ const RECEIVER_VERTEX = /* glsl */ `
173
+ #include <common>
174
+ #include <batching_pars_vertex>
175
+ #include <morphtarget_pars_vertex>
176
+ #include <skinning_pars_vertex>
177
+ #include <logdepthbuf_pars_vertex>
178
+ #include <clipping_planes_pars_vertex>
179
+ varying vec3 vReceiver;
180
+ void main() {
181
+ #include <batching_vertex>
182
+ #include <skinbase_vertex>
183
+ #include <morphinstance_vertex>
184
+ #include <begin_vertex>
185
+ #include <morphtarget_vertex>
186
+ #include <skinning_vertex>
187
+ #include <project_vertex>
188
+ #include <logdepthbuf_vertex>
189
+ #include <clipping_planes_vertex>
190
+ vReceiver = mvPosition.xyz;
191
+ }
192
+ `;
193
+
194
+ const RECEIVER_FRAGMENT = /* glsl */ `
195
+ varying vec3 vReceiver;
196
+ #include <logdepthbuf_pars_fragment>
197
+ #include <clipping_planes_pars_fragment>
198
+ void main() {
199
+ #include <clipping_planes_fragment>
200
+ #include <logdepthbuf_fragment>
201
+ gl_FragColor = vec4(vReceiver, 1.0);
202
+ }
203
+ `;
204
+
205
+ /** Samples across the drawing's width; the height keeps the drawing's aspect. */
206
+ const RECEIVER_SAMPLES = 256;
207
+
208
+ /**
209
+ * Fit sun shadows to the surfaces visible through the drawing camera, following EEVEE’s
210
+ * receiver-depth approach. Render view-space positions at a small sample grid spanning the
211
+ * drawing’s edges, then return those samples in world space. Hidden surfaces are excluded so the
212
+ * geometry behind them can contribute.
213
+ */
214
+ export class VisibleReceivers {
215
+ private target: THREE.WebGLRenderTarget | null = null;
216
+ private pixels = new Float32Array(0);
217
+ private readonly camera = new THREE.Camera();
218
+ private readonly material = new THREE.ShaderMaterial({
219
+ name: 'Blender visible receivers',
220
+ vertexShader: RECEIVER_VERTEX,
221
+ fragmentShader: RECEIVER_FRAGMENT,
222
+ });
223
+
224
+ /** World-space points of what `camera` sees of `scene`; empty when nothing (or no readable
225
+ * float target) answered, which a caller treats as "no samples". */
226
+ sample(
227
+ renderer: THREE.WebGLRenderer,
228
+ scene: THREE.Scene,
229
+ camera: THREE.Camera,
230
+ hidden: readonly THREE.Object3D[],
231
+ ): THREE.Vector3[] {
232
+ const output = renderer.getRenderTarget();
233
+ const size = output ? new THREE.Vector2(output.width, output.height) : renderer.getDrawingBufferSize(new THREE.Vector2());
234
+ const width = RECEIVER_SAMPLES;
235
+ const height = Math.max(2, Math.round((RECEIVER_SAMPLES * size.y) / Math.max(1, size.x)));
236
+ this.target ??= new THREE.WebGLRenderTarget(width, height, {
237
+ type: THREE.FloatType, minFilter: THREE.NearestFilter, magFilter: THREE.NearestFilter,
238
+ generateMipmaps: false, depthBuffer: true,
239
+ });
240
+ if (this.target.width !== width || this.target.height !== height) this.target.setSize(width, height);
241
+ // The outermost sample centres on the drawing's own edges: scaling the projection's x and y
242
+ // by (n - 1) / n puts sample i of n at (2i + 1 - n) / (n - 1) in the drawing's NDC.
243
+ camera.updateMatrixWorld();
244
+ const probe = this.camera;
245
+ probe.matrixAutoUpdate = false;
246
+ probe.matrix.copy(camera.matrixWorld);
247
+ probe.matrixWorld.copy(camera.matrixWorld);
248
+ probe.matrixWorldInverse.copy(camera.matrixWorld).invert();
249
+ probe.projectionMatrix.copy(camera.projectionMatrix)
250
+ .premultiply(new THREE.Matrix4().makeScale((width - 1) / width, (height - 1) / height, 1));
251
+ probe.projectionMatrixInverse.copy(probe.projectionMatrix).invert();
252
+ probe.layers.mask = camera.layers.mask;
253
+ const count = width * height * 4;
254
+ if (this.pixels.length !== count) this.pixels = new Float32Array(count);
255
+ this.pixels.fill(0);
256
+ const visible = hidden.map((object) => object.visible);
257
+ const background = scene.background;
258
+ const override = scene.overrideMaterial;
259
+ const autoClear = renderer.autoClear;
260
+ const shadows = renderer.shadowMap.enabled;
261
+ const clearColor = renderer.getClearColor(new THREE.Color());
262
+ const clearAlpha = renderer.getClearAlpha();
263
+ try {
264
+ for (const object of hidden) object.visible = false;
265
+ scene.background = null;
266
+ scene.overrideMaterial = this.material;
267
+ renderer.autoClear = false;
268
+ renderer.shadowMap.enabled = false;
269
+ renderer.setRenderTarget(this.target);
270
+ renderer.setClearColor(0x000000, 0);
271
+ renderer.clear(true, true, false);
272
+ renderer.render(scene, probe);
273
+ renderer.readRenderTargetPixels(this.target, 0, 0, width, height, this.pixels);
274
+ } finally {
275
+ hidden.forEach((object, index) => { object.visible = visible[index]!; });
276
+ scene.background = background;
277
+ scene.overrideMaterial = override;
278
+ renderer.autoClear = autoClear;
279
+ renderer.shadowMap.enabled = shadows;
280
+ renderer.setClearColor(clearColor, clearAlpha);
281
+ renderer.setRenderTarget(output);
282
+ }
283
+ const points: THREE.Vector3[] = [];
284
+ for (let i = 0; i < width * height; i++) {
285
+ if (this.pixels[i * 4 + 3]! === 0) continue;
286
+ points.push(new THREE.Vector3(this.pixels[i * 4]!, this.pixels[i * 4 + 1]!, this.pixels[i * 4 + 2]!)
287
+ .applyMatrix4(camera.matrixWorld));
288
+ }
289
+ return points;
290
+ }
291
+
292
+ dispose(): void {
293
+ this.target?.dispose();
294
+ this.target = null;
295
+ this.material.dispose();
296
+ this.pixels = new Float32Array(0);
297
+ }
298
+ }
@@ -74,8 +74,57 @@ export function skinGeometry(geometry: THREE.BufferGeometry, skin: FrameSkin): v
74
74
  geometry.userData['blenderSkin'] = { armature: skin.armature, groups: [...skin.groups] };
75
75
  }
76
76
 
77
+ /**
78
+ * A SKINNED MESH'S REACH: how far any of its vertices lies from a bone that moves it, at the bind.
79
+ * Skinning moves a vertex rigidly with each bone and blends the results, so wherever the bones go,
80
+ * every vertex stays within this distance of the space the bones span. `fitBounds` makes the
81
+ * frame's bounding sphere from it.
82
+ */
83
+ function skinReach(skinned: THREE.SkinnedMesh): number {
84
+ const position = skinned.geometry.getAttribute('position');
85
+ const index = skinned.geometry.getAttribute('skinIndex');
86
+ const weight = skinned.geometry.getAttribute('skinWeight');
87
+ if (!position || !index || !weight) return Infinity;
88
+ const joints = skinned.skeleton.boneInverses.map((inverse) =>
89
+ new THREE.Vector3().setFromMatrixPosition(inverse.clone().invert()));
90
+ const vertex = new THREE.Vector3();
91
+ let reach = 0;
92
+ for (let i = 0; i < position.count; i++) {
93
+ vertex.fromBufferAttribute(position, i).applyMatrix4(skinned.bindMatrix);
94
+ for (let k = 0; k < 4; k++) {
95
+ if (weight.getComponent(i, k) <= 0) continue;
96
+ const joint = joints[index.getComponent(i, k)];
97
+ if (joint) reach = Math.max(reach, vertex.distanceTo(joint));
98
+ }
99
+ }
100
+ return reach;
101
+ }
102
+
77
103
  export class RuntimeSkeletons {
78
104
  #rigs = new Map<string, Rig>();
105
+ /** Every mesh `bind` skinned, with its reach (`skinReach`). */
106
+ #bound = new Map<THREE.SkinnedMesh, number>();
107
+ readonly #box = new THREE.Box3();
108
+ readonly #point = new THREE.Vector3();
109
+ readonly #inverse = new THREE.Matrix4();
110
+
111
+ /** Refresh skinned-mesh culling spheres from current world-space bone positions plus each mesh’s reach. prepareDraw calls this after updateMatrixWorld. */
112
+ fitBounds(): void {
113
+ for (const [skinned, reach] of this.#bound) {
114
+ // Disable culling for detached meshes and skins whose reach cannot be read.
115
+ if (!skinned.parent) { skinned.frustumCulled = false; this.#bound.delete(skinned); continue; }
116
+ if (!Number.isFinite(reach)) { skinned.frustumCulled = false; continue; }
117
+ const box = this.#box.makeEmpty();
118
+ for (const bone of skinned.skeleton.bones) box.expandByPoint(this.#point.setFromMatrixPosition(bone.matrixWorld));
119
+ const sphere = skinned.boundingSphere ?? (skinned.boundingSphere = new THREE.Sphere());
120
+ box.getBoundingSphere(sphere);
121
+ sphere.radius += reach;
122
+ // Into the mesh's own space, where three reads the sphere (`Frustum.intersectsObject`).
123
+ this.#inverse.copy(skinned.matrixWorld).invert();
124
+ sphere.applyMatrix4(this.#inverse);
125
+ skinned.frustumCulled = true;
126
+ }
127
+ }
79
128
 
80
129
  /** The rig of one armature, or null when the frame names none by that name. */
81
130
  rig(armature: string): ArmatureRig | null {
@@ -130,7 +179,7 @@ export class RuntimeSkeletons {
130
179
  rig = { armature: name, object, root, still, bones, structure, pose: new Map(), poseKey: '', action: null };
131
180
  this.#rigs.set(name, rig);
132
181
  }
133
- rig.action = armature.action ?? null;
182
+ rig.action = armature.action;
134
183
  const poseKey = armature.bones.map((bone) => bone.matrix.flat().join(',')).join(';');
135
184
  if (poseKey !== rig.poseKey) {
136
185
  rig.poseKey = poseKey;
@@ -196,11 +245,14 @@ export class RuntimeSkeletons {
196
245
  // A re-export rebinds the same mesh: its old skeleton's bone texture goes with it.
197
246
  (skinned.skeleton as THREE.Skeleton | undefined)?.dispose();
198
247
  skinned.bind(new THREE.Skeleton(bones, inverses), mesh.matrixWorld.clone());
248
+ if (skinned !== mesh) this.#bound.delete(mesh as THREE.SkinnedMesh);
249
+ this.#bound.set(skinned, skinReach(skinned));
199
250
  return skinned;
200
251
  }
201
252
 
202
253
  dispose(): void {
203
254
  for (const rig of this.#rigs.values()) rig.root.removeFromParent();
204
255
  this.#rigs.clear();
256
+ this.#bound.clear();
205
257
  }
206
258
  }