meltygui 0.1.0__py3-none-any.whl

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 (372) hide show
  1. meltygui/__init__.py +107 -0
  2. meltygui/accounts/__init__.py +0 -0
  3. meltygui/accounts/internet_accounts.py +1355 -0
  4. meltygui/chat/__init__.py +91 -0
  5. meltygui/chat/activity.py +75 -0
  6. meltygui/chat/backends.py +36 -0
  7. meltygui/chat/chat_interface.py +732 -0
  8. meltygui/chat/chat_proxy.py +352 -0
  9. meltygui/chat/codex_proxy.py +592 -0
  10. meltygui/chat/codex_settings.py +100 -0
  11. meltygui/chat/codex_transport.py +60 -0
  12. meltygui/chat/command_parser.py +204 -0
  13. meltygui/chat/images.py +227 -0
  14. meltygui/chat/messages.py +417 -0
  15. meltygui/chat/metadata.py +139 -0
  16. meltygui/chat/writer_locks.py +64 -0
  17. meltygui/code/__init__.py +0 -0
  18. meltygui/code/basic_converters.py +533 -0
  19. meltygui/code/chain_converters.py +2111 -0
  20. meltygui/code/code_checks.py +2209 -0
  21. meltygui/code/core_syntax.py +1430 -0
  22. meltygui/code/file_converters.py +1933 -0
  23. meltygui/code/fileref.py +702 -0
  24. meltygui/code/hotswap_guard.py +144 -0
  25. meltygui/code/libcst_conversion.py +9724 -0
  26. meltygui/code/live_instrument.py +392 -0
  27. meltygui/code/live_view.py +2490 -0
  28. meltygui/code/melty_scan.py +2684 -0
  29. meltygui/code/new_codecs.py +1255 -0
  30. meltygui/code/new_converters.py +3017 -0
  31. meltygui/code/project_code.py +278 -0
  32. meltygui/code/source_context.py +63 -0
  33. meltygui/code/symbol_roster.py +1588 -0
  34. meltygui/code/syntax_check.py +34 -0
  35. meltygui/code/syntax_check_worker.py +114 -0
  36. meltygui/completion/__init__.py +0 -0
  37. meltygui/completion/fim.py +1232 -0
  38. meltygui/completion/fim_context.py +481 -0
  39. meltygui/completion/providers/__init__.py +0 -0
  40. meltygui/completion/providers/anthropic_oauth.py +446 -0
  41. meltygui/completion/providers/anthropic_requests.py +69 -0
  42. meltygui/completion/providers/claude.py +203 -0
  43. meltygui/completion/providers/claude_usage.py +499 -0
  44. meltygui/completion/providers/codex_accounts.py +180 -0
  45. meltygui/completion/providers/copilot.py +617 -0
  46. meltygui/completion/providers/oauth_popup.py +220 -0
  47. meltygui/completion/providers/ollama.py +320 -0
  48. meltygui/completion/providers/profiles.py +23 -0
  49. meltygui/core/README.md +88 -0
  50. meltygui/core/__init__.py +1 -0
  51. meltygui/core/automation/__init__.py +1 -0
  52. meltygui/core/automation/action_core.py +153 -0
  53. meltygui/core/automation/collection_action.py +45 -0
  54. meltygui/core/automation/mcp_eval.py +167 -0
  55. meltygui/core/automation/mcp_hotswap.py +107 -0
  56. meltygui/core/automation/mcp_query.py +552 -0
  57. meltygui/core/automation/mcp_server.py +657 -0
  58. meltygui/core/automation/orchestration_core.py +2636 -0
  59. meltygui/core/automation/query_core.py +124 -0
  60. meltygui/core/automation/search_core.py +26 -0
  61. meltygui/core/automation/selector_core.py +340 -0
  62. meltygui/core/automation/value_core.py +1752 -0
  63. meltygui/core/cache/__init__.py +1 -0
  64. meltygui/core/cache/cache_diagnostics.py +0 -0
  65. meltygui/core/cache/invalidation_decoration.py +153 -0
  66. meltygui/core/cache/invalidation_tracker.py +43 -0
  67. meltygui/core/cache/tile_cache.py +5968 -0
  68. meltygui/core/conversion/__init__.py +1 -0
  69. meltygui/core/conversion/bubbling.py +599 -0
  70. meltygui/core/conversion/cache_tree.py +188 -0
  71. meltygui/core/conversion/chain.py +113 -0
  72. meltygui/core/conversion/converter_register.py +145 -0
  73. meltygui/core/conversion/data_decoration.py +67 -0
  74. meltygui/core/conversion/dict_conversion.py +1815 -0
  75. meltygui/core/conversion/dict_conversion_util.py +177 -0
  76. meltygui/core/conversion/dynamic_obj.py +89 -0
  77. meltygui/core/conversion/graph_compare.py +183 -0
  78. meltygui/core/conversion/load_save_v2.py +1032 -0
  79. meltygui/core/conversion/missing_saved_class.py +39 -0
  80. meltygui/core/conversion/path_finder.py +606 -0
  81. meltygui/core/conversion/render_host.py +1083 -0
  82. meltygui/core/core_render.py +6537 -0
  83. meltygui/core/definition_hotswap.py +231 -0
  84. meltygui/core/diagnostics/__init__.py +1 -0
  85. meltygui/core/diagnostics/attribute_churn.py +38 -0
  86. meltygui/core/diagnostics/fps_counter.py +39 -0
  87. meltygui/core/diagnostics/gpu_frame_timer.py +103 -0
  88. meltygui/core/diagnostics/inspection_core.py +169 -0
  89. meltygui/core/diagnostics/monitor_core.py +105 -0
  90. meltygui/core/diagnostics/notifications.py +706 -0
  91. meltygui/core/diagnostics/perf_trace.py +281 -0
  92. meltygui/core/diagnostics/profile_decoration.py +88 -0
  93. meltygui/core/diagnostics/resize_trace.py +62 -0
  94. meltygui/core/diagnostics/screenshot_core.py +247 -0
  95. meltygui/core/diagnostics/session_status.py +98 -0
  96. meltygui/core/diagnostics/trace_core.py +445 -0
  97. meltygui/core/files/__init__.py +1 -0
  98. meltygui/core/files/file_core.py +208 -0
  99. meltygui/core/files/file_explorer_core.py +104 -0
  100. meltygui/core/files/file_tree_core.py +198 -0
  101. meltygui/core/files/file_watch_core.py +43 -0
  102. meltygui/core/files/import_graph_core.py +43 -0
  103. meltygui/core/files/metadata_core.py +51 -0
  104. meltygui/core/graphics/__init__.py +1 -0
  105. meltygui/core/graphics/cuda_context_core.py +166 -0
  106. meltygui/core/graphics/cuda_interop_core.py +136 -0
  107. meltygui/core/graphics/cuda_kernel_core.py +91 -0
  108. meltygui/core/graphics/framebuffer_recorder.py +337 -0
  109. meltygui/core/graphics/gl_state.py +658 -0
  110. meltygui/core/graphics/lut_core.py +52 -0
  111. meltygui/core/graphics/overlay_renderer.py +984 -0
  112. meltygui/core/graphics/scene_target.py +180 -0
  113. meltygui/core/graphics/screenshot.py +439 -0
  114. meltygui/core/graphics/shader_func.py +478 -0
  115. meltygui/core/graphics/tensor_core.py +45 -0
  116. meltygui/core/graphics/text_texture.py +329 -0
  117. meltygui/core/graphics/wayland_color.py +635 -0
  118. meltygui/core/input/__init__.py +1 -0
  119. meltygui/core/input/collision.py +165 -0
  120. meltygui/core/input/drag_drop_core.py +1525 -0
  121. meltygui/core/input/hypr_left_drag.py +323 -0
  122. meltygui/core/input/input_core.py +245 -0
  123. meltygui/core/input/input_handler.py +1101 -0
  124. meltygui/core/input/mouse_cursor.py +355 -0
  125. meltygui/core/input/pynput_backend.py +1054 -0
  126. meltygui/core/input/space_mouse.py +338 -0
  127. meltygui/core/input/touchpad_backend.py +393 -0
  128. meltygui/core/input/view_selection.py +177 -0
  129. meltygui/core/layout/__init__.py +1 -0
  130. meltygui/core/layout/column_core.py +2153 -0
  131. meltygui/core/layout/cursor_core.py +161 -0
  132. meltygui/core/layout/dropdown_core.py +278 -0
  133. meltygui/core/layout/edge_constraints.py +155 -0
  134. meltygui/core/layout/grid_core.py +117 -0
  135. meltygui/core/layout/header_core.py +23 -0
  136. meltygui/core/layout/header_runtime.py +67 -0
  137. meltygui/core/layout/layout_core.py +87 -0
  138. meltygui/core/layout/tile_manager_core.py +591 -0
  139. meltygui/core/melty.py +6726 -0
  140. meltygui/core/module_map.json +896 -0
  141. meltygui/core/module_names.py +19 -0
  142. meltygui/core/rendering/__init__.py +1 -0
  143. meltygui/core/rendering/core_decoration.py +431 -0
  144. meltygui/core/rendering/core_render_helpers.py +328 -0
  145. meltygui/core/rendering/func_metadata.py +398 -0
  146. meltygui/core/rendering/mode.py +818 -0
  147. meltygui/core/rendering/mode_defaults.py +41 -0
  148. meltygui/core/rendering/modes.py +136 -0
  149. meltygui/core/rendering/parameter_core.py +1665 -0
  150. meltygui/core/rendering/render_dispatch.py +1891 -0
  151. meltygui/core/rendering/render_funcs.py +273 -0
  152. meltygui/core/rendering/shaped.py +312 -0
  153. meltygui/core/rendering/window_decoration.py +25 -0
  154. meltygui/core/runtime/__init__.py +1 -0
  155. meltygui/core/runtime/app.py +735 -0
  156. meltygui/core/runtime/app_session.py +140 -0
  157. meltygui/core/runtime/background.py +564 -0
  158. meltygui/core/runtime/extensions.py +53 -0
  159. meltygui/core/runtime/gc_manager.py +1163 -0
  160. meltygui/core/runtime/lifecycle.py +21 -0
  161. meltygui/core/runtime/paths.py +27 -0
  162. meltygui/core/runtime/settings.py +14 -0
  163. meltygui/core/runtime/singleton.py +16 -0
  164. meltygui/core/runtime/thread_safe_bool.py +24 -0
  165. meltygui/core/runtime/thread_signal.py +30 -0
  166. meltygui/core/runtime/toggles.py +3115 -0
  167. meltygui/core/services/__init__.py +1 -0
  168. meltygui/core/services/account_core.py +10 -0
  169. meltygui/core/services/chat_core.py +19 -0
  170. meltygui/core/services/claude_terminal_core.py +346 -0
  171. meltygui/core/services/terminal_core.py +458 -0
  172. meltygui/core/services/terminal_runtime.py +78 -0
  173. meltygui/core/styling/__init__.py +1 -0
  174. meltygui/core/styling/color_core.py +46 -0
  175. meltygui/core/styling/fonts.py +639 -0
  176. meltygui/core/styling/global_style.py +338 -0
  177. meltygui/core/styling/style.py +198 -0
  178. meltygui/core/styling/style_core.py +522 -0
  179. meltygui/core/styling/warm_start.py +149 -0
  180. meltygui/core/windowing/__init__.py +1 -0
  181. meltygui/core/windowing/backends/PYIMGUI_LICENSE +28 -0
  182. meltygui/core/windowing/backends/__init__.py +1 -0
  183. meltygui/core/windowing/backends/imgui_renderer.py +138 -0
  184. meltygui/core/windowing/backends/native_wayland.py +850 -0
  185. meltygui/core/windowing/backends/protocols/xdg-decoration-unstable-v1.xml +156 -0
  186. meltygui/core/windowing/backends/protocols/xdg-shell.xml +1420 -0
  187. meltygui/core/windowing/backends/wayland_protocol.py +101 -0
  188. meltygui/core/windowing/dock_core.py +182 -0
  189. meltygui/core/windowing/frame_geometry.py +42 -0
  190. meltygui/core/windowing/geometry_feed.py +864 -0
  191. meltygui/core/windowing/glfw_utils.py +1343 -0
  192. meltygui/core/windowing/os_frame.py +1552 -0
  193. meltygui/core/windowing/surface.py +643 -0
  194. meltygui/core/windowing/titlebar.py +1560 -0
  195. meltygui/core/windowing/titlebar_buttons.py +281 -0
  196. meltygui/core/windowing/wayland_move.py +932 -0
  197. meltygui/core/windowing/window_api.py +62 -0
  198. meltygui/core/windowing/window_constants.py +339 -0
  199. meltygui/core/windowing/window_visibility.py +162 -0
  200. meltygui/debug/__init__.py +0 -0
  201. meltygui/debug/app_view_utils.py +9 -0
  202. meltygui/editor/__init__.py +0 -0
  203. meltygui/editor/bash_syntax.py +30 -0
  204. meltygui/editor/code_line_fast.py +152 -0
  205. meltygui/editor/diff.py +139 -0
  206. meltygui/editor/external_changes.py +159 -0
  207. meltygui/editor/file_header.py +41 -0
  208. meltygui/editor/live_usage.py +169 -0
  209. meltygui/editor/live_view_views.py +1803 -0
  210. meltygui/editor/pending_save.py +1351 -0
  211. meltygui/editor/roster_tints.py +547 -0
  212. meltygui/editor/source_preview.py +16 -0
  213. meltygui/editor/source_tools.py +10 -0
  214. meltygui/editor/source_ui.py +70 -0
  215. meltygui/editor/spell_check.py +77 -0
  216. meltygui/editor/text_editor.py +9076 -0
  217. meltygui/editor/usage_picker.py +538 -0
  218. meltygui/events/__init__.py +0 -0
  219. meltygui/events/example.py +118 -0
  220. meltygui/examples/__init__.py +0 -0
  221. meltygui/examples/columns_demo.py +33 -0
  222. meltygui/examples/columns_window_demo.py +60 -0
  223. meltygui/examples/context_menu_demo.py +24 -0
  224. meltygui/examples/context_menu_window_demo.py +49 -0
  225. meltygui/examples/gui_playground.py +127 -0
  226. meltygui/examples/live_view_playground.py +256 -0
  227. meltygui/examples/lora.py +43 -0
  228. meltygui/examples/lora_data.py +59 -0
  229. meltygui/examples/lora_policies.py +67 -0
  230. meltygui/examples/mode_demo.py +49 -0
  231. meltygui/examples/modifies_demo.py +119 -0
  232. meltygui/examples/scalar_policies.py +60 -0
  233. meltygui/examples/style_layouts.py +149 -0
  234. meltygui/examples/tile_manager_demo.py +74 -0
  235. meltygui/examples/tint_demo.py +160 -0
  236. meltygui/examples/tint_functions.py +56 -0
  237. meltygui/examples/trace_demo.py +88 -0
  238. meltygui/examples/two_windows.py +36 -0
  239. meltygui/files/__init__.py +0 -0
  240. meltygui/files/fast_file_explorer.py +439 -0
  241. meltygui/gnome_extension/lsd-window-geometry@latent-descent/extension.js +237 -0
  242. meltygui/gnome_extension/lsd-window-geometry@latent-descent/lsd-window-geometry@latent-descent.iml +9 -0
  243. meltygui/gnome_extension/lsd-window-geometry@latent-descent/metadata.json +7 -0
  244. meltygui/graphics/__init__.py +6 -0
  245. meltygui/graphics/base.py +85 -0
  246. meltygui/graphics/examples.py +507 -0
  247. meltygui/graphics/executor.py +520 -0
  248. meltygui/graphics/filter.py +667 -0
  249. meltygui/graphics/filter.pyi +856 -0
  250. meltygui/graphics/generate_stubs.py +22 -0
  251. meltygui/graphics/registry.py +203 -0
  252. meltygui/graphics/shader_compiler.py +155 -0
  253. meltygui/graphics/shaders.py +1302 -0
  254. meltygui/graphics/stub_generator.py +250 -0
  255. meltygui/graphics/texture_manager.py +170 -0
  256. meltygui/graphics/texture_min_max.py +295 -0
  257. meltygui/hdr_color.py +757 -0
  258. meltygui/image_load.py +308 -0
  259. meltygui/model/__init__.py +1 -0
  260. meltygui/model/account_model.py +142 -0
  261. meltygui/model/camera_model.py +159 -0
  262. meltygui/model/chat_model.py +86 -0
  263. meltygui/model/code_model.py +62 -0
  264. meltygui/model/code_proxy_model.py +876 -0
  265. meltygui/model/collection_model.py +41 -0
  266. meltygui/model/color_model.py +127 -0
  267. meltygui/model/cuda_tensor_model.py +36 -0
  268. meltygui/model/cuda_texture_model.py +149 -0
  269. meltygui/model/dropdown_model.py +137 -0
  270. meltygui/model/file_metadata_model.py +73 -0
  271. meltygui/model/file_model.py +289 -0
  272. meltygui/model/format_model.py +390 -0
  273. meltygui/model/graph_model.py +98 -0
  274. meltygui/model/icon_model.py +1024 -0
  275. meltygui/model/import_graph_model.py +468 -0
  276. meltygui/model/layout_model.py +15 -0
  277. meltygui/model/lut_model.py +266 -0
  278. meltygui/model/search_model.py +173 -0
  279. meltygui/model/tensor_model.py +402 -0
  280. meltygui/model/terminal_model.py +329 -0
  281. meltygui/model/texture_model.py +146 -0
  282. meltygui/model/tile_model.py +24 -0
  283. meltygui/model/trace_model.py +198 -0
  284. meltygui/model/trace_report_model.py +146 -0
  285. meltygui/models/__init__.py +0 -0
  286. meltygui/models/file_meta.py +613 -0
  287. meltygui/models/function_console.py +184 -0
  288. meltygui/models/orchestration.py +48 -0
  289. meltygui/pbr.py +1576 -0
  290. meltygui/png_unfilter.c +49 -0
  291. meltygui/resources/JetBrainsMono-Regular.ttf +0 -0
  292. meltygui/resources/THIRD_PARTY_NOTICES.md +21 -0
  293. meltygui/resources/dejavu/DejaVuSans-Bold.ttf +0 -0
  294. meltygui/resources/dejavu/DejaVuSans-ExtraLight.ttf +0 -0
  295. meltygui/resources/dejavu/DejaVuSans.ttf +0 -0
  296. meltygui/resources/dejavu/LICENSE.txt +78 -0
  297. meltygui/resources/fontawesome-LICENSE.txt +121 -0
  298. meltygui/resources/fontawesome-webfont.ttf +0 -0
  299. meltygui/resources/hdri/studio_small_09_1k.hdr +0 -0
  300. meltygui/resources/jetbrains-weights/JetBrainsMono-Bold.ttf +0 -0
  301. meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraBold.ttf +0 -0
  302. meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraLight.ttf +0 -0
  303. meltygui/resources/jetbrains-weights/JetBrainsMono-Light.ttf +0 -0
  304. meltygui/resources/jetbrains-weights/JetBrainsMono-Medium.ttf +0 -0
  305. meltygui/resources/jetbrains-weights/JetBrainsMono-SemiBold.ttf +0 -0
  306. meltygui/resources/jetbrains-weights/JetBrainsMono-Thin.ttf +0 -0
  307. meltygui/resources/jetbrains-weights/OFL.txt +93 -0
  308. meltygui/resources/jetbrains-weights/README.md +2 -0
  309. meltygui/state/__init__.py +0 -0
  310. meltygui/state/account_state.py +22 -0
  311. meltygui/state/animation_state.py +97 -0
  312. meltygui/state/annotation_state.py +22 -0
  313. meltygui/state/chat_state.py +36 -0
  314. meltygui/state/code_state.py +10 -0
  315. meltygui/state/core_enums.py +59 -0
  316. meltygui/state/core_markers.py +115 -0
  317. meltygui/state/core_undo.py +1075 -0
  318. meltygui/state/file_state.py +75 -0
  319. meltygui/state/graph_state.py +26 -0
  320. meltygui/state/inspection_state.py +47 -0
  321. meltygui/state/menu_state.py +10 -0
  322. meltygui/state/model_enums.py +11 -0
  323. meltygui/state/new_core_model.py +2394 -0
  324. meltygui/state/orchestration_state.py +14 -0
  325. meltygui/state/query_state.py +24 -0
  326. meltygui/state/tensor_state.py +10 -0
  327. meltygui/state/terminal_state.py +23 -0
  328. meltygui/state/trace_state.py +32 -0
  329. meltygui/state/voxel_state.py +12 -0
  330. meltygui/text_index.py +816 -0
  331. meltygui/utils/__init__.py +0 -0
  332. meltygui/utils/jump_to_code.py +344 -0
  333. meltygui/utils/pkl_inspect.py +90 -0
  334. meltygui/utils/render_utils.py +1419 -0
  335. meltygui/view/__init__.py +1 -0
  336. meltygui/view/account_view.py +524 -0
  337. meltygui/view/action_view.py +73 -0
  338. meltygui/view/chat_decoration_view.py +112 -0
  339. meltygui/view/chat_view.py +1703 -0
  340. meltygui/view/code_view.py +2677 -0
  341. meltygui/view/collection_view.py +1185 -0
  342. meltygui/view/color_view.py +824 -0
  343. meltygui/view/control_view.py +559 -0
  344. meltygui/view/decoration_view.py +439 -0
  345. meltygui/view/diagnostic_view.py +177 -0
  346. meltygui/view/dropdown_view.py +1082 -0
  347. meltygui/view/file_view.py +1599 -0
  348. meltygui/view/graph_cuda_view.py +105 -0
  349. meltygui/view/graph_view.py +882 -0
  350. meltygui/view/header_view.py +848 -0
  351. meltygui/view/input_view.py +238 -0
  352. meltygui/view/inspection_view.py +1783 -0
  353. meltygui/view/layout_view.py +596 -0
  354. meltygui/view/lut_view.py +45 -0
  355. meltygui/view/menu_view.py +212 -0
  356. meltygui/view/orchestration_view.py +658 -0
  357. meltygui/view/query_view.py +118 -0
  358. meltygui/view/search_view.py +342 -0
  359. meltygui/view/tab_view.py +258 -0
  360. meltygui/view/tensor_view.py +430 -0
  361. meltygui/view/terminal_view.py +355 -0
  362. meltygui/view/text_view.py +8031 -0
  363. meltygui/view/texture_view.py +480 -0
  364. meltygui/view/tile_view.py +38 -0
  365. meltygui/view/trace_view.py +923 -0
  366. meltygui/view/voxel_cuda_view.py +824 -0
  367. meltygui/view/voxel_view.py +1860 -0
  368. meltygui/view/window_view.py +111 -0
  369. meltygui-0.1.0.dist-info/METADATA +143 -0
  370. meltygui-0.1.0.dist-info/RECORD +372 -0
  371. meltygui-0.1.0.dist-info/WHEEL +4 -0
  372. meltygui-0.1.0.dist-info/licenses/LICENSE +202 -0
