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.
- meltygui/__init__.py +107 -0
- meltygui/accounts/__init__.py +0 -0
- meltygui/accounts/internet_accounts.py +1355 -0
- meltygui/chat/__init__.py +91 -0
- meltygui/chat/activity.py +75 -0
- meltygui/chat/backends.py +36 -0
- meltygui/chat/chat_interface.py +732 -0
- meltygui/chat/chat_proxy.py +352 -0
- meltygui/chat/codex_proxy.py +592 -0
- meltygui/chat/codex_settings.py +100 -0
- meltygui/chat/codex_transport.py +60 -0
- meltygui/chat/command_parser.py +204 -0
- meltygui/chat/images.py +227 -0
- meltygui/chat/messages.py +417 -0
- meltygui/chat/metadata.py +139 -0
- meltygui/chat/writer_locks.py +64 -0
- meltygui/code/__init__.py +0 -0
- meltygui/code/basic_converters.py +533 -0
- meltygui/code/chain_converters.py +2111 -0
- meltygui/code/code_checks.py +2209 -0
- meltygui/code/core_syntax.py +1430 -0
- meltygui/code/file_converters.py +1933 -0
- meltygui/code/fileref.py +702 -0
- meltygui/code/hotswap_guard.py +144 -0
- meltygui/code/libcst_conversion.py +9724 -0
- meltygui/code/live_instrument.py +392 -0
- meltygui/code/live_view.py +2490 -0
- meltygui/code/melty_scan.py +2684 -0
- meltygui/code/new_codecs.py +1255 -0
- meltygui/code/new_converters.py +3017 -0
- meltygui/code/project_code.py +278 -0
- meltygui/code/source_context.py +63 -0
- meltygui/code/symbol_roster.py +1588 -0
- meltygui/code/syntax_check.py +34 -0
- meltygui/code/syntax_check_worker.py +114 -0
- meltygui/completion/__init__.py +0 -0
- meltygui/completion/fim.py +1232 -0
- meltygui/completion/fim_context.py +481 -0
- meltygui/completion/providers/__init__.py +0 -0
- meltygui/completion/providers/anthropic_oauth.py +446 -0
- meltygui/completion/providers/anthropic_requests.py +69 -0
- meltygui/completion/providers/claude.py +203 -0
- meltygui/completion/providers/claude_usage.py +499 -0
- meltygui/completion/providers/codex_accounts.py +180 -0
- meltygui/completion/providers/copilot.py +617 -0
- meltygui/completion/providers/oauth_popup.py +220 -0
- meltygui/completion/providers/ollama.py +320 -0
- meltygui/completion/providers/profiles.py +23 -0
- meltygui/core/README.md +88 -0
- meltygui/core/__init__.py +1 -0
- meltygui/core/automation/__init__.py +1 -0
- meltygui/core/automation/action_core.py +153 -0
- meltygui/core/automation/collection_action.py +45 -0
- meltygui/core/automation/mcp_eval.py +167 -0
- meltygui/core/automation/mcp_hotswap.py +107 -0
- meltygui/core/automation/mcp_query.py +552 -0
- meltygui/core/automation/mcp_server.py +657 -0
- meltygui/core/automation/orchestration_core.py +2636 -0
- meltygui/core/automation/query_core.py +124 -0
- meltygui/core/automation/search_core.py +26 -0
- meltygui/core/automation/selector_core.py +340 -0
- meltygui/core/automation/value_core.py +1752 -0
- meltygui/core/cache/__init__.py +1 -0
- meltygui/core/cache/cache_diagnostics.py +0 -0
- meltygui/core/cache/invalidation_decoration.py +153 -0
- meltygui/core/cache/invalidation_tracker.py +43 -0
- meltygui/core/cache/tile_cache.py +5968 -0
- meltygui/core/conversion/__init__.py +1 -0
- meltygui/core/conversion/bubbling.py +599 -0
- meltygui/core/conversion/cache_tree.py +188 -0
- meltygui/core/conversion/chain.py +113 -0
- meltygui/core/conversion/converter_register.py +145 -0
- meltygui/core/conversion/data_decoration.py +67 -0
- meltygui/core/conversion/dict_conversion.py +1815 -0
- meltygui/core/conversion/dict_conversion_util.py +177 -0
- meltygui/core/conversion/dynamic_obj.py +89 -0
- meltygui/core/conversion/graph_compare.py +183 -0
- meltygui/core/conversion/load_save_v2.py +1032 -0
- meltygui/core/conversion/missing_saved_class.py +39 -0
- meltygui/core/conversion/path_finder.py +606 -0
- meltygui/core/conversion/render_host.py +1083 -0
- meltygui/core/core_render.py +6537 -0
- meltygui/core/definition_hotswap.py +231 -0
- meltygui/core/diagnostics/__init__.py +1 -0
- meltygui/core/diagnostics/attribute_churn.py +38 -0
- meltygui/core/diagnostics/fps_counter.py +39 -0
- meltygui/core/diagnostics/gpu_frame_timer.py +103 -0
- meltygui/core/diagnostics/inspection_core.py +169 -0
- meltygui/core/diagnostics/monitor_core.py +105 -0
- meltygui/core/diagnostics/notifications.py +706 -0
- meltygui/core/diagnostics/perf_trace.py +281 -0
- meltygui/core/diagnostics/profile_decoration.py +88 -0
- meltygui/core/diagnostics/resize_trace.py +62 -0
- meltygui/core/diagnostics/screenshot_core.py +247 -0
- meltygui/core/diagnostics/session_status.py +98 -0
- meltygui/core/diagnostics/trace_core.py +445 -0
- meltygui/core/files/__init__.py +1 -0
- meltygui/core/files/file_core.py +208 -0
- meltygui/core/files/file_explorer_core.py +104 -0
- meltygui/core/files/file_tree_core.py +198 -0
- meltygui/core/files/file_watch_core.py +43 -0
- meltygui/core/files/import_graph_core.py +43 -0
- meltygui/core/files/metadata_core.py +51 -0
- meltygui/core/graphics/__init__.py +1 -0
- meltygui/core/graphics/cuda_context_core.py +166 -0
- meltygui/core/graphics/cuda_interop_core.py +136 -0
- meltygui/core/graphics/cuda_kernel_core.py +91 -0
- meltygui/core/graphics/framebuffer_recorder.py +337 -0
- meltygui/core/graphics/gl_state.py +658 -0
- meltygui/core/graphics/lut_core.py +52 -0
- meltygui/core/graphics/overlay_renderer.py +984 -0
- meltygui/core/graphics/scene_target.py +180 -0
- meltygui/core/graphics/screenshot.py +439 -0
- meltygui/core/graphics/shader_func.py +478 -0
- meltygui/core/graphics/tensor_core.py +45 -0
- meltygui/core/graphics/text_texture.py +329 -0
- meltygui/core/graphics/wayland_color.py +635 -0
- meltygui/core/input/__init__.py +1 -0
- meltygui/core/input/collision.py +165 -0
- meltygui/core/input/drag_drop_core.py +1525 -0
- meltygui/core/input/hypr_left_drag.py +323 -0
- meltygui/core/input/input_core.py +245 -0
- meltygui/core/input/input_handler.py +1101 -0
- meltygui/core/input/mouse_cursor.py +355 -0
- meltygui/core/input/pynput_backend.py +1054 -0
- meltygui/core/input/space_mouse.py +338 -0
- meltygui/core/input/touchpad_backend.py +393 -0
- meltygui/core/input/view_selection.py +177 -0
- meltygui/core/layout/__init__.py +1 -0
- meltygui/core/layout/column_core.py +2153 -0
- meltygui/core/layout/cursor_core.py +161 -0
- meltygui/core/layout/dropdown_core.py +278 -0
- meltygui/core/layout/edge_constraints.py +155 -0
- meltygui/core/layout/grid_core.py +117 -0
- meltygui/core/layout/header_core.py +23 -0
- meltygui/core/layout/header_runtime.py +67 -0
- meltygui/core/layout/layout_core.py +87 -0
- meltygui/core/layout/tile_manager_core.py +591 -0
- meltygui/core/melty.py +6726 -0
- meltygui/core/module_map.json +896 -0
- meltygui/core/module_names.py +19 -0
- meltygui/core/rendering/__init__.py +1 -0
- meltygui/core/rendering/core_decoration.py +431 -0
- meltygui/core/rendering/core_render_helpers.py +328 -0
- meltygui/core/rendering/func_metadata.py +398 -0
- meltygui/core/rendering/mode.py +818 -0
- meltygui/core/rendering/mode_defaults.py +41 -0
- meltygui/core/rendering/modes.py +136 -0
- meltygui/core/rendering/parameter_core.py +1665 -0
- meltygui/core/rendering/render_dispatch.py +1891 -0
- meltygui/core/rendering/render_funcs.py +273 -0
- meltygui/core/rendering/shaped.py +312 -0
- meltygui/core/rendering/window_decoration.py +25 -0
- meltygui/core/runtime/__init__.py +1 -0
- meltygui/core/runtime/app.py +735 -0
- meltygui/core/runtime/app_session.py +140 -0
- meltygui/core/runtime/background.py +564 -0
- meltygui/core/runtime/extensions.py +53 -0
- meltygui/core/runtime/gc_manager.py +1163 -0
- meltygui/core/runtime/lifecycle.py +21 -0
- meltygui/core/runtime/paths.py +27 -0
- meltygui/core/runtime/settings.py +14 -0
- meltygui/core/runtime/singleton.py +16 -0
- meltygui/core/runtime/thread_safe_bool.py +24 -0
- meltygui/core/runtime/thread_signal.py +30 -0
- meltygui/core/runtime/toggles.py +3115 -0
- meltygui/core/services/__init__.py +1 -0
- meltygui/core/services/account_core.py +10 -0
- meltygui/core/services/chat_core.py +19 -0
- meltygui/core/services/claude_terminal_core.py +346 -0
- meltygui/core/services/terminal_core.py +458 -0
- meltygui/core/services/terminal_runtime.py +78 -0
- meltygui/core/styling/__init__.py +1 -0
- meltygui/core/styling/color_core.py +46 -0
- meltygui/core/styling/fonts.py +639 -0
- meltygui/core/styling/global_style.py +338 -0
- meltygui/core/styling/style.py +198 -0
- meltygui/core/styling/style_core.py +522 -0
- meltygui/core/styling/warm_start.py +149 -0
- meltygui/core/windowing/__init__.py +1 -0
- meltygui/core/windowing/backends/PYIMGUI_LICENSE +28 -0
- meltygui/core/windowing/backends/__init__.py +1 -0
- meltygui/core/windowing/backends/imgui_renderer.py +138 -0
- meltygui/core/windowing/backends/native_wayland.py +850 -0
- meltygui/core/windowing/backends/protocols/xdg-decoration-unstable-v1.xml +156 -0
- meltygui/core/windowing/backends/protocols/xdg-shell.xml +1420 -0
- meltygui/core/windowing/backends/wayland_protocol.py +101 -0
- meltygui/core/windowing/dock_core.py +182 -0
- meltygui/core/windowing/frame_geometry.py +42 -0
- meltygui/core/windowing/geometry_feed.py +864 -0
- meltygui/core/windowing/glfw_utils.py +1343 -0
- meltygui/core/windowing/os_frame.py +1552 -0
- meltygui/core/windowing/surface.py +643 -0
- meltygui/core/windowing/titlebar.py +1560 -0
- meltygui/core/windowing/titlebar_buttons.py +281 -0
- meltygui/core/windowing/wayland_move.py +932 -0
- meltygui/core/windowing/window_api.py +62 -0
- meltygui/core/windowing/window_constants.py +339 -0
- meltygui/core/windowing/window_visibility.py +162 -0
- meltygui/debug/__init__.py +0 -0
- meltygui/debug/app_view_utils.py +9 -0
- meltygui/editor/__init__.py +0 -0
- meltygui/editor/bash_syntax.py +30 -0
- meltygui/editor/code_line_fast.py +152 -0
- meltygui/editor/diff.py +139 -0
- meltygui/editor/external_changes.py +159 -0
- meltygui/editor/file_header.py +41 -0
- meltygui/editor/live_usage.py +169 -0
- meltygui/editor/live_view_views.py +1803 -0
- meltygui/editor/pending_save.py +1351 -0
- meltygui/editor/roster_tints.py +547 -0
- meltygui/editor/source_preview.py +16 -0
- meltygui/editor/source_tools.py +10 -0
- meltygui/editor/source_ui.py +70 -0
- meltygui/editor/spell_check.py +77 -0
- meltygui/editor/text_editor.py +9076 -0
- meltygui/editor/usage_picker.py +538 -0
- meltygui/events/__init__.py +0 -0
- meltygui/events/example.py +118 -0
- meltygui/examples/__init__.py +0 -0
- meltygui/examples/columns_demo.py +33 -0
- meltygui/examples/columns_window_demo.py +60 -0
- meltygui/examples/context_menu_demo.py +24 -0
- meltygui/examples/context_menu_window_demo.py +49 -0
- meltygui/examples/gui_playground.py +127 -0
- meltygui/examples/live_view_playground.py +256 -0
- meltygui/examples/lora.py +43 -0
- meltygui/examples/lora_data.py +59 -0
- meltygui/examples/lora_policies.py +67 -0
- meltygui/examples/mode_demo.py +49 -0
- meltygui/examples/modifies_demo.py +119 -0
- meltygui/examples/scalar_policies.py +60 -0
- meltygui/examples/style_layouts.py +149 -0
- meltygui/examples/tile_manager_demo.py +74 -0
- meltygui/examples/tint_demo.py +160 -0
- meltygui/examples/tint_functions.py +56 -0
- meltygui/examples/trace_demo.py +88 -0
- meltygui/examples/two_windows.py +36 -0
- meltygui/files/__init__.py +0 -0
- meltygui/files/fast_file_explorer.py +439 -0
- meltygui/gnome_extension/lsd-window-geometry@latent-descent/extension.js +237 -0
- meltygui/gnome_extension/lsd-window-geometry@latent-descent/lsd-window-geometry@latent-descent.iml +9 -0
- meltygui/gnome_extension/lsd-window-geometry@latent-descent/metadata.json +7 -0
- meltygui/graphics/__init__.py +6 -0
- meltygui/graphics/base.py +85 -0
- meltygui/graphics/examples.py +507 -0
- meltygui/graphics/executor.py +520 -0
- meltygui/graphics/filter.py +667 -0
- meltygui/graphics/filter.pyi +856 -0
- meltygui/graphics/generate_stubs.py +22 -0
- meltygui/graphics/registry.py +203 -0
- meltygui/graphics/shader_compiler.py +155 -0
- meltygui/graphics/shaders.py +1302 -0
- meltygui/graphics/stub_generator.py +250 -0
- meltygui/graphics/texture_manager.py +170 -0
- meltygui/graphics/texture_min_max.py +295 -0
- meltygui/hdr_color.py +757 -0
- meltygui/image_load.py +308 -0
- meltygui/model/__init__.py +1 -0
- meltygui/model/account_model.py +142 -0
- meltygui/model/camera_model.py +159 -0
- meltygui/model/chat_model.py +86 -0
- meltygui/model/code_model.py +62 -0
- meltygui/model/code_proxy_model.py +876 -0
- meltygui/model/collection_model.py +41 -0
- meltygui/model/color_model.py +127 -0
- meltygui/model/cuda_tensor_model.py +36 -0
- meltygui/model/cuda_texture_model.py +149 -0
- meltygui/model/dropdown_model.py +137 -0
- meltygui/model/file_metadata_model.py +73 -0
- meltygui/model/file_model.py +289 -0
- meltygui/model/format_model.py +390 -0
- meltygui/model/graph_model.py +98 -0
- meltygui/model/icon_model.py +1024 -0
- meltygui/model/import_graph_model.py +468 -0
- meltygui/model/layout_model.py +15 -0
- meltygui/model/lut_model.py +266 -0
- meltygui/model/search_model.py +173 -0
- meltygui/model/tensor_model.py +402 -0
- meltygui/model/terminal_model.py +329 -0
- meltygui/model/texture_model.py +146 -0
- meltygui/model/tile_model.py +24 -0
- meltygui/model/trace_model.py +198 -0
- meltygui/model/trace_report_model.py +146 -0
- meltygui/models/__init__.py +0 -0
- meltygui/models/file_meta.py +613 -0
- meltygui/models/function_console.py +184 -0
- meltygui/models/orchestration.py +48 -0
- meltygui/pbr.py +1576 -0
- meltygui/png_unfilter.c +49 -0
- meltygui/resources/JetBrainsMono-Regular.ttf +0 -0
- meltygui/resources/THIRD_PARTY_NOTICES.md +21 -0
- meltygui/resources/dejavu/DejaVuSans-Bold.ttf +0 -0
- meltygui/resources/dejavu/DejaVuSans-ExtraLight.ttf +0 -0
- meltygui/resources/dejavu/DejaVuSans.ttf +0 -0
- meltygui/resources/dejavu/LICENSE.txt +78 -0
- meltygui/resources/fontawesome-LICENSE.txt +121 -0
- meltygui/resources/fontawesome-webfont.ttf +0 -0
- meltygui/resources/hdri/studio_small_09_1k.hdr +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-Bold.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraBold.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraLight.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-Light.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-Medium.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-SemiBold.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-Thin.ttf +0 -0
- meltygui/resources/jetbrains-weights/OFL.txt +93 -0
- meltygui/resources/jetbrains-weights/README.md +2 -0
- meltygui/state/__init__.py +0 -0
- meltygui/state/account_state.py +22 -0
- meltygui/state/animation_state.py +97 -0
- meltygui/state/annotation_state.py +22 -0
- meltygui/state/chat_state.py +36 -0
- meltygui/state/code_state.py +10 -0
- meltygui/state/core_enums.py +59 -0
- meltygui/state/core_markers.py +115 -0
- meltygui/state/core_undo.py +1075 -0
- meltygui/state/file_state.py +75 -0
- meltygui/state/graph_state.py +26 -0
- meltygui/state/inspection_state.py +47 -0
- meltygui/state/menu_state.py +10 -0
- meltygui/state/model_enums.py +11 -0
- meltygui/state/new_core_model.py +2394 -0
- meltygui/state/orchestration_state.py +14 -0
- meltygui/state/query_state.py +24 -0
- meltygui/state/tensor_state.py +10 -0
- meltygui/state/terminal_state.py +23 -0
- meltygui/state/trace_state.py +32 -0
- meltygui/state/voxel_state.py +12 -0
- meltygui/text_index.py +816 -0
- meltygui/utils/__init__.py +0 -0
- meltygui/utils/jump_to_code.py +344 -0
- meltygui/utils/pkl_inspect.py +90 -0
- meltygui/utils/render_utils.py +1419 -0
- meltygui/view/__init__.py +1 -0
- meltygui/view/account_view.py +524 -0
- meltygui/view/action_view.py +73 -0
- meltygui/view/chat_decoration_view.py +112 -0
- meltygui/view/chat_view.py +1703 -0
- meltygui/view/code_view.py +2677 -0
- meltygui/view/collection_view.py +1185 -0
- meltygui/view/color_view.py +824 -0
- meltygui/view/control_view.py +559 -0
- meltygui/view/decoration_view.py +439 -0
- meltygui/view/diagnostic_view.py +177 -0
- meltygui/view/dropdown_view.py +1082 -0
- meltygui/view/file_view.py +1599 -0
- meltygui/view/graph_cuda_view.py +105 -0
- meltygui/view/graph_view.py +882 -0
- meltygui/view/header_view.py +848 -0
- meltygui/view/input_view.py +238 -0
- meltygui/view/inspection_view.py +1783 -0
- meltygui/view/layout_view.py +596 -0
- meltygui/view/lut_view.py +45 -0
- meltygui/view/menu_view.py +212 -0
- meltygui/view/orchestration_view.py +658 -0
- meltygui/view/query_view.py +118 -0
- meltygui/view/search_view.py +342 -0
- meltygui/view/tab_view.py +258 -0
- meltygui/view/tensor_view.py +430 -0
- meltygui/view/terminal_view.py +355 -0
- meltygui/view/text_view.py +8031 -0
- meltygui/view/texture_view.py +480 -0
- meltygui/view/tile_view.py +38 -0
- meltygui/view/trace_view.py +923 -0
- meltygui/view/voxel_cuda_view.py +824 -0
- meltygui/view/voxel_view.py +1860 -0
- meltygui/view/window_view.py +111 -0
- meltygui-0.1.0.dist-info/METADATA +143 -0
- meltygui-0.1.0.dist-info/RECORD +372 -0
- meltygui-0.1.0.dist-info/WHEEL +4 -0
- 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)
|