meltygui/pbr.py ADDED
@@ -0,0 +1,1576 @@
1
+ """
2
+ pbr — an immediate-mode 3-D object renderer with a physically based
3
+ material model, in the shape of the render functions.
4
+
5
+ scene = begin_scene(gl_state, "target", width, height,
6
+ camera=orbit_camera(tilt, spin, zoom, target=(0, 0.4, 0)),
7
+ lights=[Light((2, 4, 3), (1, 1, 1), 40.0)],
8
+ environment=environment(gl_state, "studio"))
9
+ draw_cylinder(scene, position=(0, 0.15, 0), scale=(1, 0.3, 1),
10
+ color=(0.45, 0.45, 0.48), roughness=0.55, metallic=0.1)
11
+ draw_cube(scene, position=(1.5, 0.5, 0), rotation=(0, 0.6, 0),
12
+ color=(0.8, 0.2, 0.1), roughness=0.3)
13
+ draw_sphere(scene, position=(-1.5, 0.5, 0), color=(1.0, 0.85, 0.5),
14
+ metallic=1.0, roughness=0.15)
15
+ fbo = end_scene(scene)
16
+ imgui.image(fbo.texture_id, width, height, uv0=(0, 1), uv1=(1, 0))
17
+
18
+ Every `draw_*` call is one draw, the scene its first argument the way a
19
+ @shader_func takes gl_state: its kwargs ARE the material (`color`,
20
+ `roughness`, `metallic`, `ao`, `emissive`) and the transform (`position`,
21
+ `rotation` Euler XYZ radians, `scale`, or a full `transform` 4x4) — the same
22
+ "parameters as function arguments" contract as @render_func / @shader_func,
23
+ and `@mesh_func(mesh_builder)` is what turns a mesh generator into such a
24
+ function (define new shapes by decorating a generator). begin_scene opens
25
+ the frame; end_scene renders the recorded draws — a depth pass from light 0
26
+ for shadow mapping (3x3 PCF), then the shaded pass — restores GL state and
27
+ hands the FBO back. `shadow_catcher=True` on a draw makes a surface that is
28
+ invisible except for the shadow it receives.
29
+ Nothing is retained between frames but the GL resources (meshes, programs,
30
+ the environment), which GLState owns and dedupes by key.
31
+
32
+ Shading is learnopengl's Cook-Torrance BRDF, metallic / roughness workflow:
33
+ GGX / Trowbridge-Reitz normal distribution, Smith-Schlick geometry,
34
+ Fresnel-Schlick, Lambert diffuse weighted by (1 - F)(1 - metallic), up to
35
+ four point / directional lights (packed as two mat4 uniforms: rows of xyz +
36
+ kind, and rgb + intensity). Environment lighting is learnopengl's
37
+ image-based lighting (PBR/IBL/Diffuse-irradiance + Specular-IBL) over a
38
+ CUBEMAP: the source is a Radiance .hdr equirectangular photo
39
+ (resources/hdri, Poly Haven CC0) or one of the procedural rooms, converted
40
+ to a cube (`_equirect_to_cube`), then baked ONCE per GLState into an
41
+ IRRADIANCE map (hemisphere convolution, the diffuse term), a PREFILTERED
42
+ specular map (GGX importance-sampled per mip, roughness → mip, the
43
+ split-sum's first half) and the BRDF integration LUT (its second half).
44
+ `Environment` holds the three plus the source cube. HDR is tone-mapped
45
+ (ACES) and gamma encoded at the end; the FBO holds straight alpha 1 on
46
+ every covered pixel and 0 where nothing was drawn, so views composite it
47
+ over their own bg. Meshes come from the generators here or from a file
48
+ (`load_model`: OBJ / STL / GLB, split by part name, `draw_mesh`).
49
+ """
50
+
51
+ from __future__ import annotations
52
+ import json
53
+ import math
54
+ import os
55
+ import struct
56
+
57
+ import numpy as np
58
+ import OpenGL.GL as gl
59
+
60
+ from meltygui.core.graphics.gl_state import GLState
61
+ from meltygui.core.graphics.gl_state import GLTexture
62
+ from meltygui.core.graphics.gl_state import _scalar
63
+ from meltygui.core.graphics.shader_func import shader_func
64
+
65
+
66
+
67
+ def _frame_framebuffer():
68
+ """The frame's render target (the fp16 scene while a frame is open)."""
69
+ from meltygui.core.melty import Melty
70
+ return Melty.default_framebuffer()
71
+
72
+
73
+ # ═══════════════════════════════════════════════════════════════════════════
74
+ # Meshes - numpy generators → (positions, normals, indices) → GL VAOs
75
+ # ═══════════════════════════════════════════════════════════════════════════
76
+
77
+ class MeshData:
78
+ """CPU mesh: float32 (N,3) positions and normals, uint32 (M,) indices."""
79
+ __slots__ = ("positions", "normals", "indices")
80
+
81
+ def __init__(self, positions, normals, indices):
82
+ self.positions = np.ascontiguousarray(positions, np.float32)
83
+ self.normals = np.ascontiguousarray(normals, np.float32)
84
+ self.indices = np.ascontiguousarray(indices, np.uint32)
85
+
86
+
87
+ def cube_mesh():
88
+ """Unit cube centred on the origin, flat-shaded faces (24 verts)."""
89
+ positions, normals, indices = [], [], []
90
+ for axis in range(3):
91
+ for sign in (-1.0, 1.0):
92
+ n = np.zeros(3); n[axis] = sign
93
+ u = np.zeros(3); u[(axis + 1) % 3] = 1.0
94
+ v = np.cross(n, u)
95
+ base = len(positions)
96
+ for su, sv in ((-1, -1), (1, -1), (1, 1), (-1, 1)):
97
+ positions.append((n * 0.5 + u * (0.5 * su) + v * (0.5 * sv)))
98
+ normals.append(n)
99
+ indices += [base, base + 1, base + 2, base, base + 2, base + 3]
100
+ return MeshData(positions, normals, indices)
101
+
102
+
103
+ def lathe_mesh(profile, segments=48):
104
+ """Surface of revolution about Y. `profile` = [(r, y, nr, ny), ...] from
105
+ bottom to top, each a ring with its outward normal in the (r, y) plane;
106
+ consecutive rings are stitched with quads. A ring with r = 0 is a pole
107
+ (still emitted as `segments + 1` verts so the seam stays simple)."""
108
+ rings = len(profile)
109
+ positions, normals = [], []
110
+ for r, y, nr, ny in profile:
111
+ for i in range(segments + 1):
112
+ a = 2.0 * math.pi * i / segments
113
+ c, s = math.cos(a), math.sin(a)
114
+ positions.append((r * c, y, r * s))
115
+ normals.append((nr * c, ny, nr * s))
116
+ indices = []
117
+ stride = segments + 1
118
+ for k in range(rings - 1):
119
+ for i in range(segments):
120
+ a, b = k * stride + i, k * stride + i + 1
121
+ c, d = (k + 1) * stride + i, (k + 1) * stride + i + 1
122
+ indices += [a, c, b, b, c, d]
123
+ return MeshData(positions, normals, indices)
124
+
125
+
126
+ def cylinder_mesh(edge_radius=0.0, segments=48, arc_steps=6):
127
+ """Unit cylinder: radius 1, height 1 centred on the origin, with an
128
+ optional rounded rim of `edge_radius` (in units of the radius). Flat
129
+ caps and a straight wall, joined by quarter arcs when rounded."""
130
+ e = max(0.0, min(0.5, float(edge_radius)))
131
+ top, bottom = 0.5, -0.5
132
+ profile = [(0.0, bottom, 0.0, -1.0)] # bottom pole
133
+ if e <= 0.0:
134
+ profile += [(1.0, bottom, 0.0, -1.0), (1.0, bottom, 1.0, 0.0),
135
+ (1.0, top, 1.0, 0.0), (1.0, top, 0.0, 1.0)]
136
+ else:
137
+ profile.append((1.0 - e, bottom, 0.0, -1.0))
138
+ for i in range(arc_steps + 1): # bottom rim arc
139
+ a = -math.pi / 2 + (math.pi / 2) * i / arc_steps
140
+ profile.append((1.0 - e + e * math.cos(a), bottom + e + e * math.sin(a),
141
+ math.cos(a), math.sin(a)))
142
+ for i in range(arc_steps + 1): # top rim arc
143
+ a = (math.pi / 2) * i / arc_steps
144
+ profile.append((1.0 - e + e * math.cos(a), top - e + e * math.sin(a),
145
+ math.cos(a), math.sin(a)))
146
+ profile.append((1.0 - e, top, 0.0, 1.0))
147
+ profile.append((0.0, top, 0.0, 1.0)) # top pole
148
+ return lathe_mesh(profile, segments)
149
+
150
+
151
+ def sphere_mesh(segments=48, rings=24):
152
+ """Unit sphere (radius 1) as a lathe of a semicircle."""
153
+ profile = []
154
+ for i in range(rings + 1):
155
+ a = -math.pi / 2 + math.pi * i / rings
156
+ profile.append((math.cos(a), math.sin(a), math.cos(a), math.sin(a)))
157
+ return lathe_mesh(profile, segments)
158
+
159
+
160
+ def rounded_box_mesh(radius=0.1, segments=8):
161
+ """Unit box (1 × 1 × 1, centred) with every edge and corner rounded by
162
+ `radius` (fraction of the half-size, ≤ 1). Each face is a `segments`²
163
+ grid of the unit cube's surface; a surface point p is projected onto
164
+ the inner box (the cube shrunk by the radius) as q, and the rounded
165
+ surface is q + radius · normalize(p − q) — flat where p − q is a face
166
+ normal, a quarter-cylinder along the edges, a sphere octant at the
167
+ corners — with that direction as its normal."""
168
+ r = max(1e-4, min(1.0, float(radius))) * 0.5
169
+ inner = 0.5 - r
170
+ positions, normals, indices = [], [], []
171
+ grid = np.linspace(-0.5, 0.5, segments + 1)
172
+ for axis in range(3):
173
+ for sign in (-1.0, 1.0):
174
+ n = np.zeros(3); n[axis] = sign
175
+ u = np.zeros(3); u[(axis + 1) % 3] = 1.0
176
+ v = np.cross(n, u)
177
+ base = len(positions)
178
+ for gv in grid:
179
+ for gu in grid:
180
+ p = n * 0.5 + u * gu + v * gv
181
+ q = np.clip(p, -inner, inner)
182
+ d = p - q
183
+ nn = d / max(np.linalg.norm(d), 1e-9)
184
+ positions.append(q + nn * r)
185
+ normals.append(nn)
186
+ stride = segments + 1
187
+ for j in range(segments):
188
+ for i in range(segments):
189
+ a = base + j * stride + i
190
+ b, c, d_ = a + 1, a + stride, a + stride + 1
191
+ indices += [a, b, c, b, d_, c]
192
+ return MeshData(positions, normals, indices)
193
+
194
+
195
+ def extrude_polygon_mesh(points=(), height=1.0):
196
+ """A CONVEX polygon in the XZ plane (counter-clockwise seen from +Y),
197
+ extruded `height` along Y and centred on y = 0, flat-shaded. Compose
198
+ concave shapes (an arrow) from several draws."""
199
+ pts = [(float(x), float(z)) for x, z in points]
200
+ n = len(pts)
201
+ top, bottom = height * 0.5, -height * 0.5
202
+ positions, normals, indices = [], [], []
203
+ # caps (fan)
204
+ for y, ny, order in ((top, 1.0, 1), (bottom, -1.0, -1)):
205
+ base = len(positions)
206
+ for x, z in pts:
207
+ positions.append((x, y, z)); normals.append((0.0, ny, 0.0))
208
+ for k in range(1, n - 1):
209
+ tri = (base, base + k, base + k + 1)
210
+ indices += list(tri if order > 0 else tri[::-1])
211
+ # sides
212
+ for i in range(n):
213
+ (x0, z0), (x1, z1) = pts[i], pts[(i + 1) % n]
214
+ nx, nz = (z1 - z0), -(x1 - x0) # outward for a CCW polygon (seen from +Y)
215
+ length = math.hypot(nx, nz) or 1.0
216
+ nx, nz = nx / length, nz / length
217
+ base = len(positions)
218
+ for x, z in ((x0, z0), (x1, z1)):
219
+ positions.append((x, bottom, z)); normals.append((nx, 0.0, nz))
220
+ positions.append((x, top, z)); normals.append((nx, 0.0, nz))
221
+ indices += [base, base + 2, base + 1, base + 1, base + 2, base + 3]
222
+ return MeshData(positions, normals, indices)
223
+
224
+
225
+ def plane_mesh():
226
+ """Unit square in XZ facing +Y, centred on the origin."""
227
+ positions = [(-0.5, 0, -0.5), (0.5, 0, -0.5), (0.5, 0, 0.5), (-0.5, 0, 0.5)]
228
+ normals = [(0, 1, 0)] * 4
229
+ return MeshData(positions, normals, [0, 2, 1, 0, 3, 2])
230
+
231
+
232
+ def _smooth_normals(positions, indices):
233
+ """Area-weighted vertex normals from the triangle list."""
234
+ tri = positions[indices.reshape(-1, 3)]
235
+ face_n = np.cross(tri[:, 1] - tri[:, 0], tri[:, 2] - tri[:, 0])
236
+ normals = np.zeros_like(positions)
237
+ for k in range(3):
238
+ np.add.at(normals, indices.reshape(-1, 3)[:, k], face_n)
239
+ lengths = np.linalg.norm(normals, axis=1, keepdims=True)
240
+ return normals / np.maximum(lengths, 1e-12)
241
+
242
+
243
+ def _load_obj(path):
244
+ """Wavefront OBJ → {part name: MeshData}. Parts are the `o` / `g`
245
+ groups (one part "mesh" without any); polygons fan-triangulated; vertex
246
+ normals from the file where present, smooth normals otherwise."""
247
+ positions, normals = [], []
248
+ parts, current = {}, "mesh"
249
+ faces = {} # part → [(vi, ni), ...] triangles
250
+
251
+ def add(part, tri):
252
+ faces.setdefault(part, []).append(tri)
253
+
254
+ with open(path, "r", errors="replace") as f:
255
+ for line in f:
256
+ if line.startswith("v "):
257
+ positions.append([float(x) for x in line.split()[1:4]])
258
+ elif line.startswith("vn "):
259
+ normals.append([float(x) for x in line.split()[1:4]])
260
+ elif line.startswith(("o ", "g ")):
261
+ current = line[2:].strip() or current
262
+ elif line.startswith("f "):
263
+ verts = []
264
+ for tok in line.split()[1:]:
265
+ idx = tok.split("/")
266
+ vi = int(idx[0]); vi = vi - 1 if vi > 0 else len(positions) + vi
267
+ ni = None
268
+ if len(idx) >= 3 and idx[2]:
269
+ ni = int(idx[2]); ni = ni - 1 if ni > 0 else len(normals) + ni
270
+ verts.append((vi, ni))
271
+ for k in range(1, len(verts) - 1):
272
+ add(current, (verts[0], verts[k], verts[k + 1]))
273
+ positions = np.asarray(positions, np.float32)
274
+ normals = np.asarray(normals, np.float32) if normals else None
275
+ for part, tris in faces.items():
276
+ flat = [v for tri in tris for v in tri]
277
+ if normals is not None and all(ni is not None for _, ni in flat):
278
+ # per-corner normals; unique (vi, ni) pairs become vertices
279
+ pairs = {}
280
+ idx = np.array([pairs.setdefault(v, len(pairs)) for v in flat], np.uint32)
281
+ keys = list(pairs)
282
+ pos = positions[[vi for vi, _ in keys]]
283
+ nrm = normals[[ni for _, ni in keys]]
284
+ parts[part] = MeshData(pos, nrm, idx)
285
+ else:
286
+ idx = np.array([vi for vi, _ in flat], np.uint32)
287
+ used = np.unique(idx)
288
+ remap = np.zeros(positions.shape[0], np.uint32); remap[used] = np.arange(len(used))
289
+ pos = positions[used]
290
+ idx = remap[idx]
291
+ parts[part] = MeshData(pos, _smooth_normals(pos, idx), idx)
292
+ return parts
293
+
294
+
295
+ def _load_stl(path):
296
+ """STL (binary or ASCII) → {"mesh": MeshData}, welded and smooth-shaded."""
297
+ with open(path, "rb") as f:
298
+ head = f.read(80)
299
+ rest = f.read()
300
+ if head.startswith(b"solid") and b"facet" in rest[:4000]:
301
+ tris = [[float(x) for x in line.split()[1:4]]
302
+ for line in (head + rest).decode(errors="replace").splitlines()
303
+ if line.strip().startswith("vertex")]
304
+ tri = np.asarray(tris, np.float32).reshape(-1, 3)
305
+ else:
306
+ n = struct.unpack("<I", rest[:4])[0]
307
+ rec = np.frombuffer(rest[4:4 + n * 50], dtype=np.dtype([("n", "<3f4"), ("v", "<9f4"), ("a", "<u2")]))
308
+ tri = rec["v"].reshape(-1, 3).astype(np.float32)
309
+ quant = np.round(tri, 5)
310
+ uniq, inverse = np.unique(quant, axis=0, return_inverse=True)
311
+ idx = inverse.astype(np.uint32).reshape(-1)
312
+ return {"mesh": MeshData(uniq, _smooth_normals(uniq, idx), idx)}
313
+
314
+
315
+ def _load_glb(path):
316
+ """glTF binary → {node or mesh name: MeshData} for every triangle
317
+ primitive with POSITION (NORMAL used when present, else smoothed),
318
+ node transforms applied."""
319
+ with open(path, "rb") as f:
320
+ magic, _ver, _len = struct.unpack("<III", f.read(12))
321
+ assert magic == 0x46546C67, "not a GLB"
322
+ chunks = {}
323
+ while True:
324
+ hdr = f.read(8)
325
+ if len(hdr) < 8:
326
+ break
327
+ clen, ctype = struct.unpack("<II", hdr)
328
+ chunks[ctype] = f.read(clen)
329
+ doc = json.loads(chunks[0x4E4F534A])
330
+ bin_chunk = chunks.get(0x004E4942, b"")
331
+ _ctype = {5120: np.int8, 5121: np.uint8, 5122: np.int16, 5123: np.uint16,
332
+ 5125: np.uint32, 5126: np.float32}
333
+ _ncomp = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4, "MAT4": 16}
334
+
335
+ def accessor(i):
336
+ acc = doc["accessors"][i]
337
+ view = doc["bufferViews"][acc["bufferView"]]
338
+ dtype = np.dtype(_ctype[acc["componentType"]])
339
+ n = _ncomp[acc["type"]]
340
+ start = view.get("byteOffset", 0) + acc.get("byteOffset", 0)
341
+ stride = view.get("byteStride", dtype.itemsize * n)
342
+ raw = np.frombuffer(bin_chunk, np.uint8, count=stride * (acc["count"] - 1) + dtype.itemsize * n,
343
+ offset=start)
344
+ arr = np.lib.stride_tricks.as_strided(raw, shape=(acc["count"], dtype.itemsize * n),
345
+ strides=(stride, 1))
346
+ return np.ascontiguousarray(arr).view(dtype).reshape(acc["count"], n)
347
+
348
+ def node_matrix(node):
349
+ if "matrix" in node:
350
+ return np.asarray(node["matrix"], np.float64).reshape(4, 4).T
351
+ m = np.eye(4)
352
+ t = node.get("translation", (0, 0, 0)); q = node.get("rotation", (0, 0, 0, 1))
353
+ sc = node.get("scale", (1, 1, 1))
354
+ x, y, z, w = q
355
+ R = np.array([[1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)],
356
+ [2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)],
357
+ [2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)]])
358
+ m[:3, :3] = R * np.asarray(sc)[None, :]
359
+ m[:3, 3] = t
360
+ return m
361
+
362
+ parts = {}
363
+
364
+ def visit(ni, parent):
365
+ node = doc["nodes"][ni]
366
+ m = parent @ node_matrix(node)
367
+ if "mesh" in node:
368
+ mesh = doc["meshes"][node["mesh"]]
369
+ name = node.get("name") or mesh.get("name") or f"mesh{node['mesh']}"
370
+ for pi, prim in enumerate(mesh["primitives"]):
371
+ if prim.get("mode", 4) != 4 or "POSITION" not in prim["attributes"]:
372
+ continue
373
+ pos = accessor(prim["attributes"]["POSITION"]).astype(np.float64)
374
+ pos = (m[:3, :3] @ pos.T).T + m[:3, 3]
375
+ idx = (accessor(prim["indices"]).reshape(-1).astype(np.uint32) if "indices" in prim
376
+ else np.arange(pos.shape[0], dtype=np.uint32))
377
+ if "NORMAL" in prim["attributes"]:
378
+ nrm = accessor(prim["attributes"]["NORMAL"]).astype(np.float64)
379
+ nrm = (np.linalg.inv(m[:3, :3]).T @ nrm.T).T
380
+ nrm /= np.maximum(np.linalg.norm(nrm, axis=1, keepdims=True), 1e-12)
381
+ else:
382
+ nrm = _smooth_normals(pos.astype(np.float32), idx)
383
+ key = name if pi == 0 else f"{name}.{pi}"
384
+ parts[key] = MeshData(pos, nrm, idx)
385
+ for child in node.get("children", ()):
386
+ visit(child, m)
387
+
388
+ scene = doc.get("scenes", [{}])[doc.get("scene", 0)]
389
+ for root in scene.get("nodes", range(len(doc.get("nodes", [])))):
390
+ visit(root, np.eye(4))
391
+ return parts
392
+
393
+
394
+ class Model:
395
+ """A loaded model: `parts` (name → MeshData) and the bounds of the whole."""
396
+ __slots__ = ("path", "parts", "bounds_min", "bounds_max")
397
+
398
+ def __init__(self, path, parts):
399
+ self.path, self.parts = path, parts
400
+ allpos = np.vstack([p.positions for p in parts.values()]) if parts else np.zeros((1, 3))
401
+ self.bounds_min, self.bounds_max = allpos.min(axis=0), allpos.max(axis=0)
402
+
403
+ @property
404
+ def size(self):
405
+ return self.bounds_max - self.bounds_min
406
+
407
+ @property
408
+ def center(self):
409
+ return (self.bounds_max + self.bounds_min) * 0.5
410
+
411
+ def fit_transform(self, height=1.0, floor=True, up="y"):
412
+ """A 4x4 that centres the model on the origin, scales it to `height`
413
+ along the up axis (Z-up files: up="z" swings them to Y-up) and, with
414
+ `floor`, rests its lowest point on y = 0."""
415
+ m = np.eye(4)
416
+ if up == "z":
417
+ m[:3, :3] = rotation_matrix((-math.pi / 2, 0, 0))
418
+ size = self.size.copy()
419
+ if up == "z":
420
+ size = size[[0, 2, 1]]
421
+ s = height / max(size[1], 1e-9)
422
+ center = m[:3, :3] @ self.center
423
+ m2 = np.eye(4)
424
+ m2[:3, :3] = m[:3, :3] * s
425
+ m2[:3, 3] = -center * s
426
+ if floor:
427
+ m2[1, 3] += size[1] * s * 0.5
428
+ return m2
429
+
430
+
431
+ _MODELS: dict = {}
432
+
433
+
434
+ def load_model(path) -> Model:
435
+ """OBJ / STL / GLB from disk, cached on (path, mtime)."""
436
+ path = os.fspath(path)
437
+ stamp = os.path.getmtime(path)
438
+ cached = _MODELS.get(path)
439
+ if cached is not None and cached[0] == stamp:
440
+ return cached[1]
441
+ ext = os.path.splitext(path)[1].lower()
442
+ parts = {".obj": _load_obj, ".stl": _load_stl, ".glb": _load_glb}[ext](path)
443
+ model = Model(path, parts)
444
+ _MODELS[path] = (stamp, model)
445
+ return model
446
+
447
+
448
+ class Mesh:
449
+ """A mesh uploaded to a GLState: the VAO, its index count and its local
450
+ bounds (for the shadow camera's fit)."""
451
+ __slots__ = ("vao", "count", "bounds")
452
+
453
+ def __init__(self, vao, count, bounds=None):
454
+ self.vao, self.count = vao, count
455
+ self.bounds = bounds if bounds is not None else (np.zeros(3), np.zeros(3))
456
+
457
+
458
+ def upload_mesh(gl_state: GLState, key, data: MeshData) -> Mesh:
459
+ """Upload once per (gl_state, key): interleaved position + normal VBO and
460
+ an index buffer, both owned by the VAO record for deletion."""
461
+ def build():
462
+ inter = np.hstack([data.positions, data.normals]).astype(np.float32)
463
+ vbo = _scalar(gl.glGenBuffers(1))
464
+ gl.glBindBuffer(gl.GL_ARRAY_BUFFER, vbo)
465
+ gl.glBufferData(gl.GL_ARRAY_BUFFER, inter.nbytes, inter, gl.GL_STATIC_DRAW)
466
+ ibo = _scalar(gl.glGenBuffers(1))
467
+ gl.glBindBuffer(gl.GL_ELEMENT_ARRAY_BUFFER, ibo)
468
+ gl.glBufferData(gl.GL_ELEMENT_ARRAY_BUFFER, data.indices.nbytes, data.indices,
469
+ gl.GL_STATIC_DRAW)
470
+ stride = 6 * 4
471
+ gl.glEnableVertexAttribArray(0)
472
+ gl.glVertexAttribPointer(0, 3, gl.GL_FLOAT, gl.GL_FALSE, stride, gl.ctypes.c_void_p(0))
473
+ gl.glEnableVertexAttribArray(1)
474
+ gl.glVertexAttribPointer(1, 3, gl.GL_FLOAT, gl.GL_FALSE, stride, gl.ctypes.c_void_p(12))
475
+ return (vbo, ibo)
476
+ vao = gl_state.vao(("pbr_mesh",) + tuple(key), build)
477
+ mesh = Mesh(vao, int(data.indices.shape[0]),
478
+ (data.positions.min(axis=0).astype(np.float64), data.positions.max(axis=0).astype(np.float64)))
479
+ _mesh_counts[key] = (mesh.count, mesh.bounds)
480
+ return mesh
481
+
482
+
483
+ # ═══════════════════════════════════════════════════════════════════════════
484
+ # Transforms and camera - plain numpy, row-major (shader_func transposes)
485
+ # ═══════════════════════════════════════════════════════════════════════════
486
+
487
+ def _normalize(v):
488
+ v = np.asarray(v, np.float64)
489
+ n = np.linalg.norm(v)
490
+ return v / n if n > 0 else v
491
+
492
+
493
+ def rotation_matrix(rotation):
494
+ """Euler XYZ (radians), applied in that order about the object's own
495
+ axes: R = Rz · Ry · Rx (so a point is rotated about X first)."""
496
+ rx, ry, rz = (float(a) for a in rotation)
497
+ cx, sx, cy, sy, cz, sz = (math.cos(rx), math.sin(rx), math.cos(ry), math.sin(ry),
498
+ math.cos(rz), math.sin(rz))
499
+ Rx = np.array([[1, 0, 0], [0, cx, -sx], [0, sx, cx]])
500
+ Ry = np.array([[cy, 0, sy], [0, 1, 0], [-sy, 0, cy]])
501
+ Rz = np.array([[cz, -sz, 0], [sz, cz, 0], [0, 0, 1]])
502
+ return Rz @ Ry @ Rx
503
+
504
+
505
+ def model_matrix(position=(0, 0, 0), rotation=(0, 0, 0), scale=(1, 1, 1)):
506
+ """4x4 model matrix: translate · rotate · scale."""
507
+ s = np.asarray(scale if not np.isscalar(scale) else (scale,) * 3, np.float64)
508
+ m = np.eye(4)
509
+ m[:3, :3] = rotation_matrix(rotation) * s[None, :]
510
+ m[:3, 3] = np.asarray(position, np.float64)
511
+ return m
512
+
513
+
514
+ def look_at(eye, target, up=(0, 1, 0)):
515
+ eye, target = np.asarray(eye, np.float64), np.asarray(target, np.float64)
516
+ f = _normalize(target - eye)
517
+ r = _normalize(np.cross(f, _normalize(up)))
518
+ u = np.cross(r, f)
519
+ view = np.eye(4)
520
+ view[0, :3], view[1, :3], view[2, :3] = r, u, -f
521
+ view[:3, 3] = -view[:3, :3] @ eye
522
+ return view
523
+
524
+
525
+ def perspective(fov_y, aspect, near=0.05, far=100.0):
526
+ f = 1.0 / math.tan(fov_y / 2.0)
527
+ m = np.zeros((4, 4))
528
+ m[0, 0] = f / aspect
529
+ m[1, 1] = f
530
+ m[2, 2] = (far + near) / (near - far)
531
+ m[2, 3] = 2 * far * near / (near - far)
532
+ m[3, 2] = -1.0
533
+ return m
534
+
535
+
536
+ def orthographic(half_w, half_h, near, far):
537
+ m = np.eye(4)
538
+ m[0, 0] = 1.0 / half_w
539
+ m[1, 1] = 1.0 / half_h
540
+ m[2, 2] = -2.0 / (far - near)
541
+ m[2, 3] = -(far + near) / (far - near)
542
+ return m
543
+
544
+
545
+ class Camera:
546
+ """Eye + target + vertical field of view; matrices on demand."""
547
+ __slots__ = ("eye", "target", "up", "fov_y", "near", "far")
548
+
549
+ def __init__(self, eye, target=(0, 0, 0), up=(0, 1, 0), fov_y=0.7, near=0.05, far=100.0):
550
+ self.eye, self.target, self.up = tuple(eye), tuple(target), tuple(up)
551
+ self.fov_y, self.near, self.far = float(fov_y), float(near), float(far)
552
+
553
+ def view(self):
554
+ return look_at(self.eye, self.target, self.up)
555
+
556
+ def projection(self, aspect):
557
+ return perspective(self.fov_y, aspect, self.near, self.far)
558
+
559
+
560
+ def orbit_camera(tilt, spin, zoom, target=(0, 0, 0), fov_y=0.7):
561
+ """The voxel / space-mouse orbit in a Y-up frame: tilt = elevation, spin
562
+ = azimuth, zoom = eye distance from `target`. Past a pole (cos(tilt) < 0)
563
+ the up vector flips so the orbit continues over the top like draw_voxels'."""
564
+ ct, st = math.cos(tilt), math.sin(tilt)
565
+ fwd = np.array([-math.cos(spin) * ct, -st, -math.sin(spin) * ct])
566
+ eye = np.asarray(target, np.float64) - fwd * float(zoom)
567
+ up = (0, 1, 0) if ct >= 0 else (0, -1, 0)
568
+ return Camera(eye, target, up, fov_y)
569
+
570
+
571
+ # ═══════════════════════════════════════════════════════════════════════════
572
+ # Lights and the environment cube map
573
+ # ═══════════════════════════════════════════════════════════════════════════
574
+
575
+ class Light:
576
+ """A point light (`position`, intensity falls off with 1/d²) or, with
577
+ `directional=True`, a directional light where `position` is the direction
578
+ TOWARD the light. `color` is linear RGB, `intensity` its scale."""
579
+ __slots__ = ("position", "color", "intensity", "directional")
580
+
581
+ def __init__(self, position, color=(1.0, 1.0, 1.0), intensity=10.0, directional=False):
582
+ self.position, self.color = tuple(position), tuple(color)
583
+ self.intensity, self.directional = float(intensity), bool(directional)
584
+
585
+
586
+ MAX_LIGHTS = 4
587
+
588
+
589
+ def pack_lights(lights):
590
+ """Up to MAX_LIGHTS lights → two 4x4 matrices, one light per COLUMN:
591
+ (x, y, z, kind) with kind 0 = off, 1 = point, 2 = directional, and
592
+ (r, g, b, intensity). One mat4 uniform each — no array uniforms — and
593
+ GLSL's `m[i]` is column i, which is why the lights go down the columns
594
+ (packed as rows, `light_pos[i].w` read a stray 0 and every direct light
595
+ was skipped: the first renders were lit by the environment alone)."""
596
+ pos = np.zeros((4, 4)); col = np.zeros((4, 4))
597
+ for i, light in enumerate(list(lights)[:MAX_LIGHTS]):
598
+ pos[:3, i] = light.position
599
+ pos[3, i] = 2.0 if light.directional else 1.0
600
+ col[:3, i] = light.color
601
+ col[3, i] = light.intensity
602
+ return pos, col
603
+
604
+
605
+ def _env_studio(d):
606
+ """Linear RGB of a lit studio room in direction d (N,3): a soft grey
607
+ ceiling with two rectangular light panels, warm-grey walls, a dark floor."""
608
+ x, y, z = d[:, 0], d[:, 1], d[:, 2]
609
+ up = np.clip(y, 0, 1)
610
+ col = np.empty_like(d)
611
+ col[:] = (0.28, 0.29, 0.31) # walls
612
+ col += up[:, None] * np.array([0.32, 0.33, 0.35]) # soft on the ceiling
613
+ col[y < 0] = (0.10, 0.09, 0.085) # floor
614
+ col[y < 0] += (1.0 + y[y < 0])[:, None] * np.array([0.05, 0.05, 0.05])
615
+ # Two light panels: key (up-right-front) and fill (up-left-back)
616
+ for center, size, energy in (((0.45, 0.8, 0.4), 0.16, (5.0, 4.8, 4.4)),
617
+ ((-0.6, 0.6, -0.5), 0.22, (2.2, 2.3, 2.6))):
618
+ c = _normalize(center)
619
+ cosang = d @ c
620
+ mask = np.clip((cosang - (1.0 - size)) / (size * 0.35), 0.0, 1.0)
621
+ col += mask[:, None] * np.array(energy)
622
+ return col
623
+
624
+
625
+ def _env_outdoor(d):
626
+ """Linear RGB of a clear sky over a green-grey ground, sun up-front-right."""
627
+ y = d[:, 1]
628
+ t = np.clip(y, 0, 1)[:, None]
629
+ col = (1 - t) * np.array([0.85, 0.85, 0.9]) + t * np.array([0.25, 0.45, 0.95])
630
+ ground = y < 0
631
+ col[ground] = (0.22, 0.24, 0.16)
632
+ col[ground] += (1.0 + y[ground])[:, None] * np.array([0.15, 0.13, 0.10])
633
+ sun = _normalize((0.4, 0.7, 0.55))
634
+ cosang = d @ sun
635
+ col += np.clip((cosang - 0.985) / 0.015, 0, 1)[:, None] * np.array([40.0, 36.0, 30.0])
636
+ col += np.clip((cosang - 0.90) / 0.10, 0, 1)[:, None] * np.array([0.8, 0.7, 0.5])
637
+ return col
638
+
639
+
640
+ ENVIRONMENTS = {"room": _env_studio, "outdoor": _env_outdoor}
641
+
642
+ # Radiance .hdr images under resources/hdri (Poly Haven, CC0). "studio" is
643
+ # the default: a small photo studio with softboxes.
644
+ HDRIS = {"studio": "studio_small_09_1k.hdr"}
645
+ _HDRI_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources", "hdri")
646
+
647
+ # Cubemap face axis conventions (GL): (major axis,, u axis, v axis)
648
+ _FACES = (
649
+ ((1, 0, 0), (0, 0, -1), (0, -1, 0)), ((-1, 0, 0), (0, 0, 1), (0, -1, 0)),
650
+ ((0, 1, 0), (1, 0, 0), (0, 0, 1)), ((0, -1, 0), (1, 0, 0), (0, 0, -1)),
651
+ ((0, 0, 1), (1, 0, 0), (0, -1, 0)), ((0, 0, -1), (-1, 0, 0), (0, -1, 0)),
652
+ )
653
+
654
+
655
+ def read_hdr(path):
656
+ """Radiance RGBE (.hdr) → float32 (H, W, 3) linear RGB, row 0 = TOP.
657
+ Handles the new-style RLE scanlines Poly Haven writes and flat data."""
658
+ with open(path, "rb") as f:
659
+ data = f.read()
660
+ pos = 0
661
+ width = height = None
662
+ while True:
663
+ end = data.index(b"\n", pos)
664
+ line = data[pos:end]
665
+ pos = end + 1
666
+ if line.startswith(b"-Y") or line.startswith(b"+Y"):
667
+ parts = line.split()
668
+ height, width = int(parts[1]), int(parts[3])
669
+ flip_y = line.startswith(b"+Y")
670
+ break
671
+ buf = np.frombuffer(data, np.uint8, offset=pos)
672
+ out = np.empty((height, width, 4), np.uint8)
673
+ i = 0
674
+ for y in range(height):
675
+ if width >= 8 and width < 32768 and buf[i] == 2 and buf[i + 1] == 2 and buf[i + 2] < 128:
676
+ i += 4
677
+ row = np.empty((4, width), np.uint8)
678
+ for c in range(4):
679
+ x = 0
680
+ while x < width:
681
+ n = int(buf[i]); i += 1
682
+ if n > 128:
683
+ n -= 128
684
+ row[c, x:x + n] = buf[i]; i += 1
685
+ else:
686
+ row[c, x:x + n] = buf[i:i + n]; i += n
687
+ x += n
688
+ out[y] = row.T
689
+ else:
690
+ out[y] = buf[i:i + width * 4].reshape(width, 4); i += width * 4
691
+ rgbe = out.astype(np.float32)
692
+ scale = np.where(out[:, :, 3] > 0, np.ldexp(1.0, out[:, :, 3].astype(np.int32) - 136), 0.0)
693
+ rgb = rgbe[:, :, :3] * scale[:, :, None]
694
+ if flip_y:
695
+ rgb = rgb[::-1]
696
+ return np.ascontiguousarray(rgb, np.float32)
697
+
698
+
699
+ def _cube_texture(size, levels=1, internal=gl.GL_RGB16F):
700
+ tex = _scalar(gl.glGenTextures(1))
701
+ gl.glBindTexture(gl.GL_TEXTURE_CUBE_MAP, tex)
702
+ for level in range(levels):
703
+ n = max(1, size >> level)
704
+ for i in range(6):
705
+ gl.glTexImage2D(gl.GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, level, internal, n, n, 0,
706
+ gl.GL_RGB, gl.GL_FLOAT, None)
707
+ gl.glTexParameteri(gl.GL_TEXTURE_CUBE_MAP, gl.GL_TEXTURE_MIN_FILTER,
708
+ gl.GL_LINEAR_MIPMAP_LINEAR if levels > 1 else gl.GL_LINEAR)
709
+ gl.glTexParameteri(gl.GL_TEXTURE_CUBE_MAP, gl.GL_TEXTURE_MAG_FILTER, gl.GL_LINEAR)
710
+ gl.glTexParameteri(gl.GL_TEXTURE_CUBE_MAP, gl.GL_TEXTURE_MAX_LEVEL, levels - 1)
711
+ for wrap in (gl.GL_TEXTURE_WRAP_S, gl.GL_TEXTURE_WRAP_T, gl.GL_TEXTURE_WRAP_R):
712
+ gl.glTexParameteri(gl.GL_TEXTURE_CUBE_MAP, wrap, gl.GL_CLAMP_TO_EDGE)
713
+ gl.glBindTexture(gl.GL_TEXTURE_CUBE_MAP, 0)
714
+ t = GLTexture(tex, gl.GL_TEXTURE_CUBE_MAP, (6, size, size), internal)
715
+ t.max_lod = float(levels - 1)
716
+ return t
717
+
718
+
719
+ def _bake_fbo(gl_state):
720
+ """The scratch framebuffer the bakes attach cube faces / the LUT to."""
721
+ return gl_state.get(("pbr_bake_fbo",), lambda: _scalar(gl.glGenFramebuffers(1)),
722
+ lambda f: gl.glDeleteFramebuffers(1, [int(f)]))
723
+
724
+
725
+ def _render_cube_faces(gl_state, target: GLTexture, level, pass_fn, **uniforms):
726
+ """Run a fullscreen shader_func into each face of `target` at `level`;
727
+ the pass reads `face_axis` / `face_u` / `face_v` to turn uv into the
728
+ direction (row 0 = the face's top, matching _FACES and the CPU path)."""
729
+ fbo = _bake_fbo(gl_state)
730
+ n = max(1, target.shape[1] >> level)
731
+ prev_fbo = _scalar(gl.glGetIntegerv(gl.GL_DRAW_FRAMEBUFFER_BINDING))
732
+ prev_vp = gl.glGetIntegerv(gl.GL_VIEWPORT)
733
+ gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, fbo)
734
+ gl.glViewport(0, 0, n, n)
735
+ gl.glDisable(gl.GL_DEPTH_TEST)
736
+ for i, (axis, ua, va) in enumerate(_FACES):
737
+ gl.glFramebufferTexture2D(gl.GL_FRAMEBUFFER, gl.GL_COLOR_ATTACHMENT0,
738
+ gl.GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, target.texture_id, level)
739
+ pass_fn(gl_state, face_axis=tuple(float(c) for c in axis),
740
+ face_u=tuple(float(c) for c in ua), face_v=tuple(float(c) for c in va), **uniforms)
741
+ gl.glFramebufferTexture2D(gl.GL_FRAMEBUFFER, gl.GL_COLOR_ATTACHMENT0, gl.GL_TEXTURE_2D, 0, 0)
742
+ gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, prev_fbo)
743
+ gl.glViewport(int(prev_vp[0]), int(prev_vp[1]), int(prev_vp[2]), int(prev_vp[3]))
744
+
745
+
746
+ _FACE_DIR = """
747
+ vec3 face_dir(vec2 uv) {
748
+ return normalize(face_axis + (uv.x * 2.0 - 1.0) * face_u + (uv.y * 2.0 - 1.0) * face_v);
749
+ }
750
+ """
751
+
752
+ EQUIRECT_FRAG = """
753
+ #version 330 core
754
+ in vec2 uv;
755
+ out vec4 FragColor;
756
+ uniform sampler2D equirect;
757
+ """ + _FACE_DIR + """
758
+ void main() {
759
+ vec3 d = face_dir(uv);
760
+ vec2 st = vec2(atan(d.z, d.x) * 0.15915494, asin(clamp(d.y, -1.0, 1.0)) * 0.31830989) + 0.5;
761
+ FragColor = vec4(texture(equirect, st).rgb, 1.0);
762
+ }
763
+ """
764
+
765
+ IRRADIANCE_FRAG = """
766
+ #version 330 core
767
+ in vec2 uv;
768
+ out vec4 FragColor;
769
+ uniform samplerCube source;
770
+ const float PI = 3.14159265359;
771
+ """ + _FACE_DIR + """
772
+ void main() {
773
+ // learnopengl Diffuse-irradiance: a discrete hemisphere integral of
774
+ // radiance · cos(theta) · sin(theta) around the texel's direction
775
+ vec3 N = face_dir(uv);
776
+ vec3 up = abs(N.y) < 0.999 ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.0, 0.0);
777
+ vec3 right = normalize(cross(up, N));
778
+ up = cross(N, right);
779
+ vec3 irradiance = vec3(0.0);
780
+ float delta = 0.04;
781
+ float count = 0.0;
782
+ for (float phi = 0.0; phi < 2.0 * PI; phi += delta) {
783
+ for (float theta = 0.0; theta < 0.5 * PI; theta += delta) {
784
+ vec3 t = vec3(sin(theta) * cos(phi), sin(theta) * sin(phi), cos(theta));
785
+ vec3 s = t.x * right + t.y * up + t.z * N;
786
+ irradiance += textureLod(source, s, source_lod).rgb * cos(theta) * sin(theta);
787
+ count += 1.0;
788
+ }
789
+ }
790
+ FragColor = vec4(PI * irradiance / count, 1.0);
791
+ }
792
+ """
793
+
794
+ _SAMPLING = """
795
+ const float PI = 3.14159265359;
796
+ float radical_inverse_vdc(uint bits) {
797
+ bits = (bits << 16u) | (bits >> 16u);
798
+ bits = ((bits & 0x55555555u) << 1u) | ((bits & 0xAAAAAAAAu) >> 1u);
799
+ bits = ((bits & 0x33333333u) << 2u) | ((bits & 0xCCCCCCCCu) >> 2u);
800
+ bits = ((bits & 0x0F0F0F0Fu) << 4u) | ((bits & 0xF0F0F0F0u) >> 4u);
801
+ bits = ((bits & 0x00FF00FFu) << 8u) | ((bits & 0xFF00FF00u) >> 8u);
802
+ return float(bits) * 2.3283064365386963e-10;
803
+ }
804
+ vec2 hammersley(uint i, uint n) { return vec2(float(i) / float(n), radical_inverse_vdc(i)); }
805
+ vec3 importance_sample_ggx(vec2 xi, vec3 N, float rough) {
806
+ float a = rough * rough;
807
+ float phi = 2.0 * PI * xi.x;
808
+ float cos_theta = sqrt((1.0 - xi.y) / (1.0 + (a * a - 1.0) * xi.y));
809
+ float sin_theta = sqrt(1.0 - cos_theta * cos_theta);
810
+ vec3 H = vec3(cos(phi) * sin_theta, sin(phi) * sin_theta, cos_theta);
811
+ vec3 up = abs(N.z) < 0.999 ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0);
812
+ vec3 tangent = normalize(cross(up, N));
813
+ vec3 bitangent = cross(N, tangent);
814
+ return normalize(tangent * H.x + bitangent * H.y + N * H.z);
815
+ }
816
+ float distribution_ggx(float NdotH, float rough) {
817
+ float a = rough * rough, a2 = a * a;
818
+ float d = NdotH * NdotH * (a2 - 1.0) + 1.0;
819
+ return a2 / (PI * d * d);
820
+ }
821
+ """
822
+
823
+ PREFILTER_FRAG = """
824
+ #version 330 core
825
+ in vec2 uv;
826
+ out vec4 FragColor;
827
+ uniform samplerCube source;
828
+ """ + _FACE_DIR + _SAMPLING + """
829
+ void main() {
830
+ // learnopengl Specular-IBL: GGX importance sampling with V = R = N,
831
+ // each sample read from the source mip its solid angle covers (the
832
+ // pdf-based lod that removes the bright-dot noise)
833
+ vec3 N = face_dir(uv);
834
+ vec3 V = N;
835
+ const uint SAMPLES = 256u;
836
+ float sa_texel = 4.0 * PI / (6.0 * source_size * source_size);
837
+ vec3 acc = vec3(0.0);
838
+ float weight = 0.0;
839
+ for (uint i = 0u; i < SAMPLES; i++) {
840
+ vec2 xi = hammersley(i, SAMPLES);
841
+ vec3 H = importance_sample_ggx(xi, N, roughness);
842
+ vec3 L = normalize(2.0 * dot(V, H) * H - V);
843
+ float NdotL = max(dot(N, L), 0.0);
844
+ if (NdotL > 0.0) {
845
+ float NdotH = max(dot(N, H), 0.0);
846
+ float HdotV = max(dot(H, V), 0.0);
847
+ float pdf = distribution_ggx(NdotH, roughness) * NdotH / (4.0 * HdotV) + 0.0001;
848
+ float sa_sample = 1.0 / (float(SAMPLES) * pdf + 0.0001);
849
+ float lod = roughness == 0.0 ? 0.0 : 0.5 * log2(sa_sample / sa_texel);
850
+ acc += textureLod(source, L, lod).rgb * NdotL;
851
+ weight += NdotL;
852
+ }
853
+ }
854
+ FragColor = vec4(acc / max(weight, 1e-4), 1.0);
855
+ }
856
+ """
857
+
858
+ BRDF_LUT_FRAG = """
859
+ #version 330 core
860
+ in vec2 uv;
861
+ out vec4 FragColor;
862
+ """ + _SAMPLING + """
863
+ float geometry_schlick_ggx_ibl(float NdotV, float rough) {
864
+ float k = (rough * rough) / 2.0;
865
+ return NdotV / (NdotV * (1.0 - k) + k);
866
+ }
867
+ void main() {
868
+ // the split-sum's second half: scale and bias to F0 over (NdotV, roughness)
869
+ float NdotV = max(uv.x, 1e-3), rough = uv.y;
870
+ vec3 V = vec3(sqrt(1.0 - NdotV * NdotV), 0.0, NdotV);
871
+ vec3 N = vec3(0.0, 0.0, 1.0);
872
+ float A = 0.0, B = 0.0;
873
+ const uint SAMPLES = 512u;
874
+ for (uint i = 0u; i < SAMPLES; i++) {
875
+ vec2 xi = hammersley(i, SAMPLES);
876
+ vec3 H = importance_sample_ggx(xi, N, rough);
877
+ vec3 L = normalize(2.0 * dot(V, H) * H - V);
878
+ float NdotL = max(L.z, 0.0), NdotH = max(H.z, 0.0), VdotH = max(dot(V, H), 0.0);
879
+ if (NdotL > 0.0) {
880
+ float G = geometry_schlick_ggx_ibl(NdotV, rough) * geometry_schlick_ggx_ibl(NdotL, rough);
881
+ float G_vis = (G * VdotH) / (NdotH * NdotV);
882
+ float Fc = pow(1.0 - VdotH, 5.0);
883
+ A += (1.0 - Fc) * G_vis;
884
+ B += Fc * G_vis;
885
+ }
886
+ }
887
+ FragColor = vec4(A / float(SAMPLES), B / float(SAMPLES), 0.0, 1.0);
888
+ }
889
+ """
890
+
891
+
892
+ def _fs_triangle(gl_state):
893
+ gl.glBindVertexArray(gl_state.vao("fs_triangle"))
894
+ gl.glDrawArrays(gl.GL_TRIANGLES, 0, 3)
895
+
896
+
897
+ @shader_func(fragment=EQUIRECT_FRAG)
898
+ def equirect_pass(gl_state: GLState = None, equirect=None, face_axis=(1.0, 0.0, 0.0),
899
+ face_u=(0.0, 0.0, -1.0), face_v=(0.0, -1.0, 0.0), **kwargs):
900
+ _fs_triangle(gl_state)
901
+
902
+
903
+ @shader_func(fragment=IRRADIANCE_FRAG)
904
+ def irradiance_pass(gl_state: GLState = None, source=None, source_lod=0.0, face_axis=(1.0, 0.0, 0.0),
905
+ face_u=(0.0, 0.0, -1.0), face_v=(0.0, -1.0, 0.0), **kwargs):
906
+ _fs_triangle(gl_state)
907
+
908
+
909
+ @shader_func(fragment=PREFILTER_FRAG)
910
+ def prefilter_pass(gl_state: GLState = None, source=None, source_size=256.0, roughness=0.0,
911
+ face_axis=(1.0, 0.0, 0.0), face_u=(0.0, 0.0, -1.0), face_v=(0.0, -1.0, 0.0),
912
+ **kwargs):
913
+ _fs_triangle(gl_state)
914
+
915
+
916
+ @shader_func(fragment=BRDF_LUT_FRAG)
917
+ def brdf_lut_pass(gl_state: GLState = None, **kwargs):
918
+ _fs_triangle(gl_state)
919
+
920
+
921
+ class Environment:
922
+ """The baked image-based lighting of one environment: `source` (the
923
+ radiance cubemap, mip-mapped), `irradiance` (diffuse), `prefiltered`
924
+ (specular, roughness → mip up to `max_lod`), `brdf_lut` (2-D), and
925
+ `strength` scaling the whole ambient term."""
926
+ __slots__ = ("name", "source", "irradiance", "prefiltered", "brdf_lut", "max_lod", "strength")
927
+
928
+ def __init__(self, name, source, irradiance, prefiltered, brdf_lut, max_lod):
929
+ self.name, self.source = name, source
930
+ self.irradiance, self.prefiltered, self.brdf_lut = irradiance, prefiltered, brdf_lut
931
+ self.max_lod, self.strength = float(max_lod), 1.0
932
+
933
+
934
+ def _source_cube(gl_state, preset, size):
935
+ """The radiance cubemap: an .hdr photo run through equirect_pass, or a
936
+ procedural room / sky evaluated on the CPU."""
937
+ if preset in HDRIS:
938
+ img = read_hdr(os.path.join(_HDRI_DIR, HDRIS[preset]))
939
+ eq = _scalar(gl.glGenTextures(1))
940
+ gl.glBindTexture(gl.GL_TEXTURE_2D, eq)
941
+ gl.glTexImage2D(gl.GL_TEXTURE_2D, 0, gl.GL_RGB16F, img.shape[1], img.shape[0], 0,
942
+ gl.GL_RGB, gl.GL_FLOAT, np.ascontiguousarray(img[::-1])) # row 0 → bottom
943
+ gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_MIN_FILTER, gl.GL_LINEAR)
944
+ gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_MAG_FILTER, gl.GL_LINEAR)
945
+ gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_WRAP_S, gl.GL_REPEAT)
946
+ gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_WRAP_T, gl.GL_CLAMP_TO_EDGE)
947
+ levels = int(math.log2(size)) + 1
948
+ cube = _cube_texture(size, levels)
949
+ _render_cube_faces(gl_state, cube, 0, equirect_pass,
950
+ equirect=GLTexture(eq, gl.GL_TEXTURE_2D, img.shape[:2], gl.GL_RGB16F))
951
+ gl.glDeleteTextures([eq])
952
+ else:
953
+ fn = ENVIRONMENTS[preset]
954
+ levels = int(math.log2(size)) + 1
955
+ cube = _cube_texture(size, levels)
956
+ gl.glBindTexture(gl.GL_TEXTURE_CUBE_MAP, cube.texture_id)
957
+ grid = (np.arange(size) + 0.5) / size * 2.0 - 1.0
958
+ u, v = np.meshgrid(grid, grid)
959
+ for i, (axis, ua, va) in enumerate(_FACES):
960
+ d = (np.asarray(axis, np.float64)[None, :] + u.reshape(-1, 1) * np.asarray(ua)[None, :]
961
+ + v.reshape(-1, 1) * np.asarray(va)[None, :])
962
+ d = d / np.linalg.norm(d, axis=1, keepdims=True)
963
+ face = fn(d).astype(np.float32).reshape(size, size, 3)
964
+ gl.glTexSubImage2D(gl.GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, 0, 0, 0, size, size,
965
+ gl.GL_RGB, gl.GL_FLOAT, np.ascontiguousarray(face))
966
+ gl.glBindTexture(gl.GL_TEXTURE_CUBE_MAP, 0)
967
+ gl.glBindTexture(gl.GL_TEXTURE_CUBE_MAP, cube.texture_id)
968
+ gl.glGenerateMipmap(gl.GL_TEXTURE_CUBE_MAP)
969
+ gl.glBindTexture(gl.GL_TEXTURE_CUBE_MAP, 0)
970
+ return cube
971
+
972
+
973
+ def environment(gl_state: GLState, preset="studio", strength=1.0, size=256,
974
+ irradiance_size=32, prefilter_size=128) -> Environment:
975
+ """The baked IBL for `preset` — an HDRIS photo or an ENVIRONMENTS
976
+ procedural map — once per (gl_state, preset, sizes). `strength` scales
977
+ the ambient term (stamped on the returned object, read live)."""
978
+ if preset not in HDRIS and preset not in ENVIRONMENTS:
979
+ preset = "studio"
980
+
981
+ def create():
982
+ gl.glEnable(gl.GL_TEXTURE_CUBE_MAP_SEAMLESS)
983
+ source = _source_cube(gl_state, preset, size)
984
+ irr = _cube_texture(irradiance_size, 1)
985
+ _render_cube_faces(gl_state, irr, 0, irradiance_pass, source=source,
986
+ source_lod=float(int(math.log2(size / 64))))
987
+ levels = 5
988
+ pre = _cube_texture(prefilter_size, levels)
989
+ for level in range(levels):
990
+ _render_cube_faces(gl_state, pre, level, prefilter_pass, source=source,
991
+ source_size=float(size), roughness=level / (levels - 1))
992
+ # the BRDF LUT: a 2-D RG16F target on the bake framebuffer
993
+ lut = _scalar(gl.glGenTextures(1))
994
+ gl.glBindTexture(gl.GL_TEXTURE_2D, lut)
995
+ gl.glTexImage2D(gl.GL_TEXTURE_2D, 0, gl.GL_RG16F, 256, 256, 0, gl.GL_RG, gl.GL_FLOAT, None)
996
+ gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_MIN_FILTER, gl.GL_LINEAR)
997
+ gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_MAG_FILTER, gl.GL_LINEAR)
998
+ gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_WRAP_S, gl.GL_CLAMP_TO_EDGE)
999
+ gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_WRAP_T, gl.GL_CLAMP_TO_EDGE)
1000
+ fbo = _bake_fbo(gl_state)
1001
+ prev_fbo = _scalar(gl.glGetIntegerv(gl.GL_DRAW_FRAMEBUFFER_BINDING))
1002
+ prev_vp = gl.glGetIntegerv(gl.GL_VIEWPORT)
1003
+ gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, fbo)
1004
+ gl.glFramebufferTexture2D(gl.GL_FRAMEBUFFER, gl.GL_COLOR_ATTACHMENT0, gl.GL_TEXTURE_2D, lut, 0)
1005
+ gl.glViewport(0, 0, 256, 256)
1006
+ gl.glDisable(gl.GL_DEPTH_TEST)
1007
+ brdf_lut_pass(gl_state)
1008
+ gl.glFramebufferTexture2D(gl.GL_FRAMEBUFFER, gl.GL_COLOR_ATTACHMENT0, gl.GL_TEXTURE_2D, 0, 0)
1009
+ gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, prev_fbo)
1010
+ gl.glViewport(int(prev_vp[0]), int(prev_vp[1]), int(prev_vp[2]), int(prev_vp[3]))
1011
+ lut_tex = GLTexture(lut, gl.GL_TEXTURE_2D, (256, 256), gl.GL_RG16F)
1012
+ return Environment(preset, source, irr, pre, lut_tex, levels - 1)
1013
+
1014
+ def delete(env):
1015
+ gl.glDeleteTextures([env.source.texture_id, env.irradiance.texture_id,
1016
+ env.prefiltered.texture_id, env.brdf_lut.texture_id])
1017
+
1018
+ env = gl_state.get(("pbr_env", preset, size, irradiance_size, prefilter_size), create, delete,
1019
+ deps=(preset, size, irradiance_size, prefilter_size))
1020
+ env.strength = float(strength)
1021
+ return env
1022
+
1023
+
1024
+ # ═══════════════════════════════════════════════════════════════════════════
1025
+ # The shader
1026
+ # ═══════════════════════════════════════════════════════════════════════════
1027
+
1028
+ PBR_VERT = """
1029
+ #version 330 core
1030
+ layout(location = 0) in vec3 a_position;
1031
+ layout(location = 1) in vec3 a_normal;
1032
+ out vec3 v_world;
1033
+ out vec3 v_normal;
1034
+ void main() {
1035
+ vec4 world = model * vec4(a_position, 1.0);
1036
+ v_world = world.xyz;
1037
+ v_normal = normal_matrix * a_normal;
1038
+ gl_Position = proj * view * world;
1039
+ }
1040
+ """
1041
+
1042
+ PBR_FRAG = """
1043
+ #version 330 core
1044
+ in vec3 v_world;
1045
+ in vec3 v_normal;
1046
+ out vec4 FragColor;
1047
+ uniform samplerCube irradiance_map;
1048
+ uniform samplerCube prefilter_map;
1049
+ uniform sampler2D brdf_lut;
1050
+ uniform sampler2DShadow shadow_map;
1051
+ const float PI = 3.14159265359;
1052
+
1053
+ // Visibility from the shadow-casting light (light 0). The receiving point
1054
+ // is pushed along its normal by a texel's worth of world space (normal
1055
+ // offset — kills acne on surfaces at a grazing angle to the light without
1056
+ // a big depth bias) and projected into light clip space; a 4x4 grid of
1057
+ // hardware-compared bilinear taps (16 taps x 4 texels) spread over
1058
+ // `shadow_softness` texels gives the soft edge. Outside the map, or with
1059
+ // shadows off, = fully lit.
1060
+ float shadow_visibility(vec3 world, vec3 N, float NdotL) {
1061
+ if (!shadow_on) return 1.0;
1062
+ vec3 offset_world = world + N * shadow_normal_offset * (1.0 - NdotL * 0.5);
1063
+ vec4 lc = light_matrix * vec4(offset_world, 1.0);
1064
+ vec3 p = lc.xyz / lc.w * 0.5 + 0.5;
1065
+ if (p.z > 1.0 || p.x < 0.0 || p.x > 1.0 || p.y < 0.0 || p.y > 1.0) return 1.0;
1066
+ // receiver-plane depth bias: each tap compares against the depth the
1067
+ // receiving SURFACE has at that tap, not the centre's — the light-space
1068
+ // depth gradient over the map's uv, from the screen-space derivatives
1069
+ // (a wide kernel on a slanted receiver striped itself otherwise)
1070
+ vec3 dpdx = dFdx(p), dpdy = dFdy(p);
1071
+ float det = dpdx.x * dpdy.y - dpdx.y * dpdy.x;
1072
+ vec2 dz_duv = vec2(0.0);
1073
+ if (abs(det) > 1e-12) {
1074
+ dz_duv = vec2(dpdy.y * dpdx.z - dpdx.y * dpdy.z,
1075
+ dpdx.x * dpdy.z - dpdy.x * dpdx.z) / det;
1076
+ }
1077
+ float z = p.z - shadow_bias;
1078
+ float lit = 0.0;
1079
+ float step = shadow_texel * shadow_softness * 0.5;
1080
+ float slope_cap = shadow_texel * shadow_softness * 2.0;
1081
+ for (int x = 0; x < 4; x++)
1082
+ for (int y = 0; y < 4; y++) {
1083
+ vec2 o = (vec2(x, y) - 1.5) * step;
1084
+ float dz = clamp(dot(o, dz_duv), -slope_cap, slope_cap);
1085
+ lit += texture(shadow_map, vec3(p.xy + o, z + dz));
1086
+ }
1087
+ return lit / 16.0;
1088
+ }
1089
+
1090
+ // ── Cook-Torrance terms (learnopengl.com/PBR/Theory) ──
1091
+ float distribution_ggx(vec3 N, vec3 H, float rough) {
1092
+ float a = rough * rough, a2 = a * a;
1093
+ float NdotH = max(dot(N, H), 0.0);
1094
+ float d = NdotH * NdotH * (a2 - 1.0) + 1.0;
1095
+ return a2 / (PI * d * d);
1096
+ }
1097
+ float geometry_schlick_ggx(float NdotV, float rough) {
1098
+ float r = rough + 1.0;
1099
+ float k = (r * r) / 8.0;
1100
+ return NdotV / (NdotV * (1.0 - k) + k);
1101
+ }
1102
+ float geometry_smith(vec3 N, vec3 V, vec3 L, float rough) {
1103
+ return geometry_schlick_ggx(max(dot(N, V), 0.0), rough)
1104
+ * geometry_schlick_ggx(max(dot(N, L), 0.0), rough);
1105
+ }
1106
+ vec3 fresnel_schlick(float cos_theta, vec3 F0) {
1107
+ return F0 + (1.0 - F0) * pow(clamp(1.0 - cos_theta, 0.0, 1.0), 5.0);
1108
+ }
1109
+ vec3 fresnel_schlick_roughness(float cos_theta, vec3 F0, float rough) {
1110
+ return F0 + (max(vec3(1.0 - rough), F0) - F0) * pow(clamp(1.0 - cos_theta, 0.0, 1.0), 5.0);
1111
+ }
1112
+ vec3 aces(vec3 x) {
1113
+ return clamp((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), 0.0, 1.0);
1114
+ }
1115
+
1116
+ void main() {
1117
+ vec3 N = normalize(v_normal);
1118
+ vec3 V = normalize(camera_pos - v_world);
1119
+ float rough = clamp(roughness, 0.04, 1.0);
1120
+ if (shadow_catcher && !shadow_on) { FragColor = vec4(0.0); return; }
1121
+ vec3 albedo = color;
1122
+ vec3 F0 = mix(vec3(0.04), albedo, metallic);
1123
+
1124
+ // ── direct lighting: the packed lights, rows of (xyz, kind) / (rgb, I) ──
1125
+ vec3 Lo = vec3(0.0);
1126
+ for (int i = 0; i < 4; i++) {
1127
+ vec4 lp = light_pos[i]; // column i = light i (pack_lights)
1128
+ vec4 lc = light_color[i];
1129
+ if (lp.w < 0.5) continue;
1130
+ vec3 L; float attenuation;
1131
+ if (lp.w > 1.5) { L = normalize(lp.xyz); attenuation = 1.0; }
1132
+ else { vec3 to = lp.xyz - v_world; float d2 = max(dot(to, to), 1e-4);
1133
+ L = to * inversesqrt(d2); attenuation = 1.0 / d2; }
1134
+ vec3 H = normalize(V + L);
1135
+ vec3 radiance = lc.rgb * lc.w * attenuation;
1136
+ if (i == 0) {
1137
+ float vis = shadow_visibility(v_world, N, max(dot(N, L), 0.0));
1138
+ if (shadow_catcher) {
1139
+ // the catcher: nothing but the shadow it receives, as a
1140
+ // darkening the view composites over its own background
1141
+ FragColor = vec4(0.0, 0.0, 0.0, shadow_opacity * (1.0 - vis));
1142
+ return;
1143
+ }
1144
+ radiance *= vis;
1145
+ }
1146
+ float NDF = distribution_ggx(N, H, rough);
1147
+ float G = geometry_smith(N, V, L, rough);
1148
+ vec3 F = fresnel_schlick(max(dot(H, V), 0.0), F0);
1149
+ vec3 kD = (vec3(1.0) - F) * (1.0 - metallic);
1150
+ float NdotL = max(dot(N, L), 0.0);
1151
+ vec3 specular = (NDF * G * F) / (4.0 * max(dot(N, V), 0.0) * NdotL + 1e-4);
1152
+ Lo += (kD * albedo / PI + specular) * radiance * NdotL;
1153
+ }
1154
+
1155
+ if (shadow_catcher) { FragColor = vec4(0.0); return; } // no shadow light reached it
1156
+
1157
+ // ── image-based lighting (learnopengl Specular-IBL): the baked
1158
+ // irradiance map for the diffuse term, the prefiltered map at the
1159
+ // roughness' mip and the BRDF LUT for the split-sum specular ──
1160
+ float NdotV = max(dot(N, V), 0.0);
1161
+ vec3 F = fresnel_schlick_roughness(NdotV, F0, rough);
1162
+ vec3 kD = (1.0 - F) * (1.0 - metallic);
1163
+ vec3 irradiance = texture(irradiance_map, N).rgb;
1164
+ vec3 diffuse = irradiance * albedo;
1165
+ vec3 R = reflect(-V, N);
1166
+ vec3 prefiltered = textureLod(prefilter_map, R, rough * env_max_lod).rgb;
1167
+ vec2 brdf = texture(brdf_lut, vec2(NdotV, rough)).rg;
1168
+ vec3 spec_ibl = prefiltered * (F * brdf.x + brdf.y);
1169
+ vec3 ambient = (kD * diffuse + spec_ibl) * ao * env_strength * env_tint;
1170
+
1171
+ vec3 c = ambient + Lo + emissive;
1172
+ c = aces(c * exposure);
1173
+ // Linear out: the target is the fp16 scene (hdr_color.py), the
1174
+ // presentation pass encodes once.
1175
+ FragColor = vec4(c, 1.0);
1176
+ }
1177
+ """
1178
+
1179
+
1180
+ SHADOW_VERT = """
1181
+ #version 330 core
1182
+ layout(location = 0) in vec3 a_position;
1183
+ void main() { gl_Position = light_matrix * model * vec4(a_position, 1.0); }
1184
+ """
1185
+
1186
+ SHADOW_FRAG = """
1187
+ #version 330 core
1188
+ void main() { }
1189
+ """
1190
+
1191
+
1192
+ @shader_func(fragment=SHADOW_FRAG, vertex=SHADOW_VERT)
1193
+ def shadow_pass(gl_state: GLState = None, mesh=None, model=None, light_matrix=None, **kwargs):
1194
+ gl.glBindVertexArray(mesh.vao)
1195
+ gl.glDrawElements(gl.GL_TRIANGLES, mesh.count, gl.GL_UNSIGNED_INT, None)
1196
+
1197
+
1198
+ def _shadow_target(gl_state: GLState, size):
1199
+ """A depth-only framebuffer + its depth texture (sampled as a plain
1200
+ sampler2D, compared in the shader with PCF), once per (gl_state, size)."""
1201
+ def create():
1202
+ tex = _scalar(gl.glGenTextures(1))
1203
+ gl.glBindTexture(gl.GL_TEXTURE_2D, tex)
1204
+ gl.glTexImage2D(gl.GL_TEXTURE_2D, 0, gl.GL_DEPTH_COMPONENT24, size, size, 0,
1205
+ gl.GL_DEPTH_COMPONENT, gl.GL_FLOAT, None)
1206
+ # sampled as sampler2DShadow, the hardware compares the reference
1207
+ # depth against the 4 nearest texels and bilinearly blends the
1208
+ # result - every tap of the kernel below is already a smooth PCF
1209
+ gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_MIN_FILTER, gl.GL_LINEAR)
1210
+ gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_MAG_FILTER, gl.GL_LINEAR)
1211
+ gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_COMPARE_MODE, gl.GL_COMPARE_REF_TO_TEXTURE)
1212
+ gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_COMPARE_FUNC, gl.GL_LEQUAL)
1213
+ gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_WRAP_S, gl.GL_CLAMP_TO_BORDER)
1214
+ gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_WRAP_T, gl.GL_CLAMP_TO_BORDER)
1215
+ gl.glTexParameterfv(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_BORDER_COLOR, (1.0, 1.0, 1.0, 1.0))
1216
+ fbo = _scalar(gl.glGenFramebuffers(1))
1217
+ gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, fbo)
1218
+ gl.glFramebufferTexture2D(gl.GL_FRAMEBUFFER, gl.GL_DEPTH_ATTACHMENT, gl.GL_TEXTURE_2D, tex, 0)
1219
+ gl.glDrawBuffer(gl.GL_NONE)
1220
+ gl.glReadBuffer(gl.GL_NONE)
1221
+ gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, _frame_framebuffer())
1222
+ return (fbo, GLTexture(tex, gl.GL_TEXTURE_2D, (size, size), gl.GL_DEPTH_COMPONENT24))
1223
+
1224
+ def delete(v):
1225
+ gl.glDeleteTextures([v[1].texture_id])
1226
+ gl.glDeleteFramebuffers(1, [int(v[0])])
1227
+
1228
+ return gl_state.get(("pbr_shadow", size), create, delete, deps=(size,))
1229
+
1230
+
1231
+ def _msaa_target(gl_state: GLState, key, width, height, samples):
1232
+ """A multisampled render target (RGBA8 colour + 24-bit depth
1233
+ renderbuffers) the shaded pass draws into; end_scene resolves it into
1234
+ the plain FBO with a blit. Re-made on size / sample-count change."""
1235
+ def create():
1236
+ color = _scalar(gl.glGenRenderbuffers(1))
1237
+ gl.glBindRenderbuffer(gl.GL_RENDERBUFFER, color)
1238
+ gl.glRenderbufferStorageMultisample(gl.GL_RENDERBUFFER, samples, gl.GL_RGBA16F, width, height)
1239
+ depth = _scalar(gl.glGenRenderbuffers(1))
1240
+ gl.glBindRenderbuffer(gl.GL_RENDERBUFFER, depth)
1241
+ gl.glRenderbufferStorageMultisample(gl.GL_RENDERBUFFER, samples, gl.GL_DEPTH_COMPONENT24,
1242
+ width, height)
1243
+ gl.glBindRenderbuffer(gl.GL_RENDERBUFFER, 0)
1244
+ fbo = _scalar(gl.glGenFramebuffers(1))
1245
+ gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, fbo)
1246
+ gl.glFramebufferRenderbuffer(gl.GL_FRAMEBUFFER, gl.GL_COLOR_ATTACHMENT0, gl.GL_RENDERBUFFER, color)
1247
+ gl.glFramebufferRenderbuffer(gl.GL_FRAMEBUFFER, gl.GL_DEPTH_ATTACHMENT, gl.GL_RENDERBUFFER, depth)
1248
+ ok = gl.glCheckFramebufferStatus(gl.GL_FRAMEBUFFER) == gl.GL_FRAMEBUFFER_COMPLETE
1249
+ gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, _frame_framebuffer())
1250
+ if not ok:
1251
+ gl.glDeleteFramebuffers(1, [fbo]); gl.glDeleteRenderbuffers(2, [color, depth])
1252
+ raise RuntimeError("multisample framebuffer incomplete")
1253
+ return (fbo, color, depth)
1254
+
1255
+ def delete(v):
1256
+ gl.glDeleteFramebuffers(1, [int(v[0])])
1257
+ gl.glDeleteRenderbuffers(2, [int(v[1]), int(v[2])])
1258
+
1259
+ return gl_state.get(("pbr_msaa", key), create, delete, deps=(width, height, samples))
1260
+
1261
+
1262
+ @shader_func(fragment=PBR_FRAG, vertex=PBR_VERT)
1263
+ def pbr_pass(gl_state: GLState = None, mesh=None, model=None, normal_matrix=None,
1264
+ view=None, proj=None, camera_pos=(0.0, 0.0, 5.0),
1265
+ color=(0.8, 0.8, 0.8), roughness=0.5, metallic=0.0, ao=1.0,
1266
+ emissive=(0.0, 0.0, 0.0), light_pos=None, light_color=None,
1267
+ irradiance_map=None, prefilter_map=None, brdf_lut=None,
1268
+ env_max_lod=4.0, env_strength=1.0, env_tint=(1.0, 1.0, 1.0), exposure=1.0,
1269
+ shadow_map=None, light_matrix=None, shadow_on=False, shadow_texel=1.0 / 2048,
1270
+ shadow_softness=1.0, shadow_bias=0.001, shadow_normal_offset=0.01,
1271
+ shadow_catcher=False, shadow_opacity=0.6, **kwargs):
1272
+ # Program bound, uniforms set - one indexed draw of the mesh.
1273
+ gl.glBindVertexArray(mesh.vao)
1274
+ gl.glDrawElements(gl.GL_TRIANGLES, mesh.count, gl.GL_UNSIGNED_INT, None)
1275
+
1276
+
1277
+ # ═══════════════════════════════════════════════════════════════════════════
1278
+ # === Scene - the immediate-mode frame
1279
+ # ═══════════════════════════════════════════════════════════════════════════
1280
+
1281
+ class Scene:
1282
+ """One frame's render target + camera + lights + environment, between
1283
+ begin_scene (binds the FBO, cleared to transparent, depth on) and
1284
+ end_scene (restores GL state). `fbo` is the GLState FBO the image landed
1285
+ in (`fbo.texture_id` for imgui.image)."""
1286
+
1287
+ def __init__(self, gl_state: GLState, key, width, height, camera: Camera,
1288
+ lights=(), environment=None, exposure=1.0, clear_color=(0, 0, 0, 0),
1289
+ shadows=True, shadow_size=2048, shadow_softness=1.0, shadow_opacity=0.6,
1290
+ environment_tint=(1.0, 1.0, 1.0), shadow_extent=2.5, samples=4):
1291
+ self.gl_state = gl_state
1292
+ # multisample anti-aliasing: the shaded pass draws into a
1293
+ # `samples`-sample target, resolved into the FBO at the end (0 or 1
1294
+ # = off, straight into the FBO)
1295
+ self.samples = int(samples)
1296
+ # multiplies the whole environment (image-based) term - a view's environment
1297
+ # colouring the light its scene sits in
1298
+ self.environment_tint = tuple(float(c) for c in environment_tint)[:3]
1299
+ self.key, self.width, self.height = key, int(width), int(height)
1300
+ self.camera = camera
1301
+ self.lights = list(lights)
1302
+ self.environment = environment
1303
+ self.exposure = float(exposure)
1304
+ self.clear_color = tuple(clear_color)
1305
+ # Shadows come from light 0. The frame's draws are RECORDED (the
1306
+ # draw_* calls are ignored in the API) and rendered at end_scene:
1307
+ # first a depth pass from the light over every caster, then the
1308
+ # main pass sampling it - two passes need the whole list.
1309
+ self.shadows = bool(shadows) and bool(self.lights)
1310
+ self.shadow_size, self.shadow_softness = int(shadow_size), float(shadow_softness)
1311
+ self.shadow_opacity = float(shadow_opacity)
1312
+ # the shadow camera's far plane to this many caster-radii past
1313
+ # the casters, so the ground their shadow lands on - the catcher's
1314
+ # business - is inside the frustum (at 1.0 a long cast is clipped)
1315
+ self.shadow_extent = float(shadow_extent)
1316
+ self._shadow_world_texel = 0.01 # stamped by _light_space (normal-offset bias)
1317
+ self.fbo = None
1318
+ self._view = self._proj = None
1319
+ self._light_pos = self._light_color = None
1320
+ self._draws = []
1321
+
1322
+ def begin(self):
1323
+ self.fbo = self.gl_state.fbo(self.key, self.width, self.height)
1324
+ self._view = self.camera.view()
1325
+ self._proj = self.camera.projection(self.width / max(1, self.height))
1326
+ self._light_pos, self._light_color = pack_lights(self.lights)
1327
+ self._draws = []
1328
+ return self
1329
+
1330
+ def _light_space(self):
1331
+ """The shadow camera: light 0 looking at the casters' bounding
1332
+ sphere — a perspective frustum from a point light, an orthographic
1333
+ box along a directional one. None when nothing casts."""
1334
+ light = self.lights[0]
1335
+ pts = []
1336
+ for d in self._draws:
1337
+ if not d["cast_shadow"]:
1338
+ continue
1339
+ lo, hi = d["mesh"].bounds
1340
+ corners = np.array([[x, y, z, 1.0] for x in (lo[0], hi[0]) for y in (lo[1], hi[1])
1341
+ for z in (lo[2], hi[2])])
1342
+ pts.append((d["model"] @ corners.T).T[:, :3])
1343
+ if not pts:
1344
+ return None
1345
+ pts = np.vstack(pts)
1346
+ center = (pts.min(axis=0) + pts.max(axis=0)) * 0.5
1347
+ # `fit` hugs the casters. it sets the field of view (every shadowed
1348
+ # point lies on a ray from the light THROUGH a caster, so the
1349
+ # casters' cone already holds every shadow they throw - expanding the
1350
+ # fov only spends map texels on empty ground) and the near plane;
1351
+ # `shadow_extent` stretches the FAR plane so the ground the shadow
1352
+ # lands on is inside the frustum (it was clipped at dist + fit)
1353
+ fit = max(float(np.linalg.norm(pts - center, axis=1).max()), 1e-3) * 1.05
1354
+ reach = fit * self.shadow_extent
1355
+ # a map texel's size in world units at the casters (the normal offset)
1356
+ self._shadow_world_texel = 2.0 * fit / self.shadow_size
1357
+ if light.directional:
1358
+ direction = _normalize(light.position)
1359
+ eye = center + direction * fit * 3.0
1360
+ up = (0, 1, 0) if abs(direction[1]) < 0.99 else (1, 0, 0)
1361
+ return orthographic(fit, fit, fit * 2.0, fit * 3.0 + reach) @ look_at(eye, center, up)
1362
+ eye = np.asarray(light.position, np.float64)
1363
+ dist = float(np.linalg.norm(eye - center))
1364
+ if dist < 1e-6:
1365
+ return None
1366
+ # a light inside the casters' sphere gets the widest possible frustum
1367
+ fov = 2.0 * math.asin(min(0.985, fit / dist)) * 1.1 if dist > fit else 2.8
1368
+ up = (0, 1, 0) if abs(_normalize(center - eye)[1]) < 0.99 else (1, 0, 0)
1369
+ return perspective(fov, 1.0, max(0.02, dist - fit), dist + reach) @ look_at(eye, center, up)
1370
+
1371
+ def end(self):
1372
+ depth_was = bool(gl.glIsEnabled(gl.GL_DEPTH_TEST))
1373
+ cull_was = bool(gl.glIsEnabled(gl.GL_CULL_FACE))
1374
+ blend_was = bool(gl.glIsEnabled(gl.GL_BLEND))
1375
+ gl.glEnable(gl.GL_DEPTH_TEST)
1376
+ gl.glDepthFunc(gl.GL_LESS)
1377
+ gl.glEnable(gl.GL_CULL_FACE)
1378
+ # ── pass 1: depth from the light ──
1379
+ light_matrix = self._light_space() if self.shadows else None
1380
+ shadow_tex = None
1381
+ if light_matrix is not None:
1382
+ fbo, shadow_tex = _shadow_target(self.gl_state, self.shadow_size)
1383
+ prev_fbo = _scalar(gl.glGetIntegerv(gl.GL_DRAW_FRAMEBUFFER_BINDING))
1384
+ prev_vp = gl.glGetIntegerv(gl.GL_VIEWPORT)
1385
+ gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, fbo)
1386
+ gl.glViewport(0, 0, self.shadow_size, self.shadow_size)
1387
+ gl.glClear(gl.GL_DEPTH_BUFFER_BIT)
1388
+ # back-face culling as in the main pass: one-sided surfaces (a
1389
+ # disc, a plane) must write their own depth, and a self-shadow
1390
+ # against whatever lies right beneath them (front culling striped
1391
+ # the puckered shadow); the normal-offset bias handles the acne
1392
+ gl.glCullFace(gl.GL_BACK)
1393
+ for d in self._draws:
1394
+ if d["cast_shadow"]:
1395
+ shadow_pass(self.gl_state, mesh=d["mesh"], model=d["model"], light_matrix=light_matrix)
1396
+ gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, prev_fbo)
1397
+ gl.glViewport(int(prev_vp[0]), int(prev_vp[1]), int(prev_vp[2]), int(prev_vp[3]))
1398
+ # ── pass 2: the shaded frame; catchers last so they blend over
1399
+ # whatever they don't occlude - into the multisample target when
1400
+ # anti-aliasing is on ──
1401
+ msaa = None
1402
+ if self.samples > 1:
1403
+ try:
1404
+ msaa = _msaa_target(self.gl_state, self.key, self.width, self.height, self.samples)
1405
+ except Exception as e: # no multisample support: draw aliased
1406
+ if not getattr(self, "_msaa_warned", False):
1407
+ print(f"[pbr] anti-aliasing off: {e}")
1408
+ self._msaa_warned = True
1409
+ self.fbo.bind()
1410
+ if msaa is not None:
1411
+ gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, msaa[0])
1412
+ gl.glEnable(gl.GL_MULTISAMPLE)
1413
+ gl.glCullFace(gl.GL_BACK)
1414
+ gl.glEnable(gl.GL_BLEND)
1415
+ gl.glBlendFuncSeparate(gl.GL_SRC_ALPHA, gl.GL_ONE_MINUS_SRC_ALPHA, gl.GL_ONE, gl.GL_ONE_MINUS_SRC_ALPHA)
1416
+ gl.glClearColor(*self.clear_color)
1417
+ gl.glClear(gl.GL_COLOR_BUFFER_BIT | gl.GL_DEPTH_BUFFER_BIT)
1418
+ env = self.environment
1419
+ common = dict(view=self._view, proj=self._proj,
1420
+ camera_pos=tuple(float(c) for c in self.camera.eye),
1421
+ light_pos=self._light_pos, light_color=self._light_color,
1422
+ irradiance_map=env.irradiance if env is not None else None,
1423
+ prefilter_map=env.prefiltered if env is not None else None,
1424
+ brdf_lut=env.brdf_lut if env is not None else None,
1425
+ env_max_lod=float(env.max_lod) if env is not None else 0.0,
1426
+ env_strength=float(env.strength) if env is not None else 0.0,
1427
+ env_tint=self.environment_tint, exposure=self.exposure,
1428
+ shadow_map=shadow_tex, light_matrix=light_matrix if light_matrix is not None else np.eye(4),
1429
+ shadow_on=light_matrix is not None, shadow_texel=1.0 / self.shadow_size,
1430
+ shadow_softness=self.shadow_softness, shadow_opacity=self.shadow_opacity,
1431
+ shadow_normal_offset=self._shadow_world_texel * 1.5)
1432
+ for d in sorted(self._draws, key=lambda d: d["shadow_catcher"]):
1433
+ pbr_pass(self.gl_state, mesh=d["mesh"], model=d["model"], normal_matrix=d["normal_matrix"],
1434
+ color=d["color"], roughness=d["roughness"], metallic=d["metallic"], ao=d["ao"],
1435
+ emissive=d["emissive"], shadow_catcher=d["shadow_catcher"], **common)
1436
+ self._draws = []
1437
+ if msaa is not None:
1438
+ # resolve: the multisample colour into the FBO's texture
1439
+ gl.glBindFramebuffer(gl.GL_READ_FRAMEBUFFER, msaa[0])
1440
+ gl.glBindFramebuffer(gl.GL_DRAW_FRAMEBUFFER, self.fbo.fbo)
1441
+ gl.glBlitFramebuffer(0, 0, self.width, self.height, 0, 0, self.width, self.height,
1442
+ gl.GL_COLOR_BUFFER_BIT, gl.GL_NEAREST)
1443
+ gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, self.fbo.fbo)
1444
+ if not depth_was:
1445
+ gl.glDisable(gl.GL_DEPTH_TEST)
1446
+ if not cull_was:
1447
+ gl.glDisable(gl.GL_CULL_FACE)
1448
+ if not blend_was:
1449
+ gl.glDisable(gl.GL_BLEND)
1450
+ self.fbo.unbind()
1451
+ return self.fbo
1452
+
1453
+
1454
+ def draw(self, mesh: Mesh, *, position=(0, 0, 0), rotation=(0, 0, 0), scale=(1, 1, 1),
1455
+ transform=None, color=(0.8, 0.8, 0.8), roughness=0.5, metallic=0.0, ao=1.0,
1456
+ emissive=(0.0, 0.0, 0.0), cast_shadow=True, shadow_catcher=False):
1457
+ """One draw of `mesh` with this material / transform (recorded;
1458
+ rendered by end_scene). `shadow_catcher`: the surface is invisible
1459
+ and shows only the shadow it receives. `cast_shadow=False` keeps a
1460
+ mesh out of the depth pass."""
1461
+ model = np.asarray(transform, np.float64) if transform is not None \
1462
+ else model_matrix(position, rotation, scale)
1463
+ self._draws.append(dict(
1464
+ mesh=mesh, model=model, normal_matrix=np.linalg.inv(model[:3, :3]).T,
1465
+ color=tuple(float(c) for c in color)[:3], roughness=float(roughness),
1466
+ metallic=float(metallic), ao=float(ao), emissive=tuple(float(c) for c in emissive)[:3],
1467
+ cast_shadow=bool(cast_shadow) and not shadow_catcher, shadow_catcher=bool(shadow_catcher)))
1468
+
1469
+
1470
+ def begin_scene(gl_state: GLState, key, width, height, camera: Camera, lights=(),
1471
+ environment=None, exposure=1.0, clear_color=(0, 0, 0, 0), shadows=True,
1472
+ shadow_size=2048, shadow_softness=1.0, shadow_opacity=0.6,
1473
+ environment_tint=(1.0, 1.0, 1.0), shadow_extent=2.5, samples=4) -> Scene:
1474
+ """Open a frame: the FBO under `key` at width × height. Returns the
1475
+ Scene every draw_* takes as its first arg. Shadows (from light 0) are
1476
+ on by default; `shadow_softness` widens the PCF kernel in texels."""
1477
+ return Scene(gl_state, key, width, height, camera, lights, environment,
1478
+ exposure, clear_color, shadows, shadow_size, shadow_softness, shadow_opacity,
1479
+ environment_tint, shadow_extent, samples).begin()
1480
+
1481
+
1482
+ def end_scene(scene: Scene):
1483
+ """Render the recorded draws (depth pass, then the shaded pass), restore
1484
+ GL state; returns the FBO (`.texture_id` for imgui.image)."""
1485
+ return scene.end()
1486
+
1487
+
1488
+ def mesh_func(builder, **mesh_defaults):
1489
+ """Turn a mesh generator into an immediate-mode draw function:
1490
+
1491
+ @mesh_func(cylinder_mesh, edge_radius=0.0, segments=48)
1492
+ def draw_cylinder(**kw): ...
1493
+
1494
+ The decorated name becomes `draw_cylinder(scene, position=, rotation=,
1495
+ scale=, transform=, color=, roughness=, metallic=, ao=, emissive=,
1496
+ <mesh kwargs>)`. Kwargs naming the builder's own parameters
1497
+ (`edge_radius`, `segments`) select / build the mesh variant, cached on
1498
+ the scene's GLState under the builder name + those values; everything
1499
+ else is material and transform, handed to Scene.draw. The body of the
1500
+ decorated function is never called — like @shader_func, the signature
1501
+ is the contract."""
1502
+ mesh_keys = tuple(mesh_defaults)
1503
+
1504
+ def deco(func):
1505
+ name = func.__name__
1506
+
1507
+ def draw(scene: Scene, **kwargs):
1508
+ mesh_kwargs = {k: kwargs.pop(k, v) for k, v in mesh_defaults.items()}
1509
+ key = (name,) + tuple((k, _mesh_key_value(mesh_kwargs[k])) for k in mesh_keys)
1510
+ cache = scene.gl_state.peek(("pbr_mesh",) + key)
1511
+ if cache is None or key not in _mesh_counts:
1512
+ mesh = upload_mesh(scene.gl_state, key, builder(**mesh_kwargs))
1513
+ else:
1514
+ mesh = Mesh(cache[0], *_mesh_counts[key])
1515
+ scene.draw(mesh, **kwargs)
1516
+ draw.__name__ = name
1517
+ draw.__doc__ = func.__doc__
1518
+ draw.builder = builder
1519
+ return draw
1520
+ return deco
1521
+
1522
+
1523
+ _mesh_counts: dict = {}
1524
+
1525
+
1526
+ def _mesh_key_value(v):
1527
+ return round(float(v), 4) if isinstance(v, float) else v
1528
+
1529
+
1530
+ @mesh_func(cube_mesh)
1531
+ def draw_cube(scene, **kwargs):
1532
+ """Unit cube centred on `position`, sized by `scale`."""
1533
+
1534
+
1535
+ @mesh_func(cylinder_mesh, edge_radius=0.0, segments=48)
1536
+ def draw_cylinder(scene, **kwargs):
1537
+ """Unit cylinder (radius 1, height 1, axis Y) centred on `position`;
1538
+ `scale=(r, h, r)` sizes it, `edge_radius` (fraction of the radius)
1539
+ rounds its rim."""
1540
+
1541
+
1542
+ @mesh_func(sphere_mesh, segments=48, rings=24)
1543
+ def draw_sphere(scene, **kwargs):
1544
+ """Unit sphere centred on `position`; `scale` sets the radius."""
1545
+
1546
+
1547
+ @mesh_func(plane_mesh)
1548
+ def draw_plane(scene, **kwargs):
1549
+ """Unit square facing +Y centred on `position`; `scale=(w, 1, d)`."""
1550
+
1551
+
1552
+ @mesh_func(extrude_polygon_mesh, points=((0, 0), (1, 0), (0, 1)), height=1.0)
1553
+ def draw_prism(scene, **kwargs):
1554
+ """A convex polygon (`points`, (x, z) pairs, counter-clockwise from
1555
+ above) extruded `height` along Y, centred on `position`."""
1556
+
1557
+
1558
+ @mesh_func(rounded_box_mesh, radius=0.1, segments=8)
1559
+ def draw_rounded_box(scene, **kwargs):
1560
+ """Unit box centred on `position` with rounded edges (`radius` as a
1561
+ fraction of the half-size); `scale` sizes it — note the rounding scales
1562
+ with each axis, so keep the radius small on a flat, wide slab."""
1563
+
1564
+
1565
+ def draw_mesh(scene, mesh, key=None, **kwargs):
1566
+ """Draw a MeshData (or a Model part) with the usual material / transform
1567
+ kwargs. `key` identifies the upload on the GLState — default: the
1568
+ MeshData's identity (a Model's parts are stable objects, so a loaded
1569
+ model uploads once)."""
1570
+ key = ("mesh", id(mesh)) if key is None else ("mesh",) + tuple(key)
1571
+ cache = scene.gl_state.peek(("pbr_mesh",) + key)
1572
+ if cache is None or key not in _mesh_counts:
1573
+ uploaded = upload_mesh(scene.gl_state, key, mesh)
1574
+ else:
1575
+ uploaded = Mesh(cache[0], *_mesh_counts[key])
1576
+ scene.draw(uploaded, **kwargs